feat(storage): migrate Life OS cloud state to versioned records

This commit is contained in:
Codex committed 2026-09-28 18:38:29 +08:00
1 parent 6972a00445
commit 1606c41f43
17 files changed
+502 -72

No files matched your search

+118 -25
View File
@@ -3,7 +3,7 @@
const DRAFT_KEY = 'lifeos_today_draft_v1';
const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; };
const escapeHtml = value => String(value || '').replace(/[&<>]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
const APP_VERSION = '1.12.0';
const APP_VERSION = '1.13.0';
window.LifeOSVersion = APP_VERSION;
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser', 'terminal'];
const t = (key, values) => LifeI18n.t(key, values);
@@ -80,7 +80,8 @@
// The System panel uses inline onclick handlers, so expose this complete exporter.
window.exportData = exportData;
const CLOUD_KEYS = { profile: 'lifeos_profile', memories: 'lifeos_memories', museum: 'lifeos_museum', settings: 'lifeos_settings', system: 'lifeos_system' };
let cloudReady = false, applyingCloud = false, syncTimer = null, syncErrorShown = false;
let cloudReady = false, applyingCloud = false, syncTimer = null, syncErrorShown = false, syncing = false, syncAgain = false;
let baseline = null, versions = null;
const cloudFallback = { profile: {}, memories: [], museum: [], settings: {}, system: { schemaVersion: 2 } };
function cloudSnapshot() {
const snapshot = Object.fromEntries(Object.entries(CLOUD_KEYS).map(([field, key]) => {
@@ -91,29 +92,90 @@
}
function hasLocalLife(state) { return state.memories.length || state.museum.length || Object.keys(state.profile).length || Object.keys(state.settings).length > 2 || state.system?.tasksBoard?.goals?.length || state.system?.tasksBoard?.tasks?.length; }
function applyCloudState(state) {
let localSystem = {};
try { localSystem = JSON.parse(localStorage.getItem('lifeos_system') || '{}'); } catch {}
const localBoard = localSystem.tasksBoard, cloudBoard = state.system?.tasksBoard;
const systemNeedsMigration = (Number(state.system?.schemaVersion) || 1) < 2;
state = { ...state, system: { ...(state.system || {}), schemaVersion: 2 } };
let mergedLocalBoard = false;
if ((localBoard?.goals?.length || localBoard?.tasks?.length) && state.system) {
const mergeById = (cloudItems = [], localItems = []) => {
mergedLocalBoard ||= localItems.some(item => !cloudItems.some(existing => existing.id === item.id));
return [...cloudItems, ...localItems.filter(item => !cloudItems.some(existing => existing.id === item.id))];
};
state = { ...state, system: { ...state.system, tasksBoard: { goals: mergeById(cloudBoard?.goals, localBoard.goals), tasks: mergeById(cloudBoard?.tasks, localBoard.tasks) } } };
}
applyingCloud = true;
Object.entries(CLOUD_KEYS).forEach(([field, key]) => localStorage.setItem(key, JSON.stringify(state[field] ?? cloudFallback[field])));
applyingCloud = false;
todayDraft = null; window.renderIcons?.(); window.renderAll?.(); window.applyLanguage?.(); window.applyDesktopPreferences?.();
return mergedLocalBoard || systemNeedsMigration;
}
const clone = value => JSON.parse(JSON.stringify(value));
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
function parts(state) {
const { tasksBoard, ...system } = state.system || {};
return { profile: state.profile || {}, settings: state.settings || {}, system,
memories: state.memories || [], museum: state.museum || [],
goals: tasksBoard?.goals || [], tasks: tasksBoard?.tasks || [] };
}
function record(state, kind, id) {
const group = parts(state)[kind];
return Array.isArray(group) ? group.find(item => item.id === id) || null : group;
}
function replaceRecord(state, kind, id, value) {
const next = clone(state), board = next.system.tasksBoard ||= { goals: [], tasks: [] };
if (['goals', 'tasks'].includes(kind)) board[kind] = [...board[kind].filter(item => item.id !== id), ...(value ? [value] : [])];
else if (['memories', 'museum'].includes(kind)) next[kind] = [...next[kind].filter(item => item.id !== id), ...(value ? [value] : [])];
else if (kind === 'system') next.system = { ...(value || {}), tasksBoard: board };
else next[kind] = value;
return next;
}
function changes(from, to) {
const before = parts(from), after = parts(to), operations = [];
for (const kind of ['profile', 'settings', 'system']) {
if (!same(before[kind], after[kind])) operations.push({ kind, id: 'value', value: after[kind], baseVersion: versions[kind].value || 0 });
}
for (const kind of ['memories', 'goals', 'tasks', 'museum']) {
const old = new Map(before[kind].map(item => [item.id, item]));
const current = new Map(after[kind].map(item => [item.id, item]));
for (const id of new Set([...old.keys(), ...current.keys()])) {
if (!id || same(old.get(id), current.get(id))) continue;
operations.push({ kind, id, value: current.get(id) || null, baseVersion: versions[kind][id] || 0 });
}
}
return operations;
}
function saveBaseline() {
localStorage.setItem('lifeos_cloud_baseline_v3', JSON.stringify({ state: baseline, versions }));
}
async function post(url, value) {
return fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) });
}
async function resolveConflict(op) {
const response = await fetch('/api/lifeos-state', { cache: 'no-store' });
if (!response.ok) throw new Error('conflict fetch failed');
const latest = await response.json();
const remote = record(latest.state, op.kind, op.id);
localStorage.setItem('lifeos_sync_conflict_backup_v1', JSON.stringify({ exportedAt: new Date().toISOString(), state: cloudSnapshot() }));
const overwrite = remote !== null && confirm(t('addon.syncConflictConfirm'));
baseline = replaceRecord(baseline, op.kind, op.id, remote);
versions[op.kind][op.id] = latest.versions[op.kind][op.id] || 0;
if (!overwrite) {
const restored = replaceRecord(cloudSnapshot(), op.kind, op.id, remote);
applyingCloud = true;
const field = ['goals', 'tasks'].includes(op.kind) ? 'system' : op.kind;
localStorage.setItem(CLOUD_KEYS[field], JSON.stringify(restored[field]));
applyingCloud = false;
window.renderAll?.();
}
saveBaseline();
}
async function uploadCloudState() {
if (!cloudReady) return;
const response = await fetch('/api/lifeos-state', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ state: cloudSnapshot() }) });
if (!response.ok) throw new Error('sync failed');
if (!cloudReady || syncing) { syncAgain = true; return; }
syncing = true;
try {
do {
syncAgain = false;
for (const op of changes(baseline, cloudSnapshot())) {
const response = await post('/api/lifeos-sync', op);
if (response.status === 409) { await resolveConflict(op); syncAgain = true; break; }
if (!response.ok) throw new Error('sync failed');
const result = await response.json();
baseline = replaceRecord(baseline, op.kind, op.id, op.value);
versions[op.kind][op.id] = result.version;
saveBaseline();
}
} while (syncAgain || changes(baseline, cloudSnapshot()).length);
syncErrorShown = false;
} finally { syncing = false; }
}
function scheduleCloudSync() {
if (!cloudReady || applyingCloud) return;
@@ -127,29 +189,60 @@
dialog.id = 'lifeos-cloud-migration';
dialog.innerHTML = `<div class="lifeos-cloud-card"><div class="eyebrow">${t('addon.syncReady')}</div><h2>${t('addon.migrateQuestion')}</h2><p>${t('addon.migrateBody')}</p><div class="actions"><button class="primary" data-cloud="move">${t('addon.migrate')}</button><button class="secondary" data-cloud="empty">${t('addon.fresh')}</button></div></div>`;
document.body.append(dialog);
dialog.querySelector('[data-cloud="move"]').addEventListener('click', async () => { dialog.remove(); cloudReady = true; await uploadCloudState(); toast('addon.migrated'); });
dialog.querySelector('[data-cloud="empty"]').addEventListener('click', async () => { dialog.remove(); applyCloudState(cloudFallback); cloudReady = true; await uploadCloudState(); });
dialog.querySelector('[data-cloud="move"]').addEventListener('click', async () => { dialog.remove(); try { await initializeCloud(state); toast('addon.migrated'); } catch { toast('addon.cloudUnavailable'); } });
dialog.querySelector('[data-cloud="empty"]').addEventListener('click', async () => { dialog.remove(); applyCloudState(cloudFallback); try { await initializeCloud(cloudFallback); } catch { toast('addon.cloudUnavailable'); } });
}
async function initializeCloud(state) {
const response = await post('/api/lifeos-initialize', { state });
if (!response.ok) throw new Error('cloud initialization failed');
const data = await response.json();
baseline = clone(data.state); versions = data.versions; cloudReady = true; saveBaseline();
}
async function startCloudSync() {
try {
const response = await fetch('/api/lifeos-state', { cache: 'no-store' });
if (!response.ok) throw new Error('cloud unavailable');
const { state } = await response.json();
const { state, versions: cloudVersions } = await response.json();
const local = cloudSnapshot();
if (state) {
if (hasLocalLife(local)) localStorage.setItem('lifeos_precloud_backup_v1', JSON.stringify({ exportedAt: new Date().toISOString(), state: local }));
const mergedLocalBoard = applyCloudState(state); cloudReady = true;
if (mergedLocalBoard) await uploadCloudState();
toast('addon.cloudRestored');
let previous = null;
try { previous = JSON.parse(localStorage.getItem('lifeos_cloud_baseline_v3') || 'null'); } catch {}
const pending = previous?.state ? changesFromSaved(previous.state, local) : [];
baseline = clone(state); versions = cloudVersions;
applyCloudState(state);
let hadConflict = false;
for (const op of pending) {
if (!same(record(previous.state, op.kind, op.id), record(state, op.kind, op.id))) {
localStorage.setItem('lifeos_sync_conflict_backup_v1', JSON.stringify({ exportedAt: new Date().toISOString(), state: local }));
hadConflict = true;
if (record(state, op.kind, op.id) === null || !confirm(t('addon.syncConflictConfirm'))) continue;
}
const restored = replaceRecord(cloudSnapshot(), op.kind, op.id, op.value);
applyingCloud = true;
const field = ['goals', 'tasks'].includes(op.kind) ? 'system' : op.kind;
localStorage.setItem(CLOUD_KEYS[field], JSON.stringify(restored[field]));
applyingCloud = false;
}
cloudReady = true; saveBaseline();
if (pending.length) await uploadCloudState();
toast(hadConflict ? 'addon.syncConflict' : 'addon.cloudRestored');
} else if (hasLocalLife(local)) {
migrationPrompt(local);
} else {
cloudReady = true; await uploadCloudState();
await initializeCloud(cloudFallback);
}
} catch {
if (!syncErrorShown) { syncErrorShown = true; toast('addon.cloudUnavailable'); }
}
}
function changesFromSaved(from, to) {
const savedVersions = versions;
versions = { profile: {}, settings: {}, system: {}, memories: {}, goals: {}, tasks: {}, museum: {} };
const result = changes(from, to);
versions = savedVersions;
return result;
}
const originalSetItem = Storage.prototype.setItem;
Storage.prototype.setItem = function(key, value) {
originalSetItem.call(this, key, value);