Add multi-project state, merged loading, and per-project saving
This commit is contained in:
+403
-64
@@ -12,10 +12,10 @@ function loadLocalState() {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Only UI state (expanded rows, tweaks) is persisted locally.
|
||||
// Only UI state (expanded rows, project section collapse, tweaks) is persisted locally.
|
||||
// Backlog data always lives in backlog.md — never in localStorage.
|
||||
function saveLocalState(expandedMap, tweaks) {
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify({ expandedMap, tweaks })); } catch { /* quota / private mode */ }
|
||||
function saveLocalState(expandedMap, tweaks, projectExpandedMap = {}) {
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify({ expandedMap, tweaks, projectExpandedMap })); } catch { /* quota / private mode */ }
|
||||
}
|
||||
|
||||
// Empty starting state — used when there is no saved data and no seed data loaded.
|
||||
@@ -42,6 +42,79 @@ function buildEmptyData() {
|
||||
};
|
||||
}
|
||||
|
||||
// Build ONE merged data object from per-project loads (api mode). Every entry
|
||||
// and history row is tagged with _projectId; stats derive through the same
|
||||
// buildDataFromStorage pipeline as single-project mode. A single registered
|
||||
// project degenerates cleanly — the merged model is the ONLY api data path.
|
||||
async function buildMergedDataFromProjects(projectsData, { sizeInfo = {}, backups = [] } = {}) {
|
||||
// Parser ids derive from a Date.now() counter, so loadAllProjects' parallel
|
||||
// per-project parses can mint IDENTICAL ids (same-millisecond start).
|
||||
// De-collide deterministically per project — stable across reloads — and
|
||||
// remap that project's history rows to the new id.
|
||||
const seenIds = new Set();
|
||||
const allEntries = [];
|
||||
const taggedHistory = [];
|
||||
|
||||
for (const proj of projectsData) {
|
||||
walkTree(proj.entries, it => {
|
||||
if (seenIds.has(it.id)) {
|
||||
const nid = `${it.id}~${proj.id}`;
|
||||
for (const h of proj.history) if (h.itemId === it.id) h.itemId = nid;
|
||||
it.id = nid;
|
||||
}
|
||||
seenIds.add(it.id);
|
||||
it._projectId = proj.id;
|
||||
});
|
||||
allEntries.push(...proj.entries);
|
||||
for (const h of proj.history) taggedHistory.push({ ...h, _projectId: proj.id });
|
||||
}
|
||||
taggedHistory.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
|
||||
const metas = projectsData.map(p => p.meta).filter(Boolean);
|
||||
const mergedMeta = metas.length ? {
|
||||
saved: metas.map(m => m.saved).filter(Boolean).sort().pop() ?? null,
|
||||
checksum: '—',
|
||||
entryCount: countAll(allEntries),
|
||||
historyCount: taggedHistory.length,
|
||||
} : null;
|
||||
|
||||
const merged = await buildDataFromStorage(
|
||||
{ entries: allEntries, history: taggedHistory, meta: mergedMeta, checksumOk: projectsData.every(p => p.checksumOk) },
|
||||
backups, 'api', sizeInfo,
|
||||
);
|
||||
|
||||
// Stats above derive from the merged tree; mostActiveProject is the project
|
||||
// with the most entries.
|
||||
let mostActive = '—', maxCount = -1;
|
||||
for (const proj of projectsData) {
|
||||
const n = countAll(proj.entries);
|
||||
if (n > maxCount) { maxCount = n; mostActive = proj.name; }
|
||||
}
|
||||
merged.stats.mostActiveProject = mostActive;
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Which projects have local changes to persist? An explicit id wins (or an
|
||||
// array of ids for cross-project moves); otherwise derive from _projectId tags
|
||||
// on entries AND history rows, with untagged rows falling back to the first
|
||||
// registered project.
|
||||
function computeDirtyProjectIds(data, explicitProjectId, fallbackFirst) {
|
||||
if (Array.isArray(explicitProjectId)) return explicitProjectId.filter(Boolean);
|
||||
if (explicitProjectId) return [explicitProjectId];
|
||||
const ids = new Set();
|
||||
for (const e of data.entries) ids.add(e._projectId ?? fallbackFirst);
|
||||
for (const h of data.history) ids.add(h._projectId ?? fallbackFirst);
|
||||
ids.delete(null);
|
||||
ids.delete(undefined);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
// Re-tag a node and all its descendants as belonging to `projectId`.
|
||||
// Used by cross-project drag&drop (todo 6 drop zones) and project rename.
|
||||
function retagSubtree(node, projectId) {
|
||||
walkTree([node], it => { it._projectId = projectId; });
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [data, setData] = useStateMain(buildEmptyData);
|
||||
const [storageMode, setStorageMode] = useStateMain('local'); // 'api' | 'direct' | 'local'
|
||||
@@ -57,6 +130,8 @@ function App() {
|
||||
const [confirm, setConfirm] = useStateMain(null);
|
||||
const [needsConnect, setNeedsConnect] = useStateMain(false);
|
||||
const [archiveData, setArchiveData] = useStateMain({ entries: [], history: [] });
|
||||
const [projects, setProjects] = useStateMain([]);
|
||||
const [projectExpandedMap, setProjectExpandedMap] = useStateMain(() => loadLocalState()?.projectExpandedMap ?? {});
|
||||
|
||||
const TWEAK_DEFAULTS = { accent_hue: 35, density: 'comfortable', show_ids: false, paper_texture: true, status_style: 'color', sort_mode: 'priority', theme: 'system' };
|
||||
const [tweaks, setTweak] = useTweaks({ ...TWEAK_DEFAULTS, ...(loadLocalState()?.tweaks ?? {}) });
|
||||
@@ -64,11 +139,15 @@ function App() {
|
||||
// Refs for async callbacks that need latest state without stale closures.
|
||||
const latestData = useRefMain(data);
|
||||
const latestExpandedMap = useRefMain(expandedMap);
|
||||
const latestProjects = useRefMain(projects);
|
||||
const latestProjectExpandedMap = useRefMain(projectExpandedMap);
|
||||
const isDirtyRef = useRefMain(false);
|
||||
const saveTimerRef = useRefMain(null);
|
||||
|
||||
useEffectMain(() => { latestData.current = data; }, [data]);
|
||||
useEffectMain(() => { latestExpandedMap.current = expandedMap; }, [expandedMap]);
|
||||
useEffectMain(() => { latestProjects.current = projects; }, [projects]);
|
||||
useEffectMain(() => { latestProjectExpandedMap.current = projectExpandedMap; }, [projectExpandedMap]);
|
||||
|
||||
useEffectMain(() => {
|
||||
document.documentElement.style.setProperty('--accent-hue', tweaks.accent_hue);
|
||||
@@ -91,6 +170,57 @@ function App() {
|
||||
return () => mq.removeEventListener('change', apply);
|
||||
}, [tweaks.theme]);
|
||||
|
||||
// ---- Multi-project loading (api mode) ----
|
||||
// Full reload of every registered project + merged data rebuild. Used by
|
||||
// init, 'set-changed' polls, restores, and the admin flows (todos 6-8).
|
||||
const reloadProjects = async ({ seedExpanded = false } = {}) => {
|
||||
const [projs, sizeInfo, backups] = await Promise.all([
|
||||
ApiBackend.loadAllProjects(),
|
||||
Storage.getHealthInfo(),
|
||||
Storage.listBackups(),
|
||||
]);
|
||||
const newData = await buildMergedDataFromProjects(projs, { sizeInfo, backups });
|
||||
latestProjects.current = projs;
|
||||
setProjects(projs);
|
||||
setData(newData);
|
||||
if (seedExpanded && !loadLocalState()?.expandedMap) {
|
||||
const em = {};
|
||||
walkTree(newData.entries, it => { em[it.id] = !it.collapsed; });
|
||||
setExpandedMap(em);
|
||||
}
|
||||
return { data: newData, projects: projs };
|
||||
};
|
||||
|
||||
// SyncPoller callback for multi-project polling (api mode).
|
||||
const handleExternalChangeMulti = async (kind, payload) => {
|
||||
if (kind === 'warn') {
|
||||
showToast('File changed externally — you have unsaved edits', 'warn');
|
||||
} else if (kind === 'set-changed') {
|
||||
try {
|
||||
await reloadProjects({ seedExpanded: false });
|
||||
showToast('Projects changed on server — reloaded');
|
||||
} catch (e) {
|
||||
console.error('Project set reload failed:', e); // keep current state; next poll retries
|
||||
}
|
||||
} else if (kind === 'project-reloaded' && payload) {
|
||||
const { name, parsed } = payload;
|
||||
const cur = latestProjects.current;
|
||||
const idx = cur.findIndex(p => p.id === name);
|
||||
if (idx === -1) return; // unknown project — a set-changed poll will pick it up
|
||||
const newProjs = cur.map(p => p.id === name
|
||||
? { ...p, entries: parsed.entries, history: parsed.history, meta: parsed.meta, checksumOk: parsed.checksumOk, missing: false, error: null }
|
||||
: p);
|
||||
latestProjects.current = newProjs;
|
||||
setProjects(newProjs);
|
||||
const merged = await buildMergedDataFromProjects(newProjs, {
|
||||
sizeInfo: latestData.current.health,
|
||||
backups: latestData.current.backups,
|
||||
});
|
||||
setData(merged);
|
||||
showToast(`Reloaded ${name}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Storage initialisation (runs once on mount) ----
|
||||
useEffectMain(() => {
|
||||
let cancelled = false;
|
||||
@@ -121,7 +251,11 @@ function App() {
|
||||
// API mode needs no init — server is already running.
|
||||
|
||||
if (cancelled) return;
|
||||
await applyStorageData(mode, () => cancelled);
|
||||
if (mode === 'api') {
|
||||
await applyProjectsData(() => cancelled);
|
||||
} else {
|
||||
await applyStorageData(mode, () => cancelled);
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setStorageMode('local');
|
||||
@@ -131,6 +265,23 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function applyProjectsData(isCancelled) {
|
||||
const { data: newData, projects: projs } = await reloadProjects({ seedExpanded: true });
|
||||
if (isCancelled?.()) return;
|
||||
|
||||
const saved = loadLocalState();
|
||||
const pem = {};
|
||||
for (const p of projs) pem[p.id] = saved?.projectExpandedMap?.[p.id] ?? true;
|
||||
setProjectExpandedMap(pem);
|
||||
|
||||
if (projs.some(p => !p.checksumOk && p.meta)) setShowWarning(true);
|
||||
|
||||
SyncPoller.startMulti({
|
||||
isDirty: () => isDirtyRef.current,
|
||||
onExternalChange: handleExternalChangeMulti,
|
||||
});
|
||||
}
|
||||
|
||||
async function applyStorageData(mode, isCancelled) {
|
||||
const [raw, backups, sizeInfo] = await Promise.all([
|
||||
Storage.load(),
|
||||
@@ -175,8 +326,8 @@ function App() {
|
||||
|
||||
// Persist expanded/collapsed row state and tweaks locally. Data itself lives in backlog.md.
|
||||
useEffectMain(() => {
|
||||
saveLocalState(latestExpandedMap.current, tweaks);
|
||||
}, [expandedMap, tweaks]);
|
||||
saveLocalState(latestExpandedMap.current, tweaks, latestProjectExpandedMap.current);
|
||||
}, [expandedMap, projectExpandedMap, tweaks]);
|
||||
|
||||
const showToast = (msg, kind = 'ok') => {
|
||||
setToast({ msg, kind, t: Date.now() });
|
||||
@@ -221,7 +372,9 @@ function App() {
|
||||
};
|
||||
|
||||
// ---- Real async save ----
|
||||
const triggerSave = (label) => {
|
||||
// projectId (string or array) restricts the save to that project(s);
|
||||
// omitted = derive the dirty set from _projectId tags.
|
||||
const triggerSave = (label, projectId = null) => {
|
||||
isDirtyRef.current = true;
|
||||
setSaveState(prev => ({ ...prev, status: 'saving' }));
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
@@ -230,14 +383,27 @@ function App() {
|
||||
const d = latestData.current;
|
||||
const em = latestExpandedMap.current;
|
||||
try {
|
||||
if (Storage.isConnected()) {
|
||||
if (Storage.mode === 'api') {
|
||||
// Unified api save path: serialize every DIRTY project separately.
|
||||
// A single registered project is the same loop with one element.
|
||||
const fallbackFirst = latestProjects.current[0]?.id ?? null;
|
||||
const dirtyIds = computeDirtyProjectIds(d, projectId, fallbackFirst);
|
||||
for (const pId of dirtyIds) {
|
||||
const projectEntries = d.entries.filter(e => (e._projectId ?? fallbackFirst) === pId);
|
||||
const projectHistory = d.history.filter(h => (h._projectId ?? fallbackFirst) === pId);
|
||||
const content = await Parser.serialize({ entries: projectEntries, history: projectHistory });
|
||||
const result = await ApiBackend.saveProject(pId, content);
|
||||
// Keep SyncPoller in sync with our own save to avoid false external-change triggers.
|
||||
if (result?.checksum) SyncPoller.lastChecksums[pId] = result.checksum;
|
||||
}
|
||||
} else if (Storage.isConnected()) {
|
||||
const content = await Parser.serialize({ entries: d.entries, history: d.history });
|
||||
await Storage.save(content);
|
||||
// Keep SyncPoller in sync with our own save to avoid false external-change triggers.
|
||||
const cm = content.match(/checksum:\s*(sha256:[a-f0-9]+)/);
|
||||
if (cm) SyncPoller.lastChecksum = cm[1];
|
||||
}
|
||||
saveLocalState(em, tweaks);
|
||||
saveLocalState(em, tweaks, latestProjectExpandedMap.current);
|
||||
isDirtyRef.current = false;
|
||||
const now = new Date().toISOString();
|
||||
setSaveState({ status: 'saved', lastSaved: now });
|
||||
@@ -280,30 +446,37 @@ function App() {
|
||||
// ---- Mutations ----
|
||||
const onMutate = {
|
||||
setStatus: (id, status) => {
|
||||
const item = findItem(data.entries, id);
|
||||
mutate(d => {
|
||||
const it = findItem(d.entries, id);
|
||||
if (!it || it.status === status) return;
|
||||
d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'status_changed', details: `${it.status} → ${status}` });
|
||||
const histEntry = { timestamp: new Date().toISOString(), itemId: id, action: 'status_changed', details: `${it.status} → ${status}` };
|
||||
if (it._projectId) histEntry._projectId = it._projectId;
|
||||
d.history.unshift(histEntry);
|
||||
const wasDone = it.status === 'done';
|
||||
it.status = status;
|
||||
if (status !== 'blocked') it.reason = null;
|
||||
if (status === 'done') it.progress = 100;
|
||||
else if (wasDone && (it.progress ?? 0) >= 100) it.progress = 75;
|
||||
});
|
||||
triggerSave();
|
||||
triggerSave(null, item?._projectId);
|
||||
},
|
||||
|
||||
setPriority: (id, priority) => {
|
||||
const item = findItem(data.entries, id);
|
||||
mutate(d => {
|
||||
const it = findItem(d.entries, id);
|
||||
if (!it || it.priority === priority) return;
|
||||
d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'priority_changed', details: `${it.priority} → ${priority}` });
|
||||
const histEntry = { timestamp: new Date().toISOString(), itemId: id, action: 'priority_changed', details: `${it.priority} → ${priority}` };
|
||||
if (it._projectId) histEntry._projectId = it._projectId;
|
||||
d.history.unshift(histEntry);
|
||||
it.priority = priority;
|
||||
});
|
||||
triggerSave();
|
||||
triggerSave(null, item?._projectId);
|
||||
},
|
||||
|
||||
setProgress: (id, progress) => {
|
||||
const item = findItem(data.entries, id);
|
||||
mutate(d => {
|
||||
const it = findItem(d.entries, id);
|
||||
if (!it) return;
|
||||
@@ -311,12 +484,15 @@ function App() {
|
||||
if ((it.progress ?? 0) === v) return;
|
||||
const before = it.progress ?? 0;
|
||||
it.progress = v;
|
||||
d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'progress_changed', details: `${before}% → ${v}%` });
|
||||
const histEntry = { timestamp: new Date().toISOString(), itemId: id, action: 'progress_changed', details: `${before}% → ${v}%` };
|
||||
if (it._projectId) histEntry._projectId = it._projectId;
|
||||
d.history.unshift(histEntry);
|
||||
});
|
||||
triggerSave();
|
||||
triggerSave(null, item?._projectId);
|
||||
},
|
||||
|
||||
moveWithinPriority: (id, dir) => {
|
||||
const item = findItem(data.entries, id);
|
||||
mutate(d => {
|
||||
const r = findParentList(d.entries, id);
|
||||
if (!r) return;
|
||||
@@ -327,12 +503,15 @@ function App() {
|
||||
while (j >= 0 && j < r.list.length && r.list[j].priority !== me.priority) j += dir;
|
||||
if (j < 0 || j >= r.list.length) return;
|
||||
[r.list[i], r.list[j]] = [r.list[j], r.list[i]];
|
||||
d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'item_reordered', details: `moved ${dir < 0 ? 'up' : 'down'}` });
|
||||
const histEntry = { timestamp: new Date().toISOString(), itemId: id, action: 'item_reordered', details: `moved ${dir < 0 ? 'up' : 'down'}` };
|
||||
if (me._projectId) histEntry._projectId = me._projectId;
|
||||
d.history.unshift(histEntry);
|
||||
});
|
||||
triggerSave();
|
||||
triggerSave(null, item?._projectId);
|
||||
},
|
||||
|
||||
reorder: (draggedId, targetId) => {
|
||||
const item = findItem(data.entries, draggedId);
|
||||
mutate(d => {
|
||||
const src = findParentList(d.entries, draggedId);
|
||||
const tgt = findParentList(d.entries, targetId);
|
||||
@@ -342,13 +521,38 @@ function App() {
|
||||
src.list.splice(draggedIdx, 1);
|
||||
const newTargetIdx = tgt.list.findIndex(x => x.id === targetId);
|
||||
tgt.list.splice(newTargetIdx, 0, dragged);
|
||||
d.history.unshift({ timestamp: new Date().toISOString(), itemId: draggedId, action: 'item_reordered', details: `dropped before ${targetId}` });
|
||||
const histEntry = { timestamp: new Date().toISOString(), itemId: draggedId, action: 'item_reordered', details: `dropped before ${targetId}` };
|
||||
if (dragged._projectId) histEntry._projectId = dragged._projectId;
|
||||
d.history.unshift(histEntry);
|
||||
});
|
||||
triggerSave('Reordered');
|
||||
triggerSave('Reordered', item?._projectId);
|
||||
},
|
||||
|
||||
// Cross-project drag&drop (todo 6 section drop zones call this): move the
|
||||
// node to the destination project's root, re-tagging the whole subtree.
|
||||
// Both source and destination files change, so both are saved.
|
||||
moveToProject: (draggedId, projectId) => {
|
||||
const dragged = findItem(data.entries, draggedId);
|
||||
if (!dragged || !projectId || dragged._projectId === projectId) return;
|
||||
const srcProjectId = dragged._projectId ?? latestProjects.current[0]?.id ?? null;
|
||||
mutate(d => {
|
||||
const r = findParentList(d.entries, draggedId);
|
||||
if (!r) return;
|
||||
const i = r.list.findIndex(x => x.id === draggedId);
|
||||
if (i < 0) return;
|
||||
const [node] = r.list.splice(i, 1);
|
||||
retagSubtree(node, projectId);
|
||||
(function setLevel(it, lv) { it.level = lv; (it.children || []).forEach(c => setLevel(c, lv + 1)); })(node, 1);
|
||||
d.entries.push(node);
|
||||
const histEntry = { timestamp: new Date().toISOString(), itemId: draggedId, action: 'item_reordered', details: `moved to project ${projectId}` };
|
||||
histEntry._projectId = projectId;
|
||||
d.history.unshift(histEntry);
|
||||
});
|
||||
triggerSave('Moved', [srcProjectId, projectId]);
|
||||
},
|
||||
|
||||
addChild: (parentId) => setItemDialog({ mode: 'add-child', parentId, initial: null }),
|
||||
addRoot: () => setItemDialog({ mode: 'add', parentId: null, initial: null }),
|
||||
addRoot: (projectId = null) => setItemDialog({ mode: 'add', parentId: null, initial: null, projectId }),
|
||||
|
||||
editItem: (id) => {
|
||||
const it = findItem(data.entries, id);
|
||||
@@ -360,6 +564,7 @@ function App() {
|
||||
const it = findItem(data.entries, id);
|
||||
if (!it) return;
|
||||
const childCount = (() => { let n = 0; walkTree([it], () => n++); return n - 1; })();
|
||||
const projectId = it._projectId ?? null;
|
||||
setConfirm({
|
||||
title: 'Delete this item?',
|
||||
message: <>Delete <strong>{it.title}</strong>{childCount > 0 ? ` and ${childCount} sub-item${childCount > 1 ? 's' : ''}` : ''}?</>,
|
||||
@@ -375,33 +580,56 @@ function App() {
|
||||
return false;
|
||||
}
|
||||
remove(d.entries);
|
||||
d.history.unshift({ timestamp: new Date().toISOString(), itemId: id, action: 'item_deleted', details: `final: ${it.status}` });
|
||||
const histEntry = { timestamp: new Date().toISOString(), itemId: id, action: 'item_deleted', details: `final: ${it.status}` };
|
||||
if (projectId) histEntry._projectId = projectId;
|
||||
d.history.unshift(histEntry);
|
||||
});
|
||||
setConfirm(null);
|
||||
triggerSave('Deleted');
|
||||
triggerSave('Deleted', projectId);
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Which project does a NEW item belong to? An explicit hint (dialog select
|
||||
// or section context — todos 6-7) wins; otherwise the first registered
|
||||
// project. Non-api modes have no projects → null → item stays untagged.
|
||||
const resolveTargetProjectId = (hint = null) => {
|
||||
if (hint) return hint;
|
||||
const first = latestProjects.current[0];
|
||||
return first ? first.id : null;
|
||||
};
|
||||
|
||||
const submitItemDialog = (vals) => {
|
||||
if (itemDialog.mode === 'edit') {
|
||||
const editedItem = findItem(data.entries, itemDialog.itemId);
|
||||
mutate(d => {
|
||||
const x = findItem(d.entries, itemDialog.itemId);
|
||||
if (!x) return;
|
||||
const before = { priority: x.priority, status: x.status };
|
||||
Object.assign(x, vals);
|
||||
if (before.status !== vals.status)
|
||||
d.history.unshift({ timestamp: new Date().toISOString(), itemId: x.id, action: 'status_changed', details: `${before.status} → ${vals.status}` });
|
||||
if (before.priority !== vals.priority)
|
||||
d.history.unshift({ timestamp: new Date().toISOString(), itemId: x.id, action: 'priority_changed', details: `${before.priority} → ${vals.priority}` });
|
||||
if (before.status !== vals.status) {
|
||||
const histEntry = { timestamp: new Date().toISOString(), itemId: x.id, action: 'status_changed', details: `${before.status} → ${vals.status}` };
|
||||
if (x._projectId) histEntry._projectId = x._projectId;
|
||||
d.history.unshift(histEntry);
|
||||
}
|
||||
if (before.priority !== vals.priority) {
|
||||
const histEntry = { timestamp: new Date().toISOString(), itemId: x.id, action: 'priority_changed', details: `${before.priority} → ${vals.priority}` };
|
||||
if (x._projectId) histEntry._projectId = x._projectId;
|
||||
d.history.unshift(histEntry);
|
||||
}
|
||||
});
|
||||
triggerSave('Saved');
|
||||
triggerSave('Saved', editedItem?._projectId);
|
||||
} else {
|
||||
const newId = 'n-' + Math.random().toString(36).slice(2, 8);
|
||||
const parentForTarget = itemDialog.mode === 'add-child' && itemDialog.parentId
|
||||
? findItem(data.entries, itemDialog.parentId)
|
||||
: null;
|
||||
const targetProjectId = resolveTargetProjectId(itemDialog.projectId || parentForTarget?._projectId || null);
|
||||
mutate(d => {
|
||||
const node = { id: newId, level: 1, ...vals, children: [], collapsed: false };
|
||||
if (itemDialog.mode === 'add-child' && itemDialog.parentId) {
|
||||
if (targetProjectId) node._projectId = targetProjectId;
|
||||
if (parentForTarget) {
|
||||
const parent = findItem(d.entries, itemDialog.parentId);
|
||||
if (parent) {
|
||||
parent.children = parent.children || [];
|
||||
@@ -412,9 +640,11 @@ function App() {
|
||||
} else {
|
||||
d.entries.push(node);
|
||||
}
|
||||
d.history.unshift({ timestamp: new Date().toISOString(), itemId: newId, action: 'item_created', details: vals.title });
|
||||
const histEntry = { timestamp: new Date().toISOString(), itemId: newId, action: 'item_created', details: vals.title };
|
||||
if (targetProjectId) histEntry._projectId = targetProjectId;
|
||||
d.history.unshift(histEntry);
|
||||
});
|
||||
triggerSave('Added');
|
||||
triggerSave('Added', targetProjectId);
|
||||
}
|
||||
setItemDialog(null);
|
||||
};
|
||||
@@ -496,11 +726,17 @@ function App() {
|
||||
if (Storage.isConnected()) {
|
||||
const result = await Storage.restoreBackup(b.name);
|
||||
if (!result.ok) throw new Error(result.error || 'Restore failed');
|
||||
const [raw, backups, sizeInfo] = await Promise.all([Storage.load(), Storage.listBackups(), Storage.getHealthInfo()]);
|
||||
const parsed = await Parser.parse(raw?.content || '');
|
||||
const newData = await buildDataFromStorage(parsed, backups, storageMode, sizeInfo);
|
||||
setData(newData);
|
||||
SyncPoller.lastChecksum = parsed.meta?.checksum || '';
|
||||
if (Storage.mode === 'api') {
|
||||
// The legacy backup route restored the default project — refresh everything.
|
||||
await reloadProjects({ seedExpanded: false });
|
||||
await SyncPoller.syncChecksums();
|
||||
} else {
|
||||
const [raw, backups, sizeInfo] = await Promise.all([Storage.load(), Storage.listBackups(), Storage.getHealthInfo()]);
|
||||
const parsed = await Parser.parse(raw?.content || '');
|
||||
const newData = await buildDataFromStorage(parsed, backups, storageMode, sizeInfo);
|
||||
setData(newData);
|
||||
SyncPoller.lastChecksum = parsed.meta?.checksum || '';
|
||||
}
|
||||
isDirtyRef.current = false;
|
||||
}
|
||||
showToast(`Restored from ${b.name.slice(0, 28)}…`);
|
||||
@@ -513,22 +749,49 @@ function App() {
|
||||
|
||||
// ---- Import entries from parsed content ----
|
||||
const handleImport = ({ entries, history }) => {
|
||||
// In api mode imported rows land in the first project (todo 8 adds a
|
||||
// target-project selector); other projects are left untouched.
|
||||
const pid = Storage.mode === 'api' ? resolveTargetProjectId() : null;
|
||||
mutate(d => {
|
||||
if (pid) {
|
||||
for (const e of entries) e._projectId = pid;
|
||||
}
|
||||
d.entries = entries;
|
||||
if (history?.length) d.history = [...history, ...d.history];
|
||||
d.history.unshift({ timestamp: new Date().toISOString(), itemId: 'system', action: 'imported', details: `${entries.length} top-level entries` });
|
||||
if (history?.length) d.history = [...history.map(h => pid ? { ...h, _projectId: pid } : h), ...d.history];
|
||||
const histEntry = { timestamp: new Date().toISOString(), itemId: 'system', action: 'imported', details: `${entries.length} top-level entries` };
|
||||
if (pid) histEntry._projectId = pid;
|
||||
d.history.unshift(histEntry);
|
||||
});
|
||||
setImportExportOpen(false);
|
||||
triggerSave('Imported');
|
||||
triggerSave('Imported', pid);
|
||||
};
|
||||
|
||||
// ---- Archive functions ----
|
||||
const loadArchive = async () => {
|
||||
try {
|
||||
const raw = await Storage.loadArchive();
|
||||
if (raw.exists && raw.content) {
|
||||
const parsed = await Parser.parse(raw.content);
|
||||
setArchiveData({ entries: parsed.entries || [], history: parsed.history || [] });
|
||||
if (Storage.mode === 'api') {
|
||||
// Each project has its own archive.md — merge them, tagged per project.
|
||||
const projs = latestProjects.current;
|
||||
const parsedAll = await Promise.all(projs.map(async p => {
|
||||
try {
|
||||
const raw = await ApiBackend.loadArchive(p.id);
|
||||
if (raw.exists && raw.content) return await Parser.parse(raw.content);
|
||||
} catch (e) { console.error(`Failed to load archive for ${p.id}:`, e); }
|
||||
return { entries: [], history: [] };
|
||||
}));
|
||||
const entries = [], history = [];
|
||||
projs.forEach((p, i) => {
|
||||
for (const e of parsedAll[i].entries) e._projectId = p.id;
|
||||
entries.push(...parsedAll[i].entries);
|
||||
history.push(...parsedAll[i].history);
|
||||
});
|
||||
setArchiveData({ entries, history });
|
||||
} else {
|
||||
const raw = await Storage.loadArchive();
|
||||
if (raw.exists && raw.content) {
|
||||
const parsed = await Parser.parse(raw.content);
|
||||
setArchiveData({ entries: parsed.entries || [], history: parsed.history || [] });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load archive:', e);
|
||||
@@ -561,32 +824,74 @@ function App() {
|
||||
// Remove from backlog
|
||||
const { entries: newEntries } = removeItems([...data.entries], itemIds);
|
||||
|
||||
// Add to archive
|
||||
const newArchiveEntries = [...archiveData.entries, ...toArchive];
|
||||
|
||||
// Add history entries
|
||||
const newHistory = [...data.history];
|
||||
for (const item of toArchive) {
|
||||
newHistory.unshift({
|
||||
const histEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
itemId: item.id,
|
||||
action: 'item_archived',
|
||||
details: `moved to archive.md`
|
||||
});
|
||||
};
|
||||
if (item._projectId) histEntry._projectId = item._projectId;
|
||||
newHistory.unshift(histEntry);
|
||||
}
|
||||
|
||||
try {
|
||||
// Serialize both files
|
||||
const backlogContent = await Parser.serialize({ entries: newEntries, history: newHistory });
|
||||
const archiveContent = await Parser.serialize({ entries: newArchiveEntries, history: archiveData.history });
|
||||
// Add to archive
|
||||
const newArchiveEntries = [...archiveData.entries, ...toArchive];
|
||||
|
||||
// Save both atomically
|
||||
const result = await Storage.saveArchive(backlogContent, archiveContent);
|
||||
if (!result.ok) throw new Error('Archive failed');
|
||||
try {
|
||||
if (Storage.mode === 'api') {
|
||||
// Per-project archive: group the batch by _projectId, then for each
|
||||
// affected project write its remaining backlog + its own archive.
|
||||
const fallbackFirst = latestProjects.current[0]?.id ?? null;
|
||||
const pidOf = x => x._projectId ?? fallbackFirst;
|
||||
const groups = new Map();
|
||||
for (const item of toArchive) {
|
||||
const pid = pidOf(item);
|
||||
if (!groups.has(pid)) groups.set(pid, []);
|
||||
groups.get(pid).push(item);
|
||||
}
|
||||
const existing = await Promise.all([...groups.keys()].map(async pid => {
|
||||
try {
|
||||
const raw = await ApiBackend.loadArchive(pid);
|
||||
if (raw.exists && raw.content) {
|
||||
const parsed = await Parser.parse(raw.content);
|
||||
return { pid, entries: parsed.entries || [], history: parsed.history || [] };
|
||||
}
|
||||
} catch (e) { console.error(`Failed to load archive for ${pid}:`, e); }
|
||||
return { pid, entries: [], history: [] };
|
||||
}));
|
||||
for (const { pid, entries: archEntries, history: archHistory } of existing) {
|
||||
const backlogContent = await Parser.serialize({
|
||||
entries: newEntries.filter(e => pidOf(e) === pid),
|
||||
history: newHistory.filter(h => pidOf(h) === pid),
|
||||
});
|
||||
const archiveContent = await Parser.serialize({
|
||||
entries: [...archEntries, ...groups.get(pid)],
|
||||
history: archHistory,
|
||||
});
|
||||
const result = await ApiBackend.saveArchive(backlogContent, archiveContent, pid);
|
||||
if (!result.ok) throw new Error('Archive failed');
|
||||
}
|
||||
await SyncPoller.syncChecksums();
|
||||
} else {
|
||||
// Serialize both files
|
||||
const backlogContent = await Parser.serialize({ entries: newEntries, history: newHistory });
|
||||
const archiveContent = await Parser.serialize({ entries: newArchiveEntries, history: archiveData.history });
|
||||
|
||||
// Save both atomically
|
||||
const result = await Storage.saveArchive(backlogContent, archiveContent);
|
||||
if (!result.ok) throw new Error('Archive failed');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setData(d => ({ ...d, entries: newEntries, history: newHistory }));
|
||||
setArchiveData({ entries: newArchiveEntries, history: archiveData.history });
|
||||
if (Storage.mode === 'api') {
|
||||
await loadArchive(); // refresh the merged per-project archive view from disk
|
||||
} else {
|
||||
setArchiveData({ entries: newArchiveEntries, history: archiveData.history });
|
||||
}
|
||||
isDirtyRef.current = false;
|
||||
|
||||
showToast(`Archived ${toArchive.length} item${toArchive.length > 1 ? 's' : ''}`);
|
||||
@@ -628,26 +933,52 @@ function App() {
|
||||
// Add history entries
|
||||
const newHistory = [...data.history];
|
||||
for (const item of toRestore) {
|
||||
newHistory.unshift({
|
||||
const histEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
itemId: item.id,
|
||||
action: 'item_restored',
|
||||
details: `restored from archive`
|
||||
});
|
||||
};
|
||||
if (item._projectId) histEntry._projectId = item._projectId;
|
||||
newHistory.unshift(histEntry);
|
||||
}
|
||||
|
||||
try {
|
||||
// Serialize both files
|
||||
const backlogContent = await Parser.serialize({ entries: newEntries, history: newHistory });
|
||||
const archiveContent = await Parser.serialize({ entries: remainingArchive, history: archiveData.history });
|
||||
if (Storage.mode === 'api') {
|
||||
// Mirror of the archive flow per project: only projects whose items
|
||||
// were restored get their backlog + archive rewritten.
|
||||
const fallbackFirst = latestProjects.current[0]?.id ?? null;
|
||||
const pidOf = x => x._projectId ?? fallbackFirst;
|
||||
for (const pid of [...new Set(toRestore.map(pidOf))]) {
|
||||
const backlogContent = await Parser.serialize({
|
||||
entries: newEntries.filter(e => pidOf(e) === pid),
|
||||
history: newHistory.filter(h => pidOf(h) === pid),
|
||||
});
|
||||
const archiveContent = await Parser.serialize({
|
||||
entries: remainingArchive.filter(e => pidOf(e) === pid),
|
||||
history: [],
|
||||
});
|
||||
const result = await ApiBackend.restoreFromArchive(backlogContent, archiveContent, pid);
|
||||
if (!result.ok) throw new Error('Restore failed');
|
||||
}
|
||||
await SyncPoller.syncChecksums();
|
||||
} else {
|
||||
// Serialize both files
|
||||
const backlogContent = await Parser.serialize({ entries: newEntries, history: newHistory });
|
||||
const archiveContent = await Parser.serialize({ entries: remainingArchive, history: archiveData.history });
|
||||
|
||||
// Save both atomically
|
||||
const result = await Storage.restoreFromArchive(backlogContent, archiveContent);
|
||||
if (!result.ok) throw new Error('Restore failed');
|
||||
// Save both atomically
|
||||
const result = await Storage.restoreFromArchive(backlogContent, archiveContent);
|
||||
if (!result.ok) throw new Error('Restore failed');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setData(d => ({ ...d, entries: newEntries, history: newHistory }));
|
||||
setArchiveData({ entries: remainingArchive, history: archiveData.history });
|
||||
if (Storage.mode === 'api') {
|
||||
await loadArchive(); // refresh the merged per-project archive view from disk
|
||||
} else {
|
||||
setArchiveData({ entries: remainingArchive, history: archiveData.history });
|
||||
}
|
||||
isDirtyRef.current = false;
|
||||
|
||||
let msg = `Restored ${toRestore.length} item${toRestore.length > 1 ? 's' : ''}`;
|
||||
@@ -1170,4 +1501,12 @@ function ArchiveSearchResults({ query, archiveEntries, onRestore, onLoadArchive,
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-project data-layer helpers — consumed by todos 6-8 (sections UI,
|
||||
// dialogs, admin) and the todo-5 verification harness.
|
||||
Object.assign(window, {
|
||||
buildMergedDataFromProjects,
|
||||
computeDirtyProjectIds,
|
||||
retagSubtree,
|
||||
});
|
||||
|
||||
window.App = App;
|
||||
|
||||
@@ -767,17 +767,45 @@ const Storage = {
|
||||
const SyncPoller = {
|
||||
_timer: null,
|
||||
lastChecksum: '',
|
||||
lastChecksums: {}, // multi-project mode: { projectName: checksum }
|
||||
_onExternalChange: null,
|
||||
_isDirty: null,
|
||||
_multi: false,
|
||||
|
||||
start({ onExternalChange, isDirty }) {
|
||||
this._onExternalChange = onExternalChange;
|
||||
this._isDirty = isDirty;
|
||||
this._multi = false;
|
||||
this._timer = setInterval(() => this._tick(), 5000);
|
||||
},
|
||||
|
||||
// Multi-project mode (api only): poll GET /api/projects — ONE round trip for
|
||||
// all checksums. A changed project is reloaded individually; a changed
|
||||
// project SET (registered/renamed/unregistered externally) triggers a full
|
||||
// reload via the 'set-changed' callback.
|
||||
startMulti({ onExternalChange, isDirty }) {
|
||||
this._onExternalChange = onExternalChange;
|
||||
this._isDirty = isDirty;
|
||||
this._multi = true;
|
||||
this.lastChecksums = {};
|
||||
this.syncChecksums(); // baseline; a baseline never fires callbacks
|
||||
this._timer = setInterval(() => this._tickMulti(), 5000);
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
this._multi = false;
|
||||
},
|
||||
|
||||
// Re-baseline checksums after our own writes that bypass saveProject (e.g.
|
||||
// per-project archive saves), so the next poll does not mistake them for
|
||||
// external changes.
|
||||
async syncChecksums() {
|
||||
if (!this._multi) return;
|
||||
try {
|
||||
const projects = await ApiBackend.listProjects();
|
||||
for (const p of projects) this.lastChecksums[p.name] = p.checksum || '';
|
||||
} catch { /* silent on poll errors */ }
|
||||
},
|
||||
|
||||
async _tick() {
|
||||
@@ -796,6 +824,47 @@ const SyncPoller = {
|
||||
}
|
||||
} catch { /* silent on poll errors */ }
|
||||
},
|
||||
|
||||
async _tickMulti() {
|
||||
if (!this._multi) return;
|
||||
let projects;
|
||||
try { projects = await ApiBackend.listProjects(); }
|
||||
catch { return; }
|
||||
if (!projects?.length) return;
|
||||
|
||||
const prev = this.lastChecksums;
|
||||
const known = Object.keys(prev);
|
||||
const names = projects.map(p => p.name);
|
||||
if (known.length > 0 && (names.length !== known.length || names.some(n => !known.includes(n)))) {
|
||||
if (this._isDirty?.()) {
|
||||
// Leave the baseline stale so the next tick retries after the
|
||||
// pending save lands — reloading now would clobber unsaved edits.
|
||||
this._onExternalChange?.('warn', null);
|
||||
} else {
|
||||
this.lastChecksums = Object.fromEntries(projects.map(p => [p.name, p.checksum || '']));
|
||||
this._onExternalChange?.('set-changed', null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const p of projects) {
|
||||
const cs = p.checksum || '';
|
||||
if (!(p.name in prev)) { prev[p.name] = cs; continue; } // baseline a latecomer silently
|
||||
if (!cs || cs === prev[p.name]) continue;
|
||||
prev[p.name] = cs;
|
||||
if (this._isDirty?.()) {
|
||||
// Checksum already stored — the warn fires exactly once per change.
|
||||
this._onExternalChange?.('warn', null);
|
||||
} else {
|
||||
try {
|
||||
const raw = await ApiBackend.loadProject(p.name);
|
||||
const parsed = await Parser.parse(raw.content);
|
||||
walkTree(parsed.entries, e => { e._projectId = p.name; });
|
||||
for (const h of parsed.history) h._projectId = p.name;
|
||||
this._onExternalChange?.('project-reloaded', { name: p.name, parsed });
|
||||
} catch { /* unreadable project — retry on the next checksum change */ }
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ---- buildDataFromStorage — assemble the full App data object from parsed content ----
|
||||
|
||||
Reference in New Issue
Block a user