diff --git a/README.md b/README.md index 930f278..367edc3 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ - `lifeos_settings` - `lifeos_system` -备份格式包含 `schemaVersion: 1`,为后续数据迁移预留兼容空间。目标和待办保存在 `lifeos_system.tasksBoard`,记忆及其标签、地点、关联待办保存在 `lifeos_memories`,都随现有云端数据同步并包含在 JSON 备份中。旧版记忆继续兼容,新增信息为选填。 +JSON 备份使用 `schemaVersion: 2`,导入仍兼容 v1 备份。D1 表结构保持 v1:目标和待办保存在 `lifeos_system.tasksBoard`,随现有 `system_json` 字段同步;记忆及其标签、地点、关联待办保存在 `lifeos_memories`。云端恢复时会把本机尚未同步的目标与待办合并并补传。旧版记忆继续兼容,新增信息为选填。 ## 运行 diff --git a/package.json b/package.json index ad6295b..328673e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "life-os", - "version": "1.4.0", + "version": "1.4.2", "private": true, "type": "module", "scripts": { diff --git a/public/addons.js b/public/addons.js index 447a971..c2d366d 100644 --- a/public/addons.js +++ b/public/addons.js @@ -1,7 +1,7 @@ /* Life OS reliability and productivity add-ons. */ (() => { const DRAFT_KEY = 'lifeos_today_draft_v1'; - const APP_VERSION = '1.4.0'; + const APP_VERSION = '1.4.2'; const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system']; const t = (key, values) => LifeI18n.t(key, values); @@ -75,33 +75,44 @@ }); } function exportData() { - const data = { app: 'Life OS', version: APP_VERSION, schemaVersion: 1, exportedAt: new Date().toISOString(), profile: profile(), memories: mems(), museum: museum(), settings: settings(), system: sys() }; + const data = { app: 'Life OS', version: APP_VERSION, schemaVersion: 2, exportedAt: new Date().toISOString(), profile: profile(), memories: mems(), museum: museum(), settings: settings(), system: sys() }; const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })); link.download = `life-os-backup-${day()}.json`; link.click(); const state = settings(); state.lastBackup = new Date().toISOString(); save('lifeos_settings', state); renderAll(); toast('feedback.backup'); } + // 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; - const cloudFallback = { profile: {}, memories: [], museum: [], settings: {}, system: {} }; + const cloudFallback = { profile: {}, memories: [], museum: [], settings: {}, system: { schemaVersion: 2 } }; function cloudSnapshot() { - return Object.fromEntries(Object.entries(CLOUD_KEYS).map(([field, key]) => { + const snapshot = Object.fromEntries(Object.entries(CLOUD_KEYS).map(([field, key]) => { try { return [field, JSON.parse(localStorage.getItem(key) || JSON.stringify(cloudFallback[field]))]; } catch { return [field, cloudFallback[field]]; } })); + snapshot.system = { ...snapshot.system, schemaVersion: 2 }; + return snapshot; } 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 = []) => [...cloudItems, ...localItems.filter(item => !cloudItems.some(existing => existing.id === item.id))]; + 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; renderIcons(); renderAll(); applyLanguage(); + return mergedLocalBoard || systemNeedsMigration; } async function uploadCloudState() { if (!cloudReady) return; @@ -131,7 +142,9 @@ const local = cloudSnapshot(); if (state) { if (hasLocalLife(local)) localStorage.setItem('lifeos_precloud_backup_v1', JSON.stringify({ exportedAt: new Date().toISOString(), state: local })); - applyCloudState(state); cloudReady = true; toast('addon.cloudRestored'); + const mergedLocalBoard = applyCloudState(state); cloudReady = true; + if (mergedLocalBoard) await uploadCloudState(); + toast('addon.cloudRestored'); } else if (hasLocalLife(local)) { migrationPrompt(local); } else { diff --git a/public/i18n-renderers.js b/public/i18n-renderers.js index 2d2d12c..d0d0beb 100644 --- a/public/i18n-renderers.js +++ b/public/i18n-renderers.js @@ -69,14 +69,14 @@ window.saveProfile = () => { save('lifeos_profile', { name: pname.value, birthday: pbday.value, startDate: pstart.value || day(), motto: pmotto.value }); renderAll(); toast(t('profile.saved')); }; window.systemApp = container => { const state = settings(), bytes = new Blob([JSON.stringify({ mems: mems(), museum: museum(), profile: profile(), system: sys() })]).size, oldBackup = state.lastBackup && Date.now() - new Date(state.lastBackup) > 12096e5, detected = browserLanguage() === 'zh' ? t('system.detectedZh') : t('system.detectedEn'); - container.innerHTML = `
${detected}
${t('system.local')}
${lang() === 'zh' ? `${mems().length} 条记忆 · ${museum().length} 件博物馆藏品 · ${sys().tasksBoard?.tasks?.length || 0} 项待办 · 约 ${Math.ceil(bytes/1024)} KB` : `${mems().length} memories · ${museum().length} museum items · ${sys().tasksBoard?.tasks?.length || 0} tasks · Approx. ${Math.ceil(bytes/1024)} KB`}
${t('system.stored')}
${t('system.backups')}
${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `
${t('system.recommended')}` : ''}
Life OS
Version 1.4.0 · Schema v1
${detected}
${t('system.local')}
${lang() === 'zh' ? `${mems().length} 条记忆 · ${museum().length} 件博物馆藏品 · ${sys().tasksBoard?.tasks?.length || 0} 项待办 · 约 ${Math.ceil(bytes/1024)} KB` : `${mems().length} memories · ${museum().length} museum items · ${sys().tasksBoard?.tasks?.length || 0} tasks · Approx. ${Math.ceil(bytes/1024)} KB`}
${t('system.stored')}
${t('system.backups')}
${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `
${t('system.recommended')}` : ''}
${lang() === 'zh' ? 'Life OS
版本 1.4.2 · 备份格式 v2 · 数据库结构 v1' : 'Life OS
Version 1.4.2 · Backup schema v2 · D1 schema v1'}
${t('dialog.exported',{date:short(data.exportedAt.slice(0,10))})}
${t('dialog.memories',{count:data.memories.length})}
${t('dialog.museumItems',{count:data.museum.length})}
${t('dialog.exported',{date:short(data.exportedAt.slice(0,10))})}
${t('dialog.memories',{count:data.memories.length})}
${t('dialog.museumItems',{count:data.museum.length})}
${t('dialog.goals',{count:board.goals?.length || 0})}
${t('dialog.tasks',{count:board.tasks?.length || 0})}
${t('dialog.replaceWarning')}