mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
Import many boards: on the import page of every source, several export files, or one .zip holding them, each become their own board through the same path a single import of that file takes, and the page lists what each file became (models/lib/importManyFiles.js). A .zip that is itself one export (Vikunja, Notion, Plane, WeKan with attachments) stays one board. One board per project: for the generalized importer, each swimlane the import would make - a project, folder or list of the tool - becomes its own board (models/lib/importSplit.js), so an export of a whole app imports as many boards, as a Trello .zip does. Kanri's all-data export now reads every board, not only the first, so it splits into one board per Kanri board. Export all boards: All Boards sidebar, or Multi-Selection for the selected boards, downloads every board the user may export in one format, each checked as its own export is. Excel is one workbook with a sheet per board, named after the board; every other format is a .zip with one file per board (server/routes/exportAllBoards.js). The per-format bytes are made in one place, server/lib/renderExternalExport.js, for the board route and this. A WeKan .zip export imports as a new board from All Boards, unpacked on the server with its attachments (it was refused in the browser). api.py gains importboardsfrom and exportallboards, sends every source in the shape the import page sends, and fetches Excel, PDF, CSV and TSV exports from their own routes and writes them as bytes. Fixed while documenting: a Trello Butler rule's list name kept its case, so the rule fires; the HTML export fetches the board JSON by URL instead of a link the export popup no longer has; Tasks.org exports time spent; Nextcloud Deck's done flag marks the due date done. docs/Features/ImportExport has a directory per format with the steps to import and export it, one board and many, and its details, moved from Format-Coverage.md, which is now the index and the shared contract. tests/importExportDocsCoverage.test.cjs fails when a format has no page. Thanks to xet7 !
65 lines
3.4 KiB
JavaScript
65 lines
3.4 KiB
JavaScript
// "One board per project": a document of the generalized importer (the
|
|
// Kanboard-shaped board every external parser makes, models/kanboardCreator.js)
|
|
// split into one document per swimlane, so a tool's export that holds several
|
|
// projects - Vikunja's, Plane's and Redmine's projects, Wrike's folders,
|
|
// ClickUp's lists - becomes one board per project, as a Trello .zip of many
|
|
// boards does. Plain JavaScript, so tests/importSplit.test.cjs runs it in Node.
|
|
//
|
|
// Each part keeps every list (a project shares its tool's workflow) - or,
|
|
// when the parser says which lists each swimlane had (`swimlane_columns`, as
|
|
// Kanri's all-data export does, whose boards have columns of their own), just
|
|
// those - the labels and the board-level data, takes the swimlane's name as its board name
|
|
// and puts its cards in its Default swimlane. A parent card or a dependency on
|
|
// a card that went to another board cannot be kept: it is reported in that
|
|
// part's loss report. The source's own report goes with the first part, so it
|
|
// is told once.
|
|
|
|
export const MAX_SPLIT_BOARDS = 200;
|
|
const laneOf = task => task.swimlane_name || task.swimlane || 'Default';
|
|
|
|
export function splitBySwimlane(parsed) {
|
|
const tasks = Array.isArray(parsed && parsed.tasks) ? parsed.tasks : [];
|
|
const lanes = [...new Set(tasks.map(laneOf))];
|
|
if (lanes.length < 2) return [parsed];
|
|
if (lanes.length > MAX_SPLIT_BOARDS) {
|
|
throw new Error(`the import has ${lanes.length} swimlanes; one board per project makes at most ${MAX_SPLIT_BOARDS} boards`);
|
|
}
|
|
const laneOfRef = new Map();
|
|
tasks.forEach(task => { if (task.ref !== undefined && task.ref !== null && task.ref !== '') laneOfRef.set(String(task.ref), laneOf(task)); });
|
|
const boardName = (parsed.board && parsed.board.name) || 'Imported board';
|
|
return lanes.map((lane, index) => {
|
|
const unsupported = index === 0 && Array.isArray(parsed.unsupported) ? [...parsed.unsupported] : [];
|
|
const elsewhere = ref => ref !== undefined && ref !== null && laneOfRef.has(String(ref)) && laneOfRef.get(String(ref)) !== lane;
|
|
const own = tasks.filter(task => laneOf(task) === lane).map(task => {
|
|
const copy = { ...task, swimlane_name: 'Default' };
|
|
delete copy.swimlane;
|
|
const where = `/${lane}/${task.ref || task.title}`;
|
|
if (elsewhere(task.parent_ref)) {
|
|
unsupported.push({ path: where, reason: `its parent card "${task.parent_ref}" went to the board "${laneOfRef.get(String(task.parent_ref))}"` });
|
|
delete copy.parent_ref;
|
|
}
|
|
if (Array.isArray(task.dependencies)) {
|
|
copy.dependencies = task.dependencies.filter(dependency => {
|
|
if (!elsewhere(dependency && dependency.ref)) return true;
|
|
unsupported.push({ path: where, reason: `its dependency on "${dependency.ref}" went to the board "${laneOfRef.get(String(dependency.ref))}"` });
|
|
return false;
|
|
});
|
|
}
|
|
return copy;
|
|
});
|
|
const ownColumns = parsed.swimlane_columns && Array.isArray(parsed.swimlane_columns[lane])
|
|
? parsed.swimlane_columns[lane].map(title => ({ title })) : parsed.columns;
|
|
const part = {
|
|
...parsed,
|
|
columns: ownColumns,
|
|
board: { ...(parsed.board || {}), name: lane === 'Default' ? boardName : lane },
|
|
swimlanes: [{ name: 'Default' }],
|
|
tasks: own,
|
|
warnings: index === 0 && Array.isArray(parsed.warnings) ? parsed.warnings : [],
|
|
unsupported,
|
|
};
|
|
delete part.swimlane_columns;
|
|
return part;
|
|
});
|
|
}
|