193 lines
5.4 KiB
TypeScript
193 lines
5.4 KiB
TypeScript
import {
|
|
DataAdapter,
|
|
KanbanBoard,
|
|
KanbanCard,
|
|
KanbanConfig,
|
|
KanbanLayout,
|
|
NotebookKanbanGroup,
|
|
} from "./types";
|
|
import resolveScopedFolderIds from "./folderScope";
|
|
import resolveScopedFolderMetadata from "./folderTree";
|
|
import extractGtdBlock from "./gtdBlock";
|
|
import buildKanbanCard from "./buildKanbanCard";
|
|
|
|
export interface KanbanResult {
|
|
layout: KanbanLayout;
|
|
/** @deprecated Phase 4 moves payload consumers to layout. Single-view alias. */
|
|
board: KanbanBoard;
|
|
warnings: string[];
|
|
scannedFolders: number;
|
|
scannedNotes: number;
|
|
cardCount: number;
|
|
}
|
|
|
|
/**
|
|
* Build a three-column kanban board from admitted to-dos and opted-in notes.
|
|
*
|
|
* - notes and todos filters apply independently.
|
|
* - Bucketing: completion wins; else in-progress tag;
|
|
* else Backlog.
|
|
* - done-window filters native completed to-dos only.
|
|
* - Each column sorted per config.
|
|
*
|
|
* The kanban note itself is always excluded.
|
|
*/
|
|
export default async function collectKanban(
|
|
adapter: DataAdapter,
|
|
kanbanNoteId: string,
|
|
kanbanFolderId: string,
|
|
config: KanbanConfig,
|
|
now: Date = new Date()
|
|
): Promise<KanbanResult> {
|
|
const warnings: string[] = [];
|
|
|
|
const folders = await adapter.getFolders();
|
|
const folderMetadata = config.groupByNotebook
|
|
? resolveScopedFolderMetadata(folders, kanbanFolderId)
|
|
: [];
|
|
const folderIds = config.groupByNotebook
|
|
? folderMetadata.map((folder) => folder.id)
|
|
: resolveScopedFolderIds(
|
|
folders,
|
|
kanbanFolderId,
|
|
config.scopeDepth,
|
|
config.scopeAll
|
|
);
|
|
|
|
let scannedNotes = 0;
|
|
const backlog: KanbanCard[] = [];
|
|
const inProgress: KanbanCard[] = [];
|
|
const done: KanbanCard[] = [];
|
|
const grouped = new Map<string, { board: KanbanBoard; warnings: string[] }>();
|
|
for (const folder of folderMetadata) {
|
|
grouped.set(folder.id, {
|
|
board: { backlog: [], inProgress: [], done: [] },
|
|
warnings: [],
|
|
});
|
|
}
|
|
|
|
const doneCutoff =
|
|
config.doneWindow === Infinity
|
|
? -Infinity
|
|
: now.getTime() - config.doneWindow * 24 * 60 * 60 * 1000;
|
|
|
|
// Bodies preserve explicit note opt-in and optional to-do card overrides.
|
|
// When both types are disabled, metadata alone is sufficient for scan totals.
|
|
const includeBody = config.notes !== "none" || config.todos !== "none";
|
|
|
|
for (const folderId of folderIds) {
|
|
const target = grouped.get(folderId);
|
|
const targetBoard = target?.board || { backlog, inProgress, done };
|
|
const notes = await adapter.getNotesInFolder(folderId, includeBody);
|
|
for (const note of notes) {
|
|
scannedNotes += 1;
|
|
if (note.id === kanbanNoteId) continue;
|
|
if (!note.is_todo && config.notes === "none") continue;
|
|
|
|
const result = extractGtdBlock(note.body);
|
|
if (note.is_todo) {
|
|
if (config.todos === "none") continue;
|
|
if (config.todos === "gtd-only" && !result.found) continue;
|
|
} else if (!result.found) {
|
|
continue;
|
|
}
|
|
|
|
if (result.error) {
|
|
const warning = `"${note.title}": gtd block problem — ${result.error}`;
|
|
(target?.warnings || warnings).push(warning);
|
|
}
|
|
|
|
const tags = await adapter.getNoteTagTitles(note.id);
|
|
const card = buildKanbanCard(note, result.block, tags, config.doneTag);
|
|
|
|
// Bucketing — Done wins.
|
|
if (card.completed) {
|
|
if (!card.isTodo || card.completedTime >= doneCutoff) {
|
|
targetBoard.done.push(card);
|
|
}
|
|
// Only native to-dos can be completed outside the timestamp window.
|
|
} else if (tags.includes(config.inProgressTag)) {
|
|
targetBoard.inProgress.push(card);
|
|
} else {
|
|
targetBoard.backlog.push(card);
|
|
}
|
|
}
|
|
}
|
|
|
|
const groups: NotebookKanbanGroup[] = [];
|
|
for (const folder of folderMetadata) {
|
|
const target = grouped.get(folder.id) as {
|
|
board: KanbanBoard;
|
|
warnings: string[];
|
|
};
|
|
sortBoard(target.board, config);
|
|
const cardCount = countBoard(target.board);
|
|
if (cardCount > 0) {
|
|
groups.push({
|
|
folderId: folder.id,
|
|
notebookPath: folder.notebookPath,
|
|
board: target.board,
|
|
cardCount,
|
|
warnings: target.warnings,
|
|
});
|
|
}
|
|
}
|
|
if (!config.groupByNotebook) sortBoard({ backlog, inProgress, done }, config);
|
|
const board = { backlog, inProgress, done };
|
|
const layout: KanbanLayout = config.groupByNotebook
|
|
? { kind: "notebooks", groups }
|
|
: { kind: "single", board };
|
|
const cardCount = config.groupByNotebook
|
|
? groups.reduce((sum, group) => sum + group.cardCount, 0)
|
|
: countBoard(board);
|
|
|
|
return {
|
|
layout,
|
|
board,
|
|
warnings,
|
|
scannedFolders: folderIds.length,
|
|
scannedNotes,
|
|
cardCount,
|
|
};
|
|
}
|
|
|
|
function sortBoard(board: KanbanBoard, config: KanbanConfig): void {
|
|
sortColumn(board.backlog, config);
|
|
sortColumn(board.inProgress, config);
|
|
sortColumn(board.done, config);
|
|
}
|
|
|
|
function countBoard(board: KanbanBoard): number {
|
|
return board.backlog.length + board.inProgress.length + board.done.length;
|
|
}
|
|
|
|
function sortColumn(cards: KanbanCard[], config: KanbanConfig): void {
|
|
const direction = config.sort === "desc" ? -1 : 1;
|
|
|
|
cards.sort((a, b) => {
|
|
let comparison: number;
|
|
|
|
if (config.sortType === "due-date") {
|
|
// Dateless cards always sort after dated ones, regardless of
|
|
// direction.
|
|
const aHas = a.date !== null;
|
|
const bHas = b.date !== null;
|
|
if (aHas && !bHas) return -1;
|
|
if (!aHas && bHas) return 1;
|
|
if (!aHas && !bHas) {
|
|
comparison = 0;
|
|
} else {
|
|
comparison = (a.date as string).localeCompare(b.date as string);
|
|
}
|
|
} else if (config.sortType === "modified-date") {
|
|
comparison = a.updatedTime - b.updatedTime;
|
|
} else {
|
|
comparison = a.title.localeCompare(b.title, undefined, {
|
|
sensitivity: "base",
|
|
});
|
|
}
|
|
|
|
return comparison * direction;
|
|
});
|
|
}
|