mirror of
https://github.com/wekan/wekan.git
synced 2026-11-08 03:22:32 -05:00
- #6459: card More menu rendered empty (no Delete): the vendored jade compiler's htmljs patch made isConstructedObject(Array) false, so 'label {{_ ...}}:' lines got their content array mis-assigned as tag attributes and Blaze threw 'foreign objects in attributes' killing the whole cardMorePopup. Also fixed the list More link input (nonexistent rootUrl helper -> absoluteUrl). - #6455: saving a card description never removed a pre-existing unsaved- edits record, and the draft/description comparison mismatched on Markdown hard-breaks and empty descriptions, re-creating a phantom draft on every save. Save now clears the draft; both sides normalized the same way. - #5547: comment drafts: the draft-saving escape handler had been disarmed since 2019 (flag with no setter), so a comment being written was lost when the card closed. Drafts are now saved continuously (debounced), flushed on teardown, cleared on submit; text no longer visually vanishes on outside clicks. - #5282: attachment delete: guard against the doc being already evicted from Minimongo ('Removed nonexistent document'), like the existing comment/checklist guards. - #6472: rules move card to top/bottom: fall back to the card's current list when listName does not resolve (was a swallowed TypeError = rule silently did nothing); wizard stored listTitle which the engine never reads (listName); empty destination wrote sort +-Infinity; rules JSON/CSV import now uses the rules.createRule method instead of client inserts rejected into minimongo limbo, and defaults missing trigger matching fields to '*'; empty boardId no longer overrides the real board; the server-side orphaned-cards fallback in List.cards()/cardsUnfiltered() now actually resolves its async swimlane lookup. - #6465: desktop density back to WeKan 6.09: base font 14px and headings 22/18/16px (vw clamps resolved to 18/24px on desktop), All Boards wrapper padding 10px/15px, card details docks to the right edge instead of floating on top of its own card (still draggable), 20px canvas padding instead of ~48px white borders, removed the force-all-card-text- to-title-size rule, admin org/team table Select/Unselect-all header links are compact icons and header cells may wrap, zoom pill no longer cut off, one-click board settings cog in the board header. - #6458 #6466: snap: mongodb-control now checks mongod's exit status and does an AVX pre-flight (MongoDB 5+ needs AVX; without it mongod SIGILLs) instead of pinging a dead port forever behind a 502: it switches to a completed FerretDB migration, or (re)runs the migration (AVX-free) with a 3-attempt guard. Migration importers no longer fail the whole run on >=10 per-item errors ('Avatars Errors' etc.), and a failed 3.x-source migration keeps the copied FerretDB data + checkpoint and resumes next start instead of deleting it and 'falling back' to a mongod 7 that cannot open 3.x data files. - #6467 #6468: with FerretDB, calm Meteor's no-oplog polling storm (METEOR_POLLING_THROTTLE_MS=2000, METEOR_POLLING_INTERVAL_MS=30000 defaults in the snap and bundle launcher; overridable). The FerretDB fork gets filter pushdown, a sane pool size and less locking in its own repo. - Updated ChangeLog. Thanks to xet7.
329 lines
12 KiB
JavaScript
329 lines
12 KiB
JavaScript
import { ReactiveCache } from '/imports/reactiveCache';
|
||
import { TAPi18n } from '/imports/i18n';
|
||
import Papa from 'papaparse';
|
||
|
||
const RULES_FORMAT = 'wekan-rules-1.0.0';
|
||
const STRIP_FIELDS = ['_id', 'boardId', 'createdAt', 'modifiedAt', 'updatedAt'];
|
||
|
||
function stripDoc(doc) {
|
||
const out = {};
|
||
Object.keys(doc || {}).forEach(key => {
|
||
if (!STRIP_FIELDS.includes(key)) out[key] = doc[key];
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// Build a portable, board-independent list of rules from the CURRENT board.
|
||
function collectBoardRules(boardId) {
|
||
let rules = ReactiveCache.getRules({ boardId });
|
||
const selected = Session.get('selectedRuleIds') || [];
|
||
if (selected.length) {
|
||
rules = rules.filter(r => selected.includes(r._id));
|
||
}
|
||
return rules
|
||
.map(rule => {
|
||
const trigger = ReactiveCache.getTrigger(rule.triggerId);
|
||
const action = ReactiveCache.getAction(rule.actionId);
|
||
if (!trigger || !action) return null;
|
||
return { title: rule.title, trigger: stripDoc(trigger), action: stripDoc(action) };
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
// #6472: the trigger matcher (server/rulesHelper.js buildMatchingFieldsMap)
|
||
// queries every matching field of the trigger type with {$in: [value, '*']} —
|
||
// a trigger DOCUMENT that lacks one of those fields can never match. Hand-
|
||
// written or third-party JSON often omits fields like userId, so default every
|
||
// known matching field to the '*' wildcard when absent. Extra fields on
|
||
// trigger types that do not use them are harmless (never queried).
|
||
const TRIGGER_MATCHING_FIELDS = [
|
||
'userId', 'username', 'cardTitle', 'listName', 'oldListName',
|
||
'swimlaneName', 'checklistName', 'checklistItemName', 'labelId',
|
||
'attachmentName',
|
||
];
|
||
|
||
function normalizeTrigger(trigger) {
|
||
const out = { ...trigger };
|
||
TRIGGER_MATCHING_FIELDS.forEach(f => {
|
||
if (out[f] === undefined || out[f] === null || out[f] === '') out[f] = '*';
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// Insert an array of {title, trigger, action} onto the given target board.
|
||
function importRules(rulesArray, boardId) {
|
||
let count = 0;
|
||
(rulesArray || []).forEach(entry => {
|
||
if (!entry || !entry.trigger || !entry.action) return;
|
||
// #6472: create via the rules.createRule server method (like the rules
|
||
// wizard since #5536) instead of three optimistic client inserts — those
|
||
// are rejected by the board-admin-only allow() rules for non-admins and
|
||
// land in minimongo limbo: the imported rule LOOKS created but never
|
||
// exists on the server, so it silently does nothing until it vanishes.
|
||
Meteor.call(
|
||
'rules.createRule',
|
||
boardId,
|
||
entry.title || 'Imported rule',
|
||
normalizeTrigger(stripDoc(entry.trigger)),
|
||
stripDoc(entry.action),
|
||
);
|
||
count += 1;
|
||
});
|
||
return count;
|
||
}
|
||
|
||
function download(filename, text, mime) {
|
||
const blob = new Blob([text], { type: `${mime};charset=utf-8` });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// --- CSV (round-trippable) --------------------------------------------------
|
||
function rulesToCsv(rulesArray) {
|
||
const rows = rulesArray.map(entry => {
|
||
const { activityType, ...triggerFields } = entry.trigger || {};
|
||
const { actionType, ...actionFields } = entry.action || {};
|
||
return {
|
||
title: entry.title || '',
|
||
triggerType: activityType || '',
|
||
triggerFields: JSON.stringify(triggerFields),
|
||
actionType: actionType || '',
|
||
actionFields: JSON.stringify(actionFields),
|
||
};
|
||
});
|
||
return Papa.unparse(rows, {
|
||
columns: ['title', 'triggerType', 'triggerFields', 'actionType', 'actionFields'],
|
||
});
|
||
}
|
||
|
||
function csvToRules(text) {
|
||
const parsed = Papa.parse(text.trim(), { header: true, skipEmptyLines: true });
|
||
return (parsed.data || [])
|
||
.map(row => {
|
||
let triggerFields = {};
|
||
let actionFields = {};
|
||
try { triggerFields = row.triggerFields ? JSON.parse(row.triggerFields) : {}; } catch (e) { triggerFields = {}; }
|
||
try { actionFields = row.actionFields ? JSON.parse(row.actionFields) : {}; } catch (e) { actionFields = {}; }
|
||
if (!row.triggerType || !row.actionType) return null;
|
||
return {
|
||
title: row.title,
|
||
trigger: { activityType: row.triggerType, ...triggerFields },
|
||
action: { actionType: row.actionType, ...actionFields },
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
// --- Best-effort Trello Butler parser ---------------------------------------
|
||
export function parseTrelloButler(text) {
|
||
const rules = [];
|
||
const unmapped = [];
|
||
(text || '').split('\n').map(l => l.trim()).filter(Boolean).forEach(line => {
|
||
const added = line.toLowerCase().match(/when a card is added to list ["“](.+?)["”].*move the card to the (top|bottom)/);
|
||
if (added) {
|
||
rules.push({
|
||
title: line,
|
||
trigger: { activityType: 'createCard', listName: added[1], swimlaneName: '*', cardTitle: '*', userId: '*' },
|
||
action: { actionType: added[2] === 'top' ? 'moveCardToTop' : 'moveCardToBottom', listName: '*', swimlaneName: '*' },
|
||
});
|
||
return;
|
||
}
|
||
unmapped.push(line);
|
||
});
|
||
return { rules, unmapped };
|
||
}
|
||
|
||
// --- Best-effort visual-workflow parsers (n8n, Node-RED) --------------------
|
||
// These map a workflow graph's trigger→action edges to WeKan rules by keyword.
|
||
// n8n and Node-RED nodes are arbitrary integrations, so only recognized
|
||
// trigger/action node types are mapped; unmapped edges are reported.
|
||
function mapTriggerType(type = '', name = '') {
|
||
const s = `${type} ${name}`.toLowerCase();
|
||
if (/schedule|cron|interval|inject/.test(s)) {
|
||
return { activityType: 'scheduledTrigger', scheduleKind: 'calendar', scheduleType: 'daily', atTime: '09:00', listName: '*', swimlaneName: '*' };
|
||
}
|
||
if (/trigger|webhook|http in|http-in|start/.test(s)) {
|
||
return { activityType: 'createCard', listName: '*', swimlaneName: '*', cardTitle: '*', userId: '*' };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function mapActionType(type = '', name = '') {
|
||
const s = `${type} ${name}`.toLowerCase();
|
||
if (/archive/.test(s)) return { actionType: 'archive' };
|
||
if (/move.*top|to top/.test(s)) return { actionType: 'moveCardToTop', listName: '*', swimlaneName: '*' };
|
||
if (/move.*bottom/.test(s)) return { actionType: 'moveCardToBottom', listName: '*', swimlaneName: '*' };
|
||
if (/complete|done/.test(s)) return { actionType: 'markCardComplete' };
|
||
if (/email|mail|smtp|gmail/.test(s)) return { actionType: 'sendEmail', emailTo: '', emailSubject: 'Imported workflow', emailMsg: '' };
|
||
if (/create.*card|wekan|card/.test(s)) return { actionType: 'createCard', cardName: name || 'Imported card', listName: '*', swimlaneName: '*' };
|
||
return null;
|
||
}
|
||
|
||
export function parseN8n(data) {
|
||
const nodes = data.nodes || [];
|
||
const byName = {};
|
||
nodes.forEach(n => { byName[n.name] = n; });
|
||
const rules = [];
|
||
const unmapped = [];
|
||
const conns = data.connections || {};
|
||
Object.keys(conns).forEach(srcName => {
|
||
const src = byName[srcName];
|
||
if (!src) return;
|
||
const trig = mapTriggerType(src.type, srcName);
|
||
const outs = (conns[srcName].main || []).flat();
|
||
outs.forEach(o => {
|
||
const tgt = o && byName[o.node];
|
||
if (!tgt) return;
|
||
const act = mapActionType(tgt.type, tgt.name);
|
||
if (trig && act) {
|
||
rules.push({ title: `${srcName} → ${tgt.name || o.node}`, trigger: trig, action: act });
|
||
} else {
|
||
unmapped.push(`${srcName} → ${o.node}`);
|
||
}
|
||
});
|
||
});
|
||
return { rules, unmapped };
|
||
}
|
||
|
||
export function parseNodeRed(data) {
|
||
const nodes = Array.isArray(data) ? data : (data.flows || []);
|
||
const byId = {};
|
||
nodes.forEach(n => { byId[n.id] = n; });
|
||
const rules = [];
|
||
const unmapped = [];
|
||
nodes.forEach(n => {
|
||
const trig = mapTriggerType(n.type, n.name);
|
||
if (!trig) return;
|
||
((n.wires || []).flat()).forEach(tid => {
|
||
const tgt = byId[tid];
|
||
if (!tgt) return;
|
||
const act = mapActionType(tgt.type, tgt.name);
|
||
const label = `${n.name || n.type} → ${tgt.name || tgt.type}`;
|
||
if (act) rules.push({ title: label, trigger: trig, action: act });
|
||
else unmapped.push(label);
|
||
});
|
||
});
|
||
return { rules, unmapped };
|
||
}
|
||
|
||
function parseWorkflow(text, format) {
|
||
let data;
|
||
try { data = JSON.parse(text); } catch (e) { return { rules: [], unmapped: [], error: 'invalid JSON' }; }
|
||
let fmt = format;
|
||
if (!fmt || fmt === 'auto') {
|
||
if (data && data.nodes && data.connections) fmt = 'n8n';
|
||
else if (Array.isArray(data) || data.flows) fmt = 'nodered';
|
||
}
|
||
if (fmt === 'n8n') return parseN8n(data);
|
||
if (fmt === 'nodered') return parseNodeRed(data);
|
||
return { rules: [], unmapped: [], error: 'unknown format' };
|
||
}
|
||
|
||
// --- Workspace + board selection helpers ------------------------------------
|
||
function flattenWorkspaces(nodes, depth, out) {
|
||
(nodes || []).forEach(node => {
|
||
out.push({ id: node.id, label: `${' '.repeat(depth)}${node.name}` });
|
||
if (node.children && node.children.length) flattenWorkspaces(node.children, depth + 1, out);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
Template.rulesImportExportPopup.onCreated(function () {
|
||
this.message = new ReactiveVar('');
|
||
this.selectedWorkspace = new ReactiveVar('');
|
||
this.selectedBoard = new ReactiveVar(Session.get('currentBoard'));
|
||
this.subscribe('boards'); // the user's boards (also subscribed globally)
|
||
});
|
||
|
||
Template.rulesImportExportPopup.helpers({
|
||
message() {
|
||
return Template.instance().message;
|
||
},
|
||
workspaces() {
|
||
const user = ReactiveCache.getCurrentUser();
|
||
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
|
||
return flattenWorkspaces(tree, 0, []);
|
||
},
|
||
boardsForImport() {
|
||
const tpl = Template.instance();
|
||
const userId = Meteor.userId();
|
||
const ws = tpl.selectedWorkspace.get();
|
||
const user = ReactiveCache.getCurrentUser();
|
||
const assignments = (user && user.profile && user.profile.boardWorkspaceAssignments) || {};
|
||
let boards = ReactiveCache.getBoards(
|
||
{ archived: false, 'members.userId': userId },
|
||
{ sort: { title: 1 } },
|
||
);
|
||
if (ws) boards = boards.filter(b => assignments[b._id] === ws);
|
||
const selectedBoard = tpl.selectedBoard.get();
|
||
return boards.map(b => ({ _id: b._id, title: b.title, selected: b._id === selectedBoard }));
|
||
},
|
||
});
|
||
|
||
function targetBoardId(tpl) {
|
||
return tpl.selectedBoard.get() || Session.get('currentBoard');
|
||
}
|
||
|
||
function reportImport(tpl, count, unmapped) {
|
||
let msg = TAPi18n.__('r-import-done', { count });
|
||
if (unmapped && unmapped.length) {
|
||
msg += ` — ${TAPi18n.__('r-import-unmapped', { count: unmapped.length })}`;
|
||
}
|
||
tpl.message.set(msg);
|
||
}
|
||
|
||
Template.rulesImportExportPopup.events({
|
||
'change .js-import-workspace'(event, tpl) {
|
||
tpl.selectedWorkspace.set(event.currentTarget.value);
|
||
},
|
||
'change .js-import-board'(event, tpl) {
|
||
tpl.selectedBoard.set(event.currentTarget.value);
|
||
},
|
||
'click .js-rules-export-json'() {
|
||
const boardId = Session.get('currentBoard');
|
||
const data = { _format: RULES_FORMAT, boardId, rules: collectBoardRules(boardId) };
|
||
download('wekan-rules.json', JSON.stringify(data, null, 2), 'application/json');
|
||
},
|
||
'click .js-rules-export-csv'() {
|
||
const boardId = Session.get('currentBoard');
|
||
download('wekan-rules.csv', rulesToCsv(collectBoardRules(boardId)), 'text/csv');
|
||
},
|
||
'click .js-rules-import-json'(event, tpl) {
|
||
const text = tpl.find('.js-rules-import-text').value;
|
||
try {
|
||
const parsed = JSON.parse(text);
|
||
const rulesArray = Array.isArray(parsed) ? parsed : parsed.rules;
|
||
reportImport(tpl, importRules(rulesArray, targetBoardId(tpl)));
|
||
} catch (e) {
|
||
tpl.message.set(String(e.message || e));
|
||
}
|
||
},
|
||
'click .js-rules-import-csv'(event, tpl) {
|
||
const text = tpl.find('.js-rules-import-text').value;
|
||
try {
|
||
reportImport(tpl, importRules(csvToRules(text), targetBoardId(tpl)));
|
||
} catch (e) {
|
||
tpl.message.set(String(e.message || e));
|
||
}
|
||
},
|
||
'click .js-rules-import-trello'(event, tpl) {
|
||
const { rules, unmapped } = parseTrelloButler(tpl.find('.js-rules-import-text').value);
|
||
reportImport(tpl, importRules(rules, targetBoardId(tpl)), unmapped);
|
||
},
|
||
'click .js-rules-import-workflow'(event, tpl) {
|
||
const format = tpl.find('.js-workflow-format').value;
|
||
const { rules, unmapped, error } = parseWorkflow(tpl.find('.js-rules-import-text').value, format);
|
||
if (error) {
|
||
tpl.message.set(error);
|
||
return;
|
||
}
|
||
reportImport(tpl, importRules(rules, targetBoardId(tpl)), unmapped);
|
||
},
|
||
});
|