Add multi-project state, merged loading, and per-project saving

This commit is contained in:
Alexander
2026-08-24 13:25:15 +02:00
parent 44a247e725
commit 55ae8eeff7
2 changed files with 472 additions and 64 deletions
+69
View File
@@ -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 ----