feat(storage): migrate Life OS cloud state to versioned records
This commit is contained in:
1 parent
6972a00445
commit
1606c41f43
17 files changed
+502
-72
No files matched your search
+118
-25
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
|
||||
|
||||
Reference in new issue
Block a user