// Kanban boards stored as plain Markdown, in the same shape as the Obsidian Kanban plugin: // // --- // kanban-plugin: basic <- front matter marks the page as a board // --- // // ## To do <- a column // // - [ ] Write the intro <- a card (text keeps inline Markdown: **bold**, [[links]] …) // with a second line <- indented lines belong to the card above // - [x] Pick a title <- a checked card // // %% kanban:settings ... %% <- anything from a *** or %% line on is kept as is // // Lines in a column that aren't cards (e.g. the plugin's **Complete** marker) are kept too. import { parseMarkdown, htmlToMd } from './markdown.js'; export const BOARD_KEY = 'kanban-plugin'; export const isBoard = (meta) => !!(meta && meta[BOARD_KEY]); let seq = 0; const uid = (p) => p + (++seq).toString(36) + Math.random().toString(36).slice(2, 6); export const card = (text = '', done = false) => ({ id: uid('k'), text, done }); export const column = (title = 'Untitled', cards = []) => ({ id: uid('c'), title, extra: [], cards }); export function parseBoard(src) { const lines = String(src || '').replace(/\r\n?/g, '\n').split('\n'); const board = { head: '', columns: [], tail: '' }; const head = []; let col = null; let last = null; // card that indented lines continue for (let i = 0; i < lines.length; i++) { const line = lines[i]; const t = line.trim(); if (t === '***' || t.startsWith('%% kanban')) { board.tail = lines.slice(i).join('\n').replace(/\s+$/, ''); break; } let m; if ((m = line.match(/^##\s+(.*?)\s*$/))) { col = column(m[1]); board.columns.push(col); last = null; continue; } if (!col) { head.push(line); continue; } if ((m = line.match(/^[-*+]\s+(?:\[([ xX])\]\s?)?(.*)$/))) { last = card(m[2], !!m[1] && m[1] !== ' '); col.cards.push(last); continue; } if (last && /^(\s{2,}|\t)\S/.test(line)) { last.text += '\n' + line.replace(/^(\s{2}|\t)/, ''); continue; } if (line.trim()) { col.extra.push(line); last = null; } } board.head = head.join('\n').trim(); return board; } export function boardToMd(board) { const parts = []; if (board.head) parts.push(board.head); for (const c of board.columns) { const body = [...c.extra, ...c.cards.map((k) => `- [${k.done ? 'x' : ' '}] ` + k.text.replace(/\n/g, '\n '))]; parts.push(`## ${c.title}` + (body.length ? '\n\n' + body.join('\n') : '')); } if (board.tail) parts.push(board.tail); return parts.length ? parts.join('\n\n\n') + '\n' : ''; } export const defaultBoard = () => ({ head: '', tail: '', columns: [column('To do'), column('Doing'), column('Done')] }); // Turn an ordinary page into a board: each ## heading becomes a column, list items and paragraphs become cards. // Anything before the first heading lands in a "To do" column. export function boardFromMarkdown(body) { const blocks = parseMarkdown(body); const columns = []; let col = null; for (const b of blocks) { if (b.type === 'h1' || b.type === 'h2' || b.type === 'h3') { col = column(htmlToMd(b.html).trim() || 'Untitled'); columns.push(col); continue; } const text = b.type === 'code' ? '`' + (b.text || '').split('\n')[0] + '`' : b.type === 'toggle' ? htmlToMd(b.html) : htmlToMd(b.html); if (b.type === 'divider' || b.type === 'image' || !text.trim()) continue; if (!col) { col = column('To do'); columns.unshift(col); } col.cards.push(card(text.trim().replace(/\n+/g, '\n'), !!b.checked)); } if (!columns.length) return defaultBoard(); return { head: '', tail: '', columns }; }