Files
Ilia Sharin 2bcedda2c2 Add cold archive feature for done/cancelled items
- Separate archive.md file for archived items (append-only)
- Archive manager in Admin page with filters and search
- Global search shows archive results with count in header
- Restore items to original location via restore-path metadata
- Read-only view dialog for archived items (double-click to open)
- Python API endpoints: GET/POST /api/archive, POST /api/archive/restore
- Support in all storage backends (API, Direct, Browser)
2026-08-20 13:54:06 -04:00

591 lines
25 KiB
React

// Shared helpers, status definitions, filter engine, icons.
const STATUSES = [
{ key: "open", glyph: "[ ]", label: "Open" },
{ key: "in-progress", glyph: "[/]", label: "In progress" },
{ key: "blocked", glyph: "[!]", label: "Blocked" },
{ key: "postponed", glyph: "[>]", label: "Postponed" },
{ key: "done", glyph: "[x]", label: "Done" },
{ key: "cancelled", glyph: "[-]", label: "Cancelled" }
];
const STATUS_BY_KEY = Object.fromEntries(STATUSES.map(s => [s.key, s]));
const STATUS_ORDER = STATUSES.map(s => s.key);
const PRIORITIES = ["P0", "P1", "P2", "P3"];
// ---- date helpers ----
function parseDate(s) { return s ? new Date(s + "T12:00:00Z") : null; }
function daysBetween(a, b) { return Math.round((a - b) / 86400000); }
function isOverdue(item) {
if (!item.due || item.status === "done" || item.status === "cancelled") return false;
return parseDate(item.due) < new Date();
}
function dueRelative(item) {
if (!item.due) return null;
const d = daysBetween(parseDate(item.due), new Date());
if (d < 0) return `${-d}d overdue`;
if (d === 0) return "today";
if (d === 1) return "tomorrow";
if (d < 7) return `in ${d}d`;
if (d < 30) return `in ${Math.round(d/7)}w`;
return item.due;
}
function fmtTimestamp(iso) {
if (!iso) return "—";
const d = new Date(iso);
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.round(diff/60)}m ago`;
if (diff < 86400) return `${Math.round(diff/3600)}h ago`;
if (diff < 86400*7) return `${Math.round(diff/86400)}d ago`;
return d.toISOString().slice(0, 10);
}
function fmtBytes(b) {
if (b < 1024) return `${b} B`;
if (b < 1024*1024) return `${(b/1024).toFixed(1)} KB`;
return `${(b/1024/1024).toFixed(2)} MB`;
}
const fmtRelative = fmtTimestamp;
function fmtShortDate(iso) {
if (!iso) return "—";
const d = new Date(iso);
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
// ---- tree helpers ----
function walkTree(items, fn, depth = 0, parent = null) {
for (const it of items) {
fn(it, depth, parent);
if (it.children?.length) walkTree(it.children, fn, depth + 1, it);
}
}
function findItem(items, id) {
for (const it of items) {
if (it.id === id) return it;
if (it.children?.length) {
const f = findItem(it.children, id);
if (f) return f;
}
}
return null;
}
function allTags(items) {
const counts = new Map();
walkTree(items, it => (it.tags || []).forEach(t => counts.set(t, (counts.get(t) || 0) + 1)));
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([t]) => t);
}
function countAll(items) {
let n = 0; walkTree(items, () => n++); return n;
}
function countByStatus(items) {
const c = Object.fromEntries(STATUSES.map(s => [s.key, 0]));
walkTree(items, it => { c[it.status] = (c[it.status] || 0) + 1; });
return c;
}
// ---- filter engine with parent-visibility rule ----
function filterTree(items, f) {
function matches(it) {
if (f.statuses?.length && !f.statuses.includes(it.status)) return false;
if (f.priorities?.length && !f.priorities.includes(it.priority)) return false;
if (f.tags?.length && !(it.tags || []).some(t => f.tags.includes(t))) return false;
if (f.dueRange) {
const overdue = isOverdue(it);
const d = it.due ? daysBetween(parseDate(it.due), new Date()) : null;
if (f.dueRange === "overdue" && !overdue) return false;
if (f.dueRange === "today" && !(d === 0)) return false;
if (f.dueRange === "week" && !(d !== null && d >= 0 && d <= 7)) return false;
if (f.dueRange === "month" && !(d !== null && d >= 0 && d <= 30)) return false;
}
if (f.text) {
const q = f.text.toLowerCase();
const hay = [it.title, it.body, it.reason, ...(it.tags || [])].filter(Boolean).join(" ").toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
}
function recur(list) {
const out = [];
for (const it of list) {
const kids = it.children?.length ? recur(it.children) : [];
const self = matches(it);
if (self || kids.length) {
out.push({ ...it, children: kids, _matches: self });
}
}
return out;
}
return recur(items);
}
// ---- status visuals (4 styles, switchable via Tweaks) ----
// styles: "flat" (minimal monochrome SVG, inherits status color),
// "ascii" (original [/], [x] etc),
// "color" (filled colored icons, ChatGPT-style — green check, red blocked, etc),
// "emoji" (expressive native emoji — ✅ 🚧 ⏸ etc)
// Self-contained color palette for the "color" style — does NOT inherit currentColor.
const STATUS_COLORS = {
"open": { stroke: "oklch(0.62 0.01 70)", fill: "transparent" },
"in-progress": { stroke: "oklch(0.55 0.16 230)", fill: "oklch(0.62 0.16 230)" },
"blocked": { stroke: "oklch(0.50 0.20 28)", fill: "oklch(0.62 0.20 28)" },
"postponed": { stroke: "oklch(0.55 0.13 290)", fill: "oklch(0.65 0.13 290)" },
"done": { stroke: "oklch(0.48 0.16 145)", fill: "oklch(0.60 0.16 145)" },
"cancelled": { stroke: "oklch(0.55 0.01 70)", fill: "oklch(0.65 0.01 70)" },
};
// Expressive emoji pack — chosen for clarity at small sizes (avoid round colored dots).
const STATUS_EMOJI = {
"open": "⬜",
"in-progress": "✏️",
"blocked": "🚧",
"postponed": "⏸️",
"done": "✅",
"cancelled": "❌",
};
// Context so every StatusIcon in the tree picks up the current style automatically.
const StatusStyleContext = React.createContext("flat");
function StatusIcon({ status, style, size = 14 }) {
const ctx = React.useContext(StatusStyleContext);
style = style || ctx || "flat";
if (style === "ascii") {
return <span className={`glyph-ascii status-${status}`}>{STATUS_BY_KEY[status].glyph}</span>;
}
if (style === "color") {
return <ColorStatusIcon status={status} size={size}/>;
}
if (style === "emoji") {
return <span className="glyph-emoji" style={{ fontSize: size + 2, lineHeight: 1 }}>{STATUS_EMOJI[status]}</span>;
}
// flat — minimalistic SVG icons (square family, no circles)
const c = "currentColor";
const wrap = (children) => (
<svg width={size + 2} height={size + 2} viewBox="0 0 16 16" className={`glyph-flat status-${status}`}>
{children}
</svg>
);
switch (status) {
case "open":
// empty square outline
return wrap(<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>);
case "in-progress":
// half-filled square (left filled)
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>
<path d="M3.5 3.5 H8 V12.5 H3.5 Z" fill={c}/>
</>);
case "blocked":
// filled square with horizontal bar (no entry)
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill={c}/>
<rect x="4.5" y="7.2" width="7" height="1.6" rx="0.5" fill="white"/>
</>);
case "postponed":
// square with right-pointing chevron (deferred)
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>
<polyline points="6,5.5 9,8 6,10.5" fill="none" stroke={c} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</>);
case "done":
// filled square with checkmark
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill={c}/>
<polyline points="5,8 7,10.2 11,5.8" fill="none" stroke="white" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/>
</>);
case "cancelled":
// outlined square with single clean diagonal slash
return wrap(<>
<rect x="2.5" y="2.5" width="11" height="11" rx="2" fill="none" stroke={c} strokeWidth="1.4"/>
<line x1="3.5" y1="12.5" x2="12.5" y2="3.5" stroke={c} strokeWidth="1.6" strokeLinecap="round"/>
</>);
default: return null;
}
}
// ---- color status icons (ChatGPT / Linear / GitHub style) ----
// Filled circles with semantic colors and white glyphs. Self-coloring,
// not affected by currentColor — looks good on any background.
function ColorStatusIcon({ status, size = 14 }) {
const c = STATUS_COLORS[status] || STATUS_COLORS.open;
const w = size + 2;
const cx = 8, cy = 8, r = 6.4;
switch (status) {
case "open":
// empty circle — thin gray ring
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={c.stroke} strokeWidth="1.5"/>
</svg>
);
case "in-progress":
// half-filled pie (clockwise 50%) — Linear-style "in progress"
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke={c.fill} strokeWidth="1.5"/>
<path d={`M 8 1.6 A 6.4 6.4 0 0 1 8 14.4 Z`} fill={c.fill}/>
</svg>
);
case "blocked":
// red filled circle with white horizontal bar
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<rect x="4.6" y="7.2" width="6.8" height="1.6" rx="0.8" fill="white"/>
</svg>
);
case "postponed":
// purple filled circle with white right-chevron
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<polyline points="6.4,5.4 9.4,8 6.4,10.6" fill="none" stroke="white" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
case "done":
// green filled circle with white check
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<polyline points="5,8.2 7.2,10.3 11,5.9" fill="none" stroke="white" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
case "cancelled":
// gray filled circle with white X
return (
<svg width={w} height={w} viewBox="0 0 16 16" className={`glyph-color status-${status}`}>
<circle cx={cx} cy={cy} r={r} fill={c.fill}/>
<g stroke="white" strokeWidth="1.6" strokeLinecap="round">
<line x1="5.6" y1="5.6" x2="10.4" y2="10.4"/>
<line x1="10.4" y1="5.6" x2="5.6" y2="10.4"/>
</g>
</svg>
);
default: return null;
}
}
// ---- icons (inline SVG) ----
const Icon = ({ name, size = 14 }) => {
const paths = {
chevron: <polyline points="5,8 10,13 15,8" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>,
chevronRight: <polyline points="8,5 13,10 8,15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>,
plus: <g stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><line x1="10" y1="4" x2="10" y2="16"/><line x1="4" y1="10" x2="16" y2="10"/></g>,
search: <g fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><circle cx="9" cy="9" r="5"/><line x1="13" y1="13" x2="17" y2="17"/></g>,
drag: <g fill="currentColor"><circle cx="7" cy="5" r="1.2"/><circle cx="13" cy="5" r="1.2"/><circle cx="7" cy="10" r="1.2"/><circle cx="13" cy="10" r="1.2"/><circle cx="7" cy="15" r="1.2"/><circle cx="13" cy="15" r="1.2"/></g>,
trash: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M5 6h10M8 6V4h4v2M6 6l1 10h6l1-10"/></g>,
cog: <g fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="10" cy="10" r="2.6"/><path d="M10 2v2M10 16v2M2 10h2M16 10h2M4.3 4.3l1.4 1.4M14.3 14.3l1.4 1.4M4.3 15.7l1.4-1.4M14.3 5.7l1.4-1.4"/></g>,
list: <g fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><line x1="6" y1="6" x2="16" y2="6"/><line x1="6" y1="10" x2="16" y2="10"/><line x1="6" y1="14" x2="16" y2="14"/><circle cx="3.5" cy="6" r="0.6" fill="currentColor"/><circle cx="3.5" cy="10" r="0.6" fill="currentColor"/><circle cx="3.5" cy="14" r="0.6" fill="currentColor"/></g>,
check: <polyline points="4,10 8,14 16,5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>,
download: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3v10M5 9l5 5 5-5M3 17h14"/></g>,
upload: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 16V4M5 9l5-5 5 5M3 17h14"/></g>,
save: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"><path d="M4 4h9l3 3v9H4z"/><path d="M7 4v4h6V4M7 16v-5h6v5"/></g>,
refresh: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 10a7 7 0 0 1 12-5l2 2"/><path d="M17 4v3h-3"/><path d="M17 10a7 7 0 0 1-12 5l-2-2"/><path d="M3 16v-3h3"/></g>,
warn: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M10 3l8 14H2z"/><line x1="10" y1="8" x2="10" y2="12"/><circle cx="10" cy="14.5" r="0.6" fill="currentColor"/></g>,
eye: <g fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M2 10s3-5 8-5 8 5 8 5-3 5-8 5-8-5-8-5z"/><circle cx="10" cy="10" r="2"/></g>,
archive: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"><rect x="3" y="5" width="14" height="3"/><path d="M4 8v9h12V8"/><line x1="8" y1="11" x2="12" y2="11"/></g>,
edit: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14v2h2l8-8-2-2-8 8z"/><path d="M12 4l2 2"/></g>,
addChild: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 4v6a3 3 0 0 0 3 3h4"/><line x1="14" y1="13" x2="14" y2="17"/><line x1="12" y1="15" x2="16" y2="15"/></g>,
copy: <g fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="7" y="3" width="10" height="12" rx="1.5"/><path d="M13 17H4a1 1 0 0 1-1-1V6"/></g>,
statusDots: <g fill="currentColor"><circle cx="5" cy="10" r="1.6"/><circle cx="10" cy="10" r="1.6"/><circle cx="15" cy="10" r="1.6"/></g>
};
return (
<svg width={size} height={size} viewBox="0 0 20 20" style={{ display: "inline-block", verticalAlign: "middle", flexShrink: 0 }}>
{paths[name]}
</svg>
);
};
// --- Progress (completion %) ---
// Quantized to 0/25/50/75/100 — coarse on purpose; tasks are rarely "73% done".
const PROGRESS_STEPS = [0, 25, 50, 75, 100];
function snapProgress(p) {
if (p == null || isNaN(p)) return 0;
if (p <= 0) return 0;
if (p >= 100) return 100;
// round to nearest 25
return Math.round(p / 25) * 25;
}
// Effective progress: done is always 100 regardless of stored value;
// cancelled returns null (don't render).
function progressFor(item) {
if (item.status === "done") return 100;
if (item.status === "cancelled") return null;
return snapProgress(item.progress ?? 0);
}
// When progress should be visible inline. Done shows nothing (line-through is enough).
// Open shows only if user explicitly set >0.
function shouldShowProgress(item) {
if (item.status === "cancelled") return false;
if (item.status === "done") return false;
if (item.status === "open") return (item.progress ?? 0) > 0;
return true; // in-progress, blocked, postponed (always visible, including at 100%)
}
// Click to advance through 0/25/50/75/100, wrapping.
function cycleProgress(p) {
const i = PROGRESS_STEPS.indexOf(snapProgress(p));
return PROGRESS_STEPS[(i + 1) % PROGRESS_STEPS.length];
}
// 4-segment gauge (visual representation of 0/25/50/75/100).
// 0 = empty, 100 = all 4 filled.
function ProgressGauge({ value, onClick, interactive = false, size = "sm" }) {
const v = snapProgress(value);
const filled = v / 25; // 0..4
const segs = [0, 1, 2, 3].map(i => i < filled);
return (
<span
className={`gauge ${interactive ? "gauge-interactive" : ""} gauge-${size} gauge-v-${v}`}
onClick={onClick}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
title={`${v}% complete${interactive ? " — click to change" : ""}`}
aria-label={`Progress ${v} percent`}
>
{segs.map((on, i) => (
<span key={i} className={`gauge-seg ${on ? "on" : ""}`}/>
))}
<span className="gauge-num">{v}%</span>
</span>
);
}
// ---- Markdown ↔ HTML conversion (lightweight, no external deps) ----
const MarkdownUtils = {
// Convert markdown to HTML for rendering in editor
toHtml(md) {
if (!md) return '';
let html = md;
// Escape HTML entities first
html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Code blocks (``` ... ```) - must be before inline code
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) =>
`<pre class="rte-code-block" data-lang="${lang}"><code>${code.trim()}</code></pre>`);
// Tables
html = html.replace(/^\|(.+)\|\s*\n\|[-:| ]+\|\s*\n((?:\|.+\|\s*\n?)+)/gm, (_, header, body) => {
const headers = header.split('|').map(c => c.trim()).filter(Boolean);
const rows = body.trim().split('\n').map(row =>
row.split('|').map(c => c.trim()).filter(Boolean)
);
return `<table class="rte-table"><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr>${r.map(c => `<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
});
// Images (base64 or URL)
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="rte-img">');
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Bold **text** or __text__
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
// Italic *text* or _text_ (not inside words)
html = html.replace(/(?<![*\w])\*([^*\n]+)\*(?![*\w])/g, '<em>$1</em>');
html = html.replace(/(?<![_\w])_([^_\n]+)_(?![_\w])/g, '<em>$1</em>');
// Inline code `code`
html = html.replace(/`([^`]+)`/g, '<code class="rte-inline-code">$1</code>');
// Headings
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Lists - unordered
html = html.replace(/^[-*] (.+)$/gm, '<li data-list="ul">$1</li>');
// Lists - ordered
html = html.replace(/^\d+\. (.+)$/gm, '<li data-list="ol">$1</li>');
// Wrap consecutive list items
html = html.replace(/((?:<li data-list="ul">.+<\/li>\s*)+)/g, '<ul>$1</ul>');
html = html.replace(/((?:<li data-list="ol">.+<\/li>\s*)+)/g, '<ol>$1</ol>');
html = html.replace(/ data-list="[uo]l"/g, '');
// Paragraphs - wrap remaining text blocks
html = html.split('\n\n').map(block => {
block = block.trim();
if (!block) return '';
if (/^<(h[1-3]|ul|ol|pre|table|p)/.test(block)) return block;
if (block.includes('<')) return block; // Already has HTML
return `<p>${block.replace(/\n/g, '<br>')}</p>`;
}).join('\n');
return html;
},
// Convert HTML from editor back to markdown
toMarkdown(html) {
if (!html) return '';
const doc = new DOMParser().parseFromString(html, 'text/html');
return this._nodeToMd(doc.body).trim();
},
_nodeToMd(node) {
let md = '';
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
md += child.textContent;
} else if (child.nodeType === Node.ELEMENT_NODE) {
const tag = child.tagName.toLowerCase();
const inner = this._nodeToMd(child);
switch (tag) {
case 'strong': case 'b': md += `**${inner}**`; break;
case 'em': case 'i': md += `*${inner}*`; break;
case 'code':
if (child.parentElement?.tagName === 'PRE') md += inner;
else md += `\`${inner}\``;
break;
case 'pre': md += `\`\`\`\n${inner}\n\`\`\`\n\n`; break;
case 'a': md += `[${inner}](${child.getAttribute('href') || ''})`; break;
case 'img': md += `![${child.getAttribute('alt') || ''}](${child.getAttribute('src') || ''})`; break;
case 'h1': md += `# ${inner}\n\n`; break;
case 'h2': md += `## ${inner}\n\n`; break;
case 'h3': md += `### ${inner}\n\n`; break;
case 'p': md += `${inner}\n\n`; break;
case 'br': md += '\n'; break;
case 'li': md += `- ${inner}\n`; break;
case 'ul': case 'ol': md += inner + '\n'; break;
case 'table': md += this._tableToMd(child) + '\n\n'; break;
case 'thead': case 'tbody': case 'tr': case 'th': case 'td': md += inner; break;
case 'div': md += inner + '\n'; break;
default: md += inner;
}
}
}
return md;
},
_tableToMd(table) {
const rows = Array.from(table.querySelectorAll('tr'));
if (!rows.length) return '';
const lines = [];
rows.forEach((row, i) => {
const cells = Array.from(row.querySelectorAll('th, td')).map(c => c.textContent.trim());
lines.push('| ' + cells.join(' | ') + ' |');
if (i === 0) lines.push('|' + cells.map(() => '---').join('|') + '|');
});
return lines.join('\n');
},
// Convert HTML to plain text
toPlainText(html) {
if (!html) return '';
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || '';
}
};
// ---- Clipboard utilities for multiple formats ----
const ClipboardUtils = {
// Copy to clipboard with multiple formats (HTML primary, plain text also)
async copyHtml(html, markdown) {
const plain = MarkdownUtils.toPlainText(html);
try {
// Modern API: write multiple formats
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([plain], { type: 'text/plain' })
})
]);
return true;
} catch {
// Fallback: plain text only
await navigator.clipboard.writeText(plain);
return true;
}
},
async copyMarkdown(markdown) {
await navigator.clipboard.writeText(markdown);
return true;
},
async copyPlainText(html) {
const plain = MarkdownUtils.toPlainText(html);
await navigator.clipboard.writeText(plain);
return true;
}
};
// ---- Archive Helpers ----
function getRestorePath(entries, itemId) {
// Build path of parent IDs from root to item's parent
const path = [];
function find(items, targetId, currentPath) {
for (const it of items) {
if (it.id === targetId) return currentPath;
if (it.children?.length) {
const result = find(it.children, targetId, [...currentPath, it.id]);
if (result) return result;
}
}
return null;
}
return find(entries, itemId, [])?.join('/') || '';
}
function removeItems(entries, itemIds) {
// Remove items by ID, returns removed items with their children
const removed = [];
const idsSet = new Set(itemIds);
function remove(items) {
const kept = [];
for (const it of items) {
if (idsSet.has(it.id)) {
removed.push(it);
} else {
if (it.children?.length) it.children = remove(it.children);
kept.push(it);
}
}
return kept;
}
const newEntries = remove(entries);
return { entries: newEntries, removed };
}
function insertAtPath(entries, item, restorePath) {
// Insert item at the path specified by parent IDs
if (!restorePath) {
entries.push(item);
return true;
}
const pathIds = restorePath.split('/').filter(Boolean);
let current = entries;
for (const pid of pathIds) {
const parent = current.find(it => it.id === pid);
if (!parent) {
// Parent not found - insert at root with warning
entries.push(item);
return false;
}
if (!parent.children) parent.children = [];
current = parent.children;
}
current.push(item);
return true;
}
Object.assign(window, {
STATUSES, STATUS_BY_KEY, STATUS_ORDER, PRIORITIES,
parseDate, daysBetween, isOverdue, dueRelative, fmtTimestamp, fmtBytes, fmtRelative, fmtShortDate,
walkTree, findItem, allTags, countAll, countByStatus, filterTree,
Icon, StatusIcon, ColorStatusIcon, STATUS_COLORS, STATUS_EMOJI, StatusStyleContext,
PROGRESS_STEPS, snapProgress, progressFor, shouldShowProgress, cycleProgress, ProgressGauge,
MarkdownUtils, ClipboardUtils,
getRestorePath, removeItems, insertAtPath
});