2bcedda2c2
- 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)
591 lines
25 KiB
React
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
// 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 += ` || ''})`; 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
|
|
});
|