Files
Lauri Ojansivu 7858d84e12 Import and export many boards at once for every format, and document each format in its own directory.
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 !
2026-10-09 10:33:37 +03:00

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;
});
}