Files
wekan/models/lib/versionControlView.js
Lauri Ojansivu b7589737f5 File docs/Features the way the WeKan menus are.
A feature's guide now sits under the menu it is used from, read from the
templates rather than from where somebody once put it: Header-Bar,
All-Boards, Pages, Board-Header (search and every Board View), Board-Sidebar
(Board Settings, Filter, Members, Labels, Archive), Rules, Swimlanes, Lists,
Cards, Member-Settings, Admin-Panel and Login-Page. Topics used from several
menus (editor, history, theme, translations, email, troubleshooting) are in
General.

- Each area has a README mapping its menu entries to their guides and to the
  template or server module that implements them. Board-Header/README.md
  lists all 40 Board View entries with the template boardBody.jade renders;
  five views (Table, Calendar Mode, Time, Timeline, Map) have no guide of
  their own yet and say so.
- Board-View.md listed 25 views while BOARD_VIEWS has 40; it now lists all
  of them with their English labels.
- Pages keep their file names. Where a released changelog links an old path,
  a "# Moved" page stays there and points to the new one; test-menu.sh skips
  folders holding only those.
- Undo was filed under Login; it is General/Undo now.
- Every link in docs, code comments, scripts and tests follows the move;
  docsLinksResolve checks them all.

Tests that read a doc follow it to its new place. boardViewSettings now fails
when Board-View.md or Board-Header/README.md differs from BOARD_VIEWS, or
names a template boardBody.jade does not render.

Thanks to xet7 !
2026-10-16 23:45:35 +03:00

272 lines
11 KiB
JavaScript

