-
+
{PROGRESS_STEPS.map(p => (
))}
@@ -400,32 +407,52 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
setReason(e.target.value)}
- placeholder="Waiting for…"/>
+ placeholder="Waiting for…"
+ readOnly={mode === "view"}/>
)}
-
-
+
+ {mode === "view" ? (
+
+ {tags.length === 0 ? No tags : tags.map(t => (
+ {t}
+ ))}
+
+ ) : (
+
+ )}
-
⌘↩ to save · Esc to cancel
-
-
-
-
+ {mode === "view" ? (
+ <>
+
Read-only · archived {initial?.archived || ''}
+
+
+
+ >
+ ) : (
+ <>
+
⌘↩ to save · Esc to cancel
+
+
+
+
+ >
+ )}
diff --git a/web/helpers.jsx b/web/helpers.jsx
index 1a42d32..6fd3894 100644
--- a/web/helpers.jsx
+++ b/web/helpers.jsx
@@ -520,11 +520,71 @@ const ClipboardUtils = {
}
};
+// ---- 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
+ MarkdownUtils, ClipboardUtils,
+ getRestorePath, removeItems, insertAtPath
});
diff --git a/web/storage.jsx b/web/storage.jsx
index c09ea28..eb3b1d1 100644
--- a/web/storage.jsx
+++ b/web/storage.jsx
@@ -103,16 +103,19 @@ const Parser = {
const raw = m[3].trim();
let title = raw, due = null, reason = null, tags = [], priority = 'P1', progress = 0;
+ let archived = null, restorePath = null;
const metaM = raw.match(/^(.*?)\s*\*\((.*)\)\*\s*$/);
if (metaM) {
title = metaM[1].trim();
for (const p of metaM[2].split(',').map(s => s.trim())) {
- if (p.startsWith('due:')) due = p.slice(4).trim();
- else if (p.startsWith('reason:')) reason = p.slice(7).trim();
- else if (p.startsWith('tags:')) tags = p.slice(5).trim().split(/\s+/).filter(Boolean);
- else if (p.startsWith('priority:')) priority = p.slice(9).trim().toUpperCase();
- else if (p.startsWith('progress:')) progress = parseInt(p.slice(9).trim(), 10) || 0;
+ if (p.startsWith('due:')) due = p.slice(4).trim();
+ else if (p.startsWith('reason:')) reason = p.slice(7).trim();
+ else if (p.startsWith('tags:')) tags = p.slice(5).trim().split(/\s+/).filter(Boolean);
+ else if (p.startsWith('priority:')) priority = p.slice(9).trim().toUpperCase();
+ else if (p.startsWith('progress:')) progress = parseInt(p.slice(9).trim(), 10) || 0;
+ else if (p.startsWith('archived:')) archived = p.slice(9).trim();
+ else if (p.startsWith('restore-path:')) restorePath = p.slice(13).trim();
}
}
@@ -131,6 +134,8 @@ const Parser = {
collapsed: false,
children: [],
body: null,
+ archived: archived || null,
+ restorePath: restorePath || null,
};
stack[stack.length - 1].children.push(item);
stack.push({ children: item.children, depth, level: item.level, item });
@@ -163,6 +168,8 @@ const Parser = {
if (it.tags?.length) meta.push(`tags: ${it.tags.join(' ')}`);
if (it.priority && it.priority !== 'P1') meta.push(`priority: ${it.priority}`);
if ((it.progress ?? 0) > 0) meta.push(`progress: ${it.progress}`);
+ if (it.archived) meta.push(`archived: ${it.archived}`);
+ if (it.restorePath) meta.push(`restore-path: ${it.restorePath}`);
const metaStr = meta.length ? ` *(${meta.join(', ')})*` : '';
const prefix = it.priority ? `[${it.priority}] ` : '';
lines.push(`${indent}- [${GLYPH[it.status] || ' '}] ${prefix}${it.title}${metaStr}`);
@@ -249,9 +256,38 @@ const ApiBackend = {
statsSize: 0,
masterPath: j.masterPath || '',
backupsPath: j.backupsPath || '',
+ archiveExists: j.archiveExists || false,
+ archiveSize: j.archiveSize || 0,
+ archivePath: j.archivePath || '',
};
} catch { return {}; }
},
+
+ async loadArchive() {
+ const r = await fetch('/api/archive');
+ if (!r.ok) throw new Error('API archive load failed: ' + r.status);
+ return r.json(); // { content, checksum, exists }
+ },
+
+ async saveArchive(backlogContent, archiveContent) {
+ const r = await fetch('/api/archive', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ backlog: backlogContent, archive: archiveContent }),
+ });
+ if (!r.ok) throw new Error('API archive save failed: ' + r.status);
+ return r.json();
+ },
+
+ async restoreFromArchive(backlogContent, archiveContent) {
+ const r = await fetch('/api/archive/restore', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ backlog: backlogContent, archive: archiveContent }),
+ });
+ if (!r.ok) throw new Error('API archive restore failed: ' + r.status);
+ return r.json();
+ },
};
// ---- Direct File Backend (File System Access API) ----
@@ -376,8 +412,31 @@ const DirectBackend = {
async getHealthInfo() {
try {
const f = await (await this.dirHandle.getFileHandle('backlog.md')).getFile();
- return { masterSize: f.size, statsSize: 0, dirName: this.dirHandle?.name ?? null };
- } catch { return { masterSize: 0, statsSize: 0, dirName: this.dirHandle?.name ?? null }; }
+ let archiveExists = false, archiveSize = 0;
+ try {
+ const af = await (await this.dirHandle.getFileHandle('archive.md')).getFile();
+ archiveExists = true;
+ archiveSize = af.size;
+ } catch {}
+ return { masterSize: f.size, statsSize: 0, dirName: this.dirHandle?.name ?? null, archiveExists, archiveSize };
+ } catch { return { masterSize: 0, statsSize: 0, dirName: this.dirHandle?.name ?? null, archiveExists: false, archiveSize: 0 }; }
+ },
+
+ async loadArchive() {
+ const content = await this._readFile('archive.md');
+ return { content: content || '', checksum: '', exists: !!content };
+ },
+
+ async saveArchive(backlogContent, archiveContent) {
+ await this._writeFile('backlog.md', backlogContent);
+ await this._writeFile('archive.md', archiveContent);
+ return { ok: true };
+ },
+
+ async restoreFromArchive(backlogContent, archiveContent) {
+ await this._writeFile('backlog.md', backlogContent);
+ await this._writeFile('archive.md', archiveContent);
+ return { ok: true };
},
};
@@ -518,6 +577,7 @@ const BrowserStorageBackend = {
async getHealthInfo() {
try {
const content = await this._get(this._CONTENT_KEY) || '';
+ const archiveContent = await this._get('archive') || '';
const estimate = await navigator.storage?.estimate?.() || {};
const persisted = await navigator.storage?.persisted?.() || false;
return {
@@ -527,9 +587,40 @@ const BrowserStorageBackend = {
persisted,
quota: estimate.quota,
usage: estimate.usage,
+ archiveExists: !!archiveContent,
+ archiveSize: new TextEncoder().encode(archiveContent).length,
};
} catch {
- return { masterSize: 0, statsSize: 0, browserStorage: true, persisted: false };
+ return { masterSize: 0, statsSize: 0, browserStorage: true, persisted: false, archiveExists: false, archiveSize: 0 };
+ }
+ },
+
+ async loadArchive() {
+ try {
+ const content = await this._get('archive') || '';
+ return { content, checksum: '', exists: !!content };
+ } catch {
+ return { content: '', checksum: '', exists: false };
+ }
+ },
+
+ async saveArchive(backlogContent, archiveContent) {
+ try {
+ await this._set(this._CONTENT_KEY, backlogContent);
+ await this._set('archive', archiveContent);
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, error: e.message };
+ }
+ },
+
+ async restoreFromArchive(backlogContent, archiveContent) {
+ try {
+ await this._set(this._CONTENT_KEY, backlogContent);
+ await this._set('archive', archiveContent);
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, error: e.message };
}
},
};
@@ -583,6 +674,9 @@ const Storage = {
async listBackups() { return this.backend?.listBackups() ?? []; },
async restoreBackup(name) { return this.backend?.restoreBackup(name) ?? { ok: false }; },
async getHealthInfo() { return this.backend?.getHealthInfo?.() ?? {}; },
+ async loadArchive() { return this.backend?.loadArchive?.() ?? { content: '', exists: false }; },
+ async saveArchive(backlog, archive) { return this.backend?.saveArchive?.(backlog, archive) ?? { ok: false }; },
+ async restoreFromArchive(backlog, archive) { return this.backend?.restoreFromArchive?.(backlog, archive) ?? { ok: false }; },
};
// ---- SyncPoller — detect external file changes (5s interval) ----
diff --git a/web/styles.css b/web/styles.css
index 130834c..78747ac 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -1304,3 +1304,99 @@ input { font: inherit; color: inherit; }
.field-row { grid-template-columns: 1fr; }
.status-grid { grid-template-columns: repeat(2, 1fr); }
}
+
+/* ---------- Archive Manager ---------- */
+.archive-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--rule-2); }
+.archive-section:first-of-type { margin-top: 8px; padding-top: 0; border-top: 0; }
+.archive-toolbar {
+ display: flex; align-items: center; gap: 10px;
+ margin-bottom: 12px; flex-wrap: wrap;
+}
+.archive-label { font-size: 12px; font-weight: 600; color: var(--ink-2); }
+.archive-filter {
+ padding: 4px 8px; font-size: 12px;
+ border: 1px solid var(--rule); border-radius: var(--radius);
+ background: var(--paper); color: var(--ink);
+}
+.archive-search {
+ padding: 4px 10px; font-size: 12px;
+ border: 1px solid var(--rule); border-radius: var(--radius);
+ background: var(--paper); color: var(--ink);
+ width: 160px;
+}
+.archive-search::placeholder { color: var(--ink-3); }
+.archive-empty {
+ padding: 20px; text-align: center;
+ color: var(--ink-3); font-size: 13px;
+}
+.archive-list {
+ display: flex; flex-direction: column; gap: 4px;
+ max-height: 280px; overflow-y: auto;
+}
+.archive-item {
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 10px; background: var(--bg); border-radius: var(--radius);
+ font-size: 13px; cursor: pointer;
+ transition: background 0.12s;
+}
+.archive-item:hover { background: var(--bg-2); }
+.archive-item.archived { cursor: default; }
+.archive-item input[type="checkbox"] { flex-shrink: 0; }
+.archive-item .glyph { flex-shrink: 0; width: 18px; text-align: center; }
+.archive-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.archive-item-children {
+ font-size: 11px; color: var(--ink-3);
+ padding: 2px 6px; background: var(--rule-2); border-radius: 3px;
+}
+.archive-item-date { font-size: 11px; color: var(--ink-3); font-family: var(--font-mono); }
+.archive-more { padding: 8px; text-align: center; font-size: 12px; color: var(--ink-3); }
+.btn-sm { padding: 4px 10px; font-size: 12px; }
+.btn-xs { padding: 2px 8px; font-size: 11px; }
+
+/* ---------- Archive Search Results ---------- */
+.archive-search-section {
+ margin-top: 16px; padding-top: 16px;
+ border-top: 1px dashed var(--rule);
+}
+.archive-search-toggle {
+ display: flex; align-items: center; gap: 6px;
+ padding: 6px 10px;
+ font-size: 12px; font-weight: 500; color: var(--ink-2);
+ background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius);
+ cursor: pointer;
+ transition: background 0.12s, color 0.12s;
+}
+.archive-search-toggle:hover { background: var(--bg-2); color: var(--ink); }
+.archive-loading { color: var(--ink-3); font-style: italic; }
+.archive-count { color: var(--ink-3); }
+.archive-search-results {
+ margin-top: 8px; padding: 8px;
+ background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius);
+}
+.archive-search-empty {
+ padding: 12px; text-align: center;
+ color: var(--ink-3); font-size: 13px;
+}
+.archive-search-item {
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 8px;
+ border-radius: var(--radius);
+ transition: background 0.12s;
+}
+.archive-search-item:hover { background: var(--bg-2); }
+.archive-search-item .glyph { flex-shrink: 0; width: 18px; text-align: center; }
+.archive-search-title { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.archive-search-date { font-size: 11px; color: var(--ink-3); font-family: var(--font-mono); }
+.archive-search-more { padding: 8px; text-align: center; font-size: 12px; color: var(--ink-3); }
+.archive-search-header {
+ font-size: 12px; font-weight: 600; color: var(--ink-2);
+ padding: 4px 0; margin-bottom: 8px;
+}
+.archive-match-hint { font-size: 14px; color: var(--ink-3); font-weight: 400; }
+.rte-readonly {
+ padding: 10px 12px; background: var(--bg); border: 1px solid var(--rule);
+ border-radius: var(--radius); font-size: 13px; line-height: 1.5; color: var(--ink-2);
+ min-height: 60px; max-height: 200px; overflow-y: auto;
+}
+.rte-readonly:empty::before { content: 'No details'; color: var(--ink-3); }
+.tag-list-readonly { display: flex; flex-wrap: wrap; gap: 6px; }
diff --git a/webapp/index-style-v2.html b/webapp/index-style-v2.html
index 5329c4c..1467ef9 100644
--- a/webapp/index-style-v2.html
+++ b/webapp/index-style-v2.html
@@ -1317,6 +1317,102 @@ input { font: inherit; color: inherit; }
.status-grid { grid-template-columns: repeat(2, 1fr); }
}
+/* ---------- Archive Manager ---------- */
+.archive-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--rule-2); }
+.archive-section:first-of-type { margin-top: 8px; padding-top: 0; border-top: 0; }
+.archive-toolbar {
+ display: flex; align-items: center; gap: 10px;
+ margin-bottom: 12px; flex-wrap: wrap;
+}
+.archive-label { font-size: 12px; font-weight: 600; color: var(--ink-2); }
+.archive-filter {
+ padding: 4px 8px; font-size: 12px;
+ border: 1px solid var(--rule); border-radius: var(--radius);
+ background: var(--paper); color: var(--ink);
+}
+.archive-search {
+ padding: 4px 10px; font-size: 12px;
+ border: 1px solid var(--rule); border-radius: var(--radius);
+ background: var(--paper); color: var(--ink);
+ width: 160px;
+}
+.archive-search::placeholder { color: var(--ink-3); }
+.archive-empty {
+ padding: 20px; text-align: center;
+ color: var(--ink-3); font-size: 13px;
+}
+.archive-list {
+ display: flex; flex-direction: column; gap: 4px;
+ max-height: 280px; overflow-y: auto;
+}
+.archive-item {
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 10px; background: var(--bg); border-radius: var(--radius);
+ font-size: 13px; cursor: pointer;
+ transition: background 0.12s;
+}
+.archive-item:hover { background: var(--bg-2); }
+.archive-item.archived { cursor: default; }
+.archive-item input[type="checkbox"] { flex-shrink: 0; }
+.archive-item .glyph { flex-shrink: 0; width: 18px; text-align: center; }
+.archive-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.archive-item-children {
+ font-size: 11px; color: var(--ink-3);
+ padding: 2px 6px; background: var(--rule-2); border-radius: 3px;
+}
+.archive-item-date { font-size: 11px; color: var(--ink-3); font-family: var(--font-mono); }
+.archive-more { padding: 8px; text-align: center; font-size: 12px; color: var(--ink-3); }
+.btn-sm { padding: 4px 10px; font-size: 12px; }
+.btn-xs { padding: 2px 8px; font-size: 11px; }
+
+/* ---------- Archive Search Results ---------- */
+.archive-search-section {
+ margin-top: 16px; padding-top: 16px;
+ border-top: 1px dashed var(--rule);
+}
+.archive-search-toggle {
+ display: flex; align-items: center; gap: 6px;
+ padding: 6px 10px;
+ font-size: 12px; font-weight: 500; color: var(--ink-2);
+ background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius);
+ cursor: pointer;
+ transition: background 0.12s, color 0.12s;
+}
+.archive-search-toggle:hover { background: var(--bg-2); color: var(--ink); }
+.archive-loading { color: var(--ink-3); font-style: italic; }
+.archive-count { color: var(--ink-3); }
+.archive-search-results {
+ margin-top: 8px; padding: 8px;
+ background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius);
+}
+.archive-search-empty {
+ padding: 12px; text-align: center;
+ color: var(--ink-3); font-size: 13px;
+}
+.archive-search-item {
+ display: flex; align-items: center; gap: 8px;
+ padding: 6px 8px;
+ border-radius: var(--radius);
+ transition: background 0.12s;
+}
+.archive-search-item:hover { background: var(--bg-2); }
+.archive-search-item .glyph { flex-shrink: 0; width: 18px; text-align: center; }
+.archive-search-title { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.archive-search-date { font-size: 11px; color: var(--ink-3); font-family: var(--font-mono); }
+.archive-search-more { padding: 8px; text-align: center; font-size: 12px; color: var(--ink-3); }
+.archive-search-header {
+ font-size: 12px; font-weight: 600; color: var(--ink-2);
+ padding: 4px 0; margin-bottom: 8px;
+}
+.archive-match-hint { font-size: 14px; color: var(--ink-3); font-weight: 400; }
+.rte-readonly {
+ padding: 10px 12px; background: var(--bg); border: 1px solid var(--rule);
+ border-radius: var(--radius); font-size: 13px; line-height: 1.5; color: var(--ink-2);
+ min-height: 60px; max-height: 200px; overflow-y: auto;
+}
+.rte-readonly:empty::before { content: 'No details'; color: var(--ink-3); }
+.tag-list-readonly { display: flex; flex-wrap: wrap; gap: 6px; }
+
@@ -2673,6 +2769,66 @@ const ClipboardUtils = {
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,
@@ -2705,7 +2861,10 @@ Object.assign(window, {
cycleProgress,
ProgressGauge,
MarkdownUtils,
- ClipboardUtils
+ ClipboardUtils,
+ getRestorePath,
+ removeItems,
+ insertAtPath
});