'use strict';
// Board View / Version Control: the board as a GitHub / Gitea repository's
// issue tracker, drawn in the board's own theme
// (client/components/boards/versionControlView.*, design:
// docs/Features/Board-Header/Board-Views/Version-Control-View.md).
//
// Nothing here is a second copy of anything. A card is an issue, the board's
// labels are the labels, a label with a due date (#2802) is a milestone, the
// card's comments are the conversation, its attachments are the files, and
// the board's change history (models/changeHistory.js) is the git log, with
// its undo. An issue is CLOSED when its card is archived or is in a list
// named like GitHub's / Gitea's closed column; everything else is OPEN.
//
// Pure CommonJS, no Meteor: the client, the server methods and
// tests/versionControlView.test.cjs load the same rules.
const TABS = ['issues', 'milestones', 'labels', 'releases', 'wiki', 'files', 'history'];
// The lists the GitHub organization import fills (models/lib/githubOrgImport.js,
// models/lib/githubWiki.js): their cards are releases and wiki pages, not issues.
const RELEASES_LIST_TITLE = 'Releases';
const WIKI_LIST_TITLE = 'Wiki';
// A list with one of these names holds closed issues. The GitHub, GitLab,
// Gitea and Forgejo importers create "Open" and "Closed"
// (models/lib/externalParsers.js); "Done" is WeKan's own default board.
const CLOSED_LIST_NAMES = ['closed', 'done', 'resolved', 'merged', 'wontfix'];
const OPEN_LIST_NAME = 'Open';
const CLOSED_LIST_NAME = 'Closed';
// The importers keep a milestone as the label `milestone:<title>`.
const MILESTONE_PREFIX = 'milestone:';
const SORTS = ['created-desc', 'created-asc', 'updated-desc', 'comments-desc'];
const norm = s => String(s == null ? '' : s).trim().toLowerCase();
function isClosedListTitle(title) {
return CLOSED_LIST_NAMES.includes(norm(title));
}
/** 'open' or 'closed', from the card and the title of its list. */
function issueState(card, listTitle) {
if (!card) return 'open';
if (card.archived) return 'closed';
// A synced issue's state is the tracker's (list sync's "Comments, labels,
// state and files", models/lib/listSyncIssueDetails.js): the card stays in
// its synced list either way.
if (card.syncIssueState === 'open' || card.syncIssueState === 'closed') return card.syncIssueState;
return isClosedListTitle(listTitle) ? 'closed' : 'open';
}
/** A card kept by list sync: its state is changed in the tracker, not here. */
function isSyncedIssue(card) {
return !!(card && card.syncExternalId && card.syncSourceKey);
}
/**
* The issue number: the upstream one for an imported issue (the importers
* end the description with `Source: #<n>`, GitLab's `Source: group/project#<n>`),
* else the card's own number.
*/
function issueNumber(card) {
if (!card) return null;
const match = /(?:^|\n)Source: [^\s#]*#(\d{1,9})\b/.exec(String(card.description || ''));
if (match) return Number(match[1]);
return Number.isInteger(card.cardNumber) && card.cardNumber > 0 ? card.cardNumber : null;
}
/** The description without the importer's `Source: #n <url>` footer. */
function issueBody(description) {
return String(description || '').replace(/\n*Source: [^\s#]*#\d{1,9}(?: \S+)?\s*$/, '').trim();
}
/**
* The milestones of a board: every label with a due date (#2802), and every
* label the importers named `milestone:<title>`. Labels that are milestones
* are not listed again as labels.
*/
function boardMilestones(labels) {
return (Array.isArray(labels) ? labels : [])
.filter(l => l && l._id && (l.dueAt || norm(l.name).startsWith(MILESTONE_PREFIX)))
.map(l => ({
_id: l._id,
title: norm(l.name).startsWith(MILESTONE_PREFIX) ? String(l.name).slice(MILESTONE_PREFIX.length).trim() : String(l.name || l.color || ''),
color: l.color,
dueAt: l.dueAt ? new Date(l.dueAt) : null,
}));
}
function boardPlainLabels(labels) {
const milestoneIds = new Set(boardMilestones(labels).map(m => m._id));
return (Array.isArray(labels) ? labels : []).filter(l => l && l._id && !milestoneIds.has(l._id));
}
// --- The search box: GitHub's query language, the parts WeKan has -----------
//
// is:open is:closed label:"needs review" milestone:v2 author:alice
// assignee:bob no:label no:milestone no:assignee sort:created-asc
// and any other words, searched in the number, title and body.
function tokenize(query) {
const tokens = [];
const re = /(-?)([a-z]+):(?:"([^"]*)"|(\S+))|"([^"]*)"|(\S+)/gi;
let m;
while ((m = re.exec(String(query || '')))) {
if (m[2]) tokens.push({ negate: m[1] === '-', key: m[2].toLowerCase(), value: m[3] != null ? m[3] : m[4] });
else tokens.push({ word: m[5] != null ? m[5] : m[6] });
}
return tokens;
}
function parseQuery(query) {
const q = { state: null, labels: [], notLabels: [], milestone: null, author: null, assignee: null,
no: [], sort: 'created-desc', words: [] };
for (const t of tokenize(query)) {
if (t.word != null) { if (t.word) q.words.push(norm(t.word)); continue; }
const value = t.value;
switch (t.key) {
case 'is':
case 'state':
if (['open', 'closed'].includes(norm(value))) q.state = norm(value);
break;
case 'label':
(t.negate ? q.notLabels : q.labels).push(norm(value));
break;
case 'milestone': q.milestone = norm(value); break;
case 'author': q.author = norm(value).replace(/^@/, ''); break;
case 'assignee': q.assignee = norm(value).replace(/^@/, ''); break;
case 'no':
if (['label', 'milestone', 'assignee'].includes(norm(value))) q.no.push(norm(value));
break;
case 'sort':
if (SORTS.includes(norm(value))) q.sort = norm(value);
break;
default:
// An unknown qualifier is searched as the text it is.
q.words.push(norm(`${t.key}:${value}`));
}
}
return q;
}
/** The query with `key:value` set (or replaced), as clicking a label does. */
function withQualifier(query, key, value) {
const quoted = /\s/.test(value) ? `"${value}"` : value;
const kept = tokenize(query).filter(t => t.word != null || t.key !== key || key === 'label')
.map(t => (t.word != null ? (/\s/.test(t.word) ? `"${t.word}"` : t.word)
: `${t.negate ? '-' : ''}${t.key}:${/\s/.test(t.value) ? `"${t.value}"` : t.value}`));
if (key === 'label' && kept.includes(`label:${quoted}`)) return kept.join(' ');
return [...kept, `${key}:${quoted}`].join(' ').trim();
}
/**
* The issues that match a parsed query, sorted. `issues` are plain rows:
* { _id, number, title, body, state, labelNames: [], milestoneTitles: [],
* author, assignees: [], comments, createdAt, updatedAt }
* Names compare case-insensitively, as GitHub's do.
*/
function filterIssues(issues, query, { ignoreState = false } = {}) {
const q = typeof query === 'string' ? parseQuery(query) : query;
const rows = (Array.isArray(issues) ? issues : []).filter((issue) => {
if (!ignoreState && q.state && issue.state !== q.state) return false;
const labels = (issue.labelNames || []).map(norm);
const milestones = (issue.milestoneTitles || []).map(norm);
const assignees = (issue.assignees || []).map(norm);
if (q.labels.some(l => !labels.includes(l))) return false;
if (q.notLabels.some(l => labels.includes(l))) return false;
if (q.milestone && !milestones.includes(q.milestone)) return false;
if (q.author && norm(issue.author) !== q.author) return false;
if (q.assignee && !assignees.includes(q.assignee)) return false;
if (q.no.includes('label') && labels.length) return false;
if (q.no.includes('milestone') && milestones.length) return false;
if (q.no.includes('assignee') && assignees.length) return false;
if (q.words.length) {
const hay = norm(`#${issue.number || ''} ${issue.title || ''} ${issue.body || ''}`);
if (!q.words.every(w => hay.includes(w))) return false;
}
return true;
});
const time = d => (d ? new Date(d).getTime() : 0);
const by = {
'created-desc': (a, b) => time(b.createdAt) - time(a.createdAt),
'created-asc': (a, b) => time(a.createdAt) - time(b.createdAt),
'updated-desc': (a, b) => time(b.updatedAt || b.createdAt) - time(a.updatedAt || a.createdAt),
'comments-desc': (a, b) => (b.comments || 0) - (a.comments || 0),
}[q.sort] || (() => 0);
return rows.sort((a, b) => by(a, b) || (b.number || 0) - (a.number || 0));
}
/** Open and closed counts of the issues matching the query apart from its state. */
function stateCounts(issues, query) {
const rows = filterIssues(issues, query, { ignoreState: true });
return { open: rows.filter(i => i.state === 'open').length, closed: rows.filter(i => i.state === 'closed').length };
}
/** Each milestone with its open and closed issues and how far along it is. */
function milestoneProgress(milestones, issues, now = new Date()) {
return (Array.isArray(milestones) ? milestones : []).map((m) => {
const mine = (issues || []).filter(i => (i.milestoneIds || []).includes(m._id));
const closed = mine.filter(i => i.state === 'closed').length;
const open = mine.length - closed;
return {
...m,
open,
closed,
percent: mine.length ? Math.round((closed * 100) / mine.length) : 0,
overdue: Boolean(m.dueAt && open > 0 && new Date(m.dueAt).getTime() < new Date(now).getTime()),
};
});
}
/** Each label with how many OPEN issues carry it, as GitHub's labels page. */
function labelUsage(labels, issues) {
return boardPlainLabels(labels).map(l => ({
_id: l._id,
name: l.name || '',
color: l.color,
open: (issues || []).filter(i => i.state === 'open' && (i.labelIds || []).includes(l._id)).length,
}));
}
/** New-issue input, trimmed and bounded, or throws with an i18n key. */
function cleanNewIssue(input) {
const title = String((input && input.title) || '').replace(/\s+/g, ' ').trim();
const body = String((input && input.body) || '').trim();
if (!title) throw new Error('vcs-issue-title-required');
if (title.length > 1000) throw new Error('vcs-issue-title-too-long');
if (body.length > 65536) throw new Error('vcs-issue-body-too-long');
const labelIds = Array.isArray(input && input.labelIds) ? input.labelIds.filter(id => typeof id === 'string').slice(0, 50) : [];
return { title, body, labelIds };
}
/**
* Where a card goes when its issue is closed or reopened: the list to move it
* to, or `create` with the title of the list to make first.
* lists: the board's unarchived lists, in their order
*/
function stateTarget(lists, state) {
const rows = (Array.isArray(lists) ? lists : []).filter(l => l && !l.archived);
if (state === 'closed') {
const closed = rows.find(l => norm(l.title) === 'closed') || rows.find(l => isClosedListTitle(l.title));
return closed ? { listId: closed._id } : { create: CLOSED_LIST_NAME };
}
const open = rows.find(l => norm(l.title) === 'open') || rows.find(l => !isClosedListTitle(l.title));
return open ? { listId: open._id } : { create: OPEN_LIST_NAME };
}
module.exports = {
TABS,
RELEASES_LIST_TITLE,
WIKI_LIST_TITLE,
SORTS,
CLOSED_LIST_NAMES,
MILESTONE_PREFIX,
isClosedListTitle,
issueState,
isSyncedIssue,
issueNumber,
issueBody,
boardMilestones,
boardPlainLabels,
parseQuery,
withQualifier,
filterIssues,
stateCounts,
milestoneProgress,
labelUsage,
cleanNewIssue,
stateTarget,
};