From 1606c41f43f4eb05a2e9978e8141567e1ee7b4c5 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 28 Sep 2026 18:38:29 +0800 Subject: [PATCH] feat(storage): migrate Life OS cloud state to versioned records --- CHANGELOG.md | 7 ++ README.md | 14 +-- drizzle/0001_lifeos_records.sql | 88 +++++++++++++++++++ drizzle/meta/_journal.json | 7 ++ package.json | 5 +- public/addons.js | 143 ++++++++++++++++++++++++------ public/i18n-renderers.js | 2 +- public/index.html | 6 +- public/locales/en.json | 2 + public/locales/zh-CN.json | 2 + public/service-worker.js | 2 +- public/terminal-app.js | 4 +- scripts/build.mjs | 3 +- scripts/test-cloud.mjs | 105 ++++++++++++++++++++++ scripts/validate.mjs | 5 +- worker/cloud-state.js | 148 ++++++++++++++++++++++++++++++++ worker/index.js | 31 +------ 17 files changed, 502 insertions(+), 72 deletions(-) create mode 100644 drizzle/0001_lifeos_records.sql create mode 100644 scripts/test-cloud.mjs create mode 100644 worker/cloud-state.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e0923..c9be523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ 本项目按 `v主版本.次版本.修订版本` 标记发布。下列内容以对应 Git tag 和已提交功能为准。 +## v1.13.0 — 2026-09-28 + +- D1 新增按记录存储的记忆、标签、目标、待办和收藏表;资料与设置保留 JSON。 +- 用户首次访问时从旧状态表回填数据,旧表保留为只读迁移来源;浏览器备份格式仍为 Schema v2。 +- 云同步改为逐条提交与版本校验;跨设备修改同一记录时保存本地冲突副本并让用户选择。旧版整份状态写入接口停止接收写入。 +- 增加 SQLite 集成验证,覆盖回填、标签更新、删除标记、版本冲突和用户隔离。 + ## v1.12.0 — 2026-09-28 - 目标与待办可在终端逐项创建;记忆、目标、待办可先选择记录,再输入 `yes` 确认删除。 diff --git a/README.md b/README.md index 593e761..ac35193 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 以桌面操作系统为交互方式的个人记录网站。用 App 记录日常、管理目标与待办,并沿时间线回看已经留下的内容。 -在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.12.0** · [更新日志](CHANGELOG.md) +在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.13.0** · [更新日志](CHANGELOG.md) ## 功能 @@ -29,11 +29,11 @@ ## 数据与备份 -站点使用 ChatGPT 登录标识隔离不同用户的数据。登录后,资料通过同源 API 保存到 Sites 的 D1 数据库;浏览器 `localStorage` 充当缓存、草稿及首次迁移来源。首次云端恢复前,本地资料会保留一份迁移前副本。目标与任务存于 `lifeos_system.tasksBoard`,记忆及其可选信息存于 `lifeos_memories`。 +站点使用 ChatGPT 登录标识隔离不同用户的数据。登录后,资料通过同源 API 保存到 Sites 的 D1 数据库;浏览器 `localStorage` 充当缓存、草稿及首次迁移来源。首次云端恢复前,本地资料会保留一份迁移前副本。浏览器缓存与 JSON 备份仍使用 `lifeos_system.tasksBoard`;云端将其中的目标和待办拆分为独立记录。 **系统设置 → 导出数据** 会生成 JSON 备份;导入支持合并或覆盖。备份格式使用 `schemaVersion: 2`,仍可导入 v1 备份。壁纸来源、快捷方式、开机 App 和小部件开关包含在同步与备份中;窗口布局与小部件格位分别保存在本设备的 `lifeos_window_layout_v1`、`lifeos_widget_layout_v1`,也包含在 JSON 导出中,覆盖导入时可恢复。未保存的今日草稿仅留在本机。 -主要数据键:`lifeos_memories`、`lifeos_museum`、`lifeos_profile`、`lifeos_settings`、`lifeos_system`。D1 的初始迁移位于 `drizzle/0000_lifeos_cloud_state.sql`:它将各类数据作为 JSON 字段保存在每个用户的一行记录中;应用的备份 schema 版本与数据库迁移编号是两回事。建议定期导出备份。 +主要本地数据键:`lifeos_memories`、`lifeos_museum`、`lifeos_profile`、`lifeos_settings`、`lifeos_system`。云端表结构见 `drizzle/0001_lifeos_records.sql`:记忆、标签、目标、待办、收藏各自按记录保存,资料与设置保留按用户存储的 JSON。旧版 `lifeos_states` 作为只读迁移来源保留;用户首次访问新版本时自动回填新表,旧表不会被清空。单条记录的修改使用版本号校验;冲突时会保存本地副本并要求选择。旧版浏览器标签页需刷新,整份状态写入接口已停用,避免覆盖新数据。应用的备份 `schemaVersion: 2` 与数据库迁移编号是两回事。建议定期导出备份。 ## 构建与验证 @@ -42,9 +42,10 @@ ```bash npm run build npm run validate +npm run test:cloud ``` -`build` 将页面及 App 脚本打包成 `dist/server/` 中的 Cloudflare Worker 产物;`validate` 检查产物、双语键与主要交互。生产托管配置在 `.openai/hosting.json`,含 Sites 项目 ID 和 D1 逻辑绑定。直接在其他环境部署时,需要提供兼容的 Worker 环境与 `DB` 绑定;云端 API 还依赖托管层提供的 `oai-authenticated-user-id` 请求头。 +`build` 将页面及 App 脚本打包成 `dist/server/` 中的 Cloudflare Worker 产物;`validate` 检查产物、双语键与主要交互;`test:cloud` 使用内存 SQLite 检查旧数据回填、版本冲突与租户隔离。生产托管配置在 `.openai/hosting.json`,含 Sites 项目 ID 和 D1 逻辑绑定。直接在其他环境部署时,需要提供兼容的 Worker 环境与 `DB` 绑定;云端 API 还依赖托管层提供的 `oai-authenticated-user-id` 请求头。 ## 项目结构 @@ -61,8 +62,9 @@ public/desktop-widgets.js 吸附网格与桌面小部件 public/addons.js 导入导出、云同步与辅助操作 public/locales/ 中文、英文文案 public/service-worker.js PWA 缓存 -worker/index.js Worker 路由、数据 API、必应图片代理 -drizzle/ D1 初始迁移 +worker/index.js Worker 路由与必应图片代理 +worker/cloud-state.js 云端记录迁移与同步 API +drizzle/ D1 数据表迁移 scripts/ 构建与产物验证 ``` diff --git a/drizzle/0001_lifeos_records.sql b/drizzle/0001_lifeos_records.sql new file mode 100644 index 0000000..912d63b --- /dev/null +++ b/drizzle/0001_lifeos_records.sql @@ -0,0 +1,88 @@ +CREATE TABLE lifeos_migrations ( + user_id TEXT PRIMARY KEY, + migrated_at TEXT NOT NULL +); + +CREATE TABLE lifeos_meta ( + user_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('profile', 'settings', 'system')), + value_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, kind) +); + +CREATE TABLE lifeos_memories ( + user_id TEXT NOT NULL, + id TEXT NOT NULL, + memory_date TEXT, + value_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + deleted_at TEXT, + PRIMARY KEY (user_id, id) +); +CREATE INDEX idx_lifeos_memories_user_date ON lifeos_memories(user_id, memory_date DESC) WHERE deleted_at IS NULL; + +CREATE TABLE lifeos_memory_tags ( + user_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + tag TEXT NOT NULL, + PRIMARY KEY (user_id, memory_id, tag), + FOREIGN KEY (user_id, memory_id) REFERENCES lifeos_memories(user_id, id) +); +CREATE INDEX idx_lifeos_memory_tags_user_tag ON lifeos_memory_tags(user_id, tag); +CREATE TRIGGER lifeos_memory_tags_insert AFTER INSERT ON lifeos_memories +WHEN NEW.deleted_at IS NULL +BEGIN + INSERT OR IGNORE INTO lifeos_memory_tags(user_id, memory_id, tag) + SELECT NEW.user_id, NEW.id, value FROM json_each(NEW.value_json, '$.tags') + WHERE type = 'text' AND value <> ''; +END; +CREATE TRIGGER lifeos_memory_tags_update AFTER UPDATE OF value_json, deleted_at ON lifeos_memories +BEGIN + DELETE FROM lifeos_memory_tags WHERE user_id = NEW.user_id AND memory_id = NEW.id; + INSERT OR IGNORE INTO lifeos_memory_tags(user_id, memory_id, tag) + SELECT NEW.user_id, NEW.id, value FROM json_each(NEW.value_json, '$.tags') + WHERE NEW.deleted_at IS NULL AND type = 'text' AND value <> ''; +END; + +CREATE TABLE lifeos_goals ( + user_id TEXT NOT NULL, + id TEXT NOT NULL, + target_date TEXT, + done INTEGER NOT NULL DEFAULT 0, + value_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + deleted_at TEXT, + PRIMARY KEY (user_id, id) +); +CREATE INDEX idx_lifeos_goals_user_status_date ON lifeos_goals(user_id, done, target_date) WHERE deleted_at IS NULL; + +CREATE TABLE lifeos_tasks ( + user_id TEXT NOT NULL, + id TEXT NOT NULL, + goal_id TEXT, + due_date TEXT, + done INTEGER NOT NULL DEFAULT 0, + value_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + deleted_at TEXT, + PRIMARY KEY (user_id, id) +); +CREATE INDEX idx_lifeos_tasks_user_status_date ON lifeos_tasks(user_id, done, due_date) WHERE deleted_at IS NULL; +CREATE INDEX idx_lifeos_tasks_user_goal ON lifeos_tasks(user_id, goal_id) WHERE deleted_at IS NULL; + +CREATE TABLE lifeos_favorites ( + user_id TEXT NOT NULL, + id TEXT NOT NULL, + memory_id TEXT, + value_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL, + deleted_at TEXT, + PRIMARY KEY (user_id, id) +); +CREATE INDEX idx_lifeos_favorites_user_memory ON lifeos_favorites(user_id, memory_id) WHERE deleted_at IS NULL; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2dca654..3d683c4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1790389200000, "tag": "0000_lifeos_cloud_state", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1790640000000, + "tag": "0001_lifeos_records", + "breakpoints": true } ] } diff --git a/package.json b/package.json index c64106c..43b3281 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "life-os", - "version": "1.12.0", + "version": "1.13.0", "private": true, "type": "module", "scripts": { "build": "node scripts/build.mjs", - "validate": "node scripts/validate.mjs" + "validate": "node scripts/validate.mjs", + "test:cloud": "node scripts/test-cloud.mjs" } } diff --git a/public/addons.js b/public/addons.js index a292b78..bb762bc 100644 --- a/public/addons.js +++ b/public/addons.js @@ -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 = `
${t('addon.syncReady')}

${t('addon.migrateQuestion')}

${t('addon.migrateBody')}

`; 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); diff --git a/public/i18n-renderers.js b/public/i18n-renderers.js index 7aa6d31..a1e721b 100644 --- a/public/i18n-renderers.js +++ b/public/i18n-renderers.js @@ -110,7 +110,7 @@ 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(), preference = LifeI18n.preference(), 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 = `
${t('system.eyebrow')}

${t('system.title')}

${t('system.appearance')}
${['dark','light','auto'].map(item => ``).join('')}
${t('system.language')}

${detected}

${t('system.data')}
${t('system.storage')}

${t('system.local')}
${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}

${t('system.dataStorage')}

${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')}` : ''}

${t('system.version')}

${t('system.release',{version:window.LifeOSVersion||'1.12.0'})}

`; + container.innerHTML = `
${t('system.eyebrow')}

${t('system.title')}

${t('system.appearance')}
${['dark','light','auto'].map(item => ``).join('')}
${t('system.language')}

${detected}

${t('system.data')}
${t('system.storage')}

${t('system.local')}
${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}

${t('system.dataStorage')}

${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')}` : ''}

${t('system.version')}

${t('system.release',{version:window.LifeOSVersion||'1.13.0'})}

`; }; window.importPreview = () => { const data = importData, board = data.system?.tasksBoard || {}; rootModal().innerHTML = ``; }; window.replaceConfirm = () => { rootModal().innerHTML = ``; }; diff --git a/public/index.html b/public/index.html index c6c2b7e..e83b1d3 100644 --- a/public/index.html +++ b/public/index.html @@ -14,7 +14,7 @@ function browserLanguage(){let ls=navigator.languages?.length?navigator.language function appName(id){return i18n(APPS[id][1])}function moodName(m){return i18n(m.toUpperCase(),cap(m))}function day(){const d=new Date();return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`}function fmtDate(d){return new Date(d+'T12:00:00').toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'})}function shortDate(d){return new Date(d+'T12:00:00').toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',lang()==='zh'?{month:'numeric',day:'numeric'}:{month:'short',day:'numeric'}).toUpperCase()}function cap(x){return x[0].toUpperCase()+x.slice(1)}function esc(s){return (s||'').replace(/[&<>]/g,x=>({'&':'&','<':'<','>':'>'}[x]))} function applyWallpaper(w){if(!w?.url)return;desktop.style.backgroundImage=`linear-gradient(120deg,color-mix(in srgb,var(--bg) 68%,transparent),color-mix(in srgb,var(--bg) 36%,transparent)),url('${w.url}')`;desktop.style.backgroundSize='cover';desktop.style.backgroundPosition='center';desktop.title=LifeI18n.t('shell.wallpaperTitle',{title:w.title||LifeI18n.t('wallpaper.dailyImage')})}async function loadBingWallpaper(){let s=settings();if(s.wallpaper?.date===day()&&s.wallpaper?.source==='lifeos-proxy'){applyWallpaper(s.wallpaper);return}try{let r=await fetch('/api/bing-wallpaper'),d=await r.json();if(!r.ok||!d.url)throw Error(d.error||'No Bing image');s=settings();if((s.wallpaperSource||'bing')!=='bing')return;s.wallpaper={date:day(),url:'/api/bing-wallpaper-image?date='+day(),title:d.title||'Bing daily image',source:'lifeos-proxy'};save('lifeos_settings',s);applyWallpaper(s.wallpaper)}catch(e){console.info('Bing wallpaper unavailable; using the calm fallback.',e)}}function init(){let s=settings();document.documentElement.dataset.theme=s.theme==='auto'?(matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'):s.theme;renderIcons();renderTaskbar();loadBingWallpaper();setInterval(clock,1000);clock();setTimeout(()=>window.applyLanguage?.(),0);setTimeout(()=>boot.remove(),650)} function desktopIconClick(id,e){if(innerWidth<650){openApp(id);return}selectedIcon=id;document.querySelectorAll('.app-icon').forEach(x=>x.classList.remove('selected'));e.currentTarget.classList.add('selected')}function desktopIconKey(id,e){if(e.key==='Enter'||e.key===' '){e.preventDefault();openApp(id)}}function renderIcons(){icons.innerHTML=Object.entries(APPS).map(([id,[g,n]])=>``).join('')}function taskClick(id){let w=windows[id];if(!w){openApp(id);return}if(w.classList.contains('min')){openApp(id);return}if(activeWindow===id){minWin(id);return}focus(w)}function renderTaskbar(){let ids=innerWidth<650?Object.keys(windows):['today','timeline','museum','insights','tasks','calendar','browser','terminal'];tasks.innerHTML=ids.map(id=>{let w=windows[id],min=w?.classList.contains('min'),fg=activeWindow===id&&!min,name=window.appName(id);return``}).join('')} -function clock(){let d=new Date();document.querySelector('#clock').textContent=d.toLocaleTimeString(LifeI18n.locale(), {hour:'2-digit',minute:'2-digit'});document.querySelector('#date').textContent=d.toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',lang()==='zh'?{month:'numeric',day:'numeric'}:{month:'short',day:'numeric'}).toUpperCase()}function toggleLauncher(){launcher.classList.toggle('hidden');calendar.classList.add('hidden');launcher.innerHTML=`
LIFE OS${LifeI18n.t(`shell.${new Date().getHours()<12?'morning':new Date().getHours()<18?'afternoon':'evening'}`)}
${Object.entries(APPS).map(([id,a])=>``).join('')}
${LifeI18n.t('shell.systemOnline',{version:window.LifeOSVersion||'1.12.0'})}
`}function toggleCalendar(){calendar.classList.toggle('hidden');launcher.classList.add('hidden');calendar.innerHTML=`
${LifeI18n.t('shell.systemDate')}

${fmtDate(day())}

${LifeI18n.t('shell.todaySummary',{memories:mems().length,preserved:museum().length})}

`}function hideContext(){document.querySelector('#life-context')?.remove()}function showContext(e,id){e.preventDefault();e.stopPropagation();hideContext();let labels={open:LifeI18n.t('shell.open'),min:LifeI18n.t('shell.minimize'),close:LifeI18n.t('shell.menuCloseWindow'),refresh:LifeI18n.t('shell.refresh'),new:LifeI18n.t('shell.newToday'),system:LifeI18n.t('shell.systemSettings')},actions=id?`${windows[id]?``:''}`:`
`;let m=document.createElement('div');m.id='life-context';m.style.cssText=`position:fixed;z-index:7000;left:${Math.min(e.clientX,innerWidth-190)}px;top:${Math.min(e.clientY,innerHeight-150)}px;width:180px;padding:6px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow)`;m.innerHTML=actions;m.querySelectorAll('button').forEach(b=>b.style.cssText='display:block;width:100%;border:0;background:transparent;color:var(--ink);text-align:left;border-radius:6px;padding:9px 10px;font:12px Manrope');m.querySelectorAll('button').forEach(b=>b.onmouseenter=()=>b.style.background='var(--panel2)');document.body.append(m)}document.addEventListener('contextmenu',e=>{if(e.target.closest('.desktop')&&!e.target.closest('.app-icon')&&!e.target.closest('.window'))showContext(e,null)});document.addEventListener('click',hideContext) +function clock(){let d=new Date();document.querySelector('#clock').textContent=d.toLocaleTimeString(LifeI18n.locale(), {hour:'2-digit',minute:'2-digit'});document.querySelector('#date').textContent=d.toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',lang()==='zh'?{month:'numeric',day:'numeric'}:{month:'short',day:'numeric'}).toUpperCase()}function toggleLauncher(){launcher.classList.toggle('hidden');calendar.classList.add('hidden');launcher.innerHTML=`
LIFE OS${LifeI18n.t(`shell.${new Date().getHours()<12?'morning':new Date().getHours()<18?'afternoon':'evening'}`)}
${Object.entries(APPS).map(([id,a])=>``).join('')}
${LifeI18n.t('shell.systemOnline',{version:window.LifeOSVersion||'1.13.0'})}
`}function toggleCalendar(){calendar.classList.toggle('hidden');launcher.classList.add('hidden');calendar.innerHTML=`
${LifeI18n.t('shell.systemDate')}

${fmtDate(day())}

${LifeI18n.t('shell.todaySummary',{memories:mems().length,preserved:museum().length})}

`}function hideContext(){document.querySelector('#life-context')?.remove()}function showContext(e,id){e.preventDefault();e.stopPropagation();hideContext();let labels={open:LifeI18n.t('shell.open'),min:LifeI18n.t('shell.minimize'),close:LifeI18n.t('shell.menuCloseWindow'),refresh:LifeI18n.t('shell.refresh'),new:LifeI18n.t('shell.newToday'),system:LifeI18n.t('shell.systemSettings')},actions=id?`${windows[id]?``:''}`:`
`;let m=document.createElement('div');m.id='life-context';m.style.cssText=`position:fixed;z-index:7000;left:${Math.min(e.clientX,innerWidth-190)}px;top:${Math.min(e.clientY,innerHeight-150)}px;width:180px;padding:6px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow)`;m.innerHTML=actions;m.querySelectorAll('button').forEach(b=>b.style.cssText='display:block;width:100%;border:0;background:transparent;color:var(--ink);text-align:left;border-radius:6px;padding:9px 10px;font:12px Manrope');m.querySelectorAll('button').forEach(b=>b.onmouseenter=()=>b.style.background='var(--panel2)');document.body.append(m)}document.addEventListener('contextmenu',e=>{if(e.target.closest('.desktop')&&!e.target.closest('.app-icon')&&!e.target.closest('.window'))showContext(e,null)});document.addEventListener('click',hideContext) function openApp(id){launcher.classList.add('hidden');let w=windows[id];if(w){w.classList.remove('min');focus(w);return}w=document.createElement('section');w.className='window';w.dataset.id=id;w.style.left=(innerWidth<650?0:90+Object.keys(windows).length*36)+'px';w.style.top=(innerWidth<650?0:72+Object.keys(windows).length*28)+'px';w.innerHTML=`
${APPS[id][0]}${appName(id)}
`;windows[id]=w;document.querySelector('#windows').append(w);let controls=w.querySelector('.controls'),bar=w.querySelector('.titlebar');controls.addEventListener('pointerdown',e=>{e.preventDefault();e.stopPropagation()});controls.addEventListener('click',e=>{let action=e.target.closest('button')?.dataset.windowAction;if(!action)return;e.preventDefault();e.stopPropagation();if(action==='min')minWin(id);if(action==='max')maxWin(id);if(action==='close')closeWin(id)});bar.addEventListener('dblclick',e=>{if(!e.target.closest('.controls'))maxWin(id)});w.addEventListener('pointerdown',()=>focus(w));drag(w);renderApp(id);focus(w)}function focus(w){if(!w)return;activeWindow=w.dataset.id;w.style.zIndex=++z;document.querySelectorAll('.window').forEach(x=>x.classList.toggle('active-window',x===w));renderTaskbar()}function closeWin(id){windows[id]?.remove();delete windows[id];if(activeWindow===id){activeWindow=Object.keys(windows).find(k=>!windows[k].classList.contains('min'))||null;if(activeWindow)focus(windows[activeWindow])}renderTaskbar()}function minWin(id){let w=windows[id];if(!w)return;w.classList.add('min');if(activeWindow===id){activeWindow=Object.keys(windows).find(k=>k!==id&&!windows[k].classList.contains('min'))||null;if(activeWindow)focus(windows[activeWindow])}renderTaskbar();toast(i18n('MINIMIZED — TAP THE APP ICON TO RESTORE'))}function maxWin(id){let w=windows[id];if(!w)return;w.classList.toggle('max');focus(w)}function snapWindow(w){if(innerWidth<650||w.classList.contains('max'))return;let r=w.getBoundingClientRect(),edge=22;if(r.topinnerWidth-edge){w.style.left='calc(50vw + 9px)';w.style.top='18px';w.style.width='calc(50vw - 27px)';w.style.height='calc(100vh - 98px)'}}function drag(w){let bar=w.querySelector('.titlebar'),x,y,l,t;bar.onpointerdown=e=>{if(e.target.closest('.controls')||innerWidth<650)return;if(w.classList.contains('max'))w.classList.remove('max');x=e.clientX;y=e.clientY;l=w.offsetLeft;t=w.offsetTop;bar.setPointerCapture(e.pointerId);bar.onpointermove=e=>{w.style.left=Math.max(0,l+e.clientX-x)+'px';w.style.top=Math.max(0,t+e.clientY-y)+'px'};bar.onpointerup=()=>{bar.onpointermove=null;snapWindow(w)}}}document.addEventListener('keydown',e=>{if(e.altKey&&e.key==='F4'&&activeWindow){e.preventDefault();closeWin(activeWindow)}if(e.key==='Escape'&&activeWindow&&!document.querySelector('.modal')&&!document.querySelector('#lifeos-search')){let w=windows[activeWindow];if(w?.classList.contains('max')){e.preventDefault();maxWin(activeWindow)}}}) function renderApp(id){let c=document.querySelector('#content-'+id);if(!c)return;if(id==='today')window.today(c);if(id==='timeline')window.timeline(c);if(id==='museum')window.museumApp(c);if(id==='insights')window.insights(c);if(id==='profile')window.profileApp(c);if(id==='tasks')window.tasksApp(c);if(id==='calendar')window.calendarApp(c);if(id==='system')window.systemApp(c);if(id==='browser')window.browserApp(c);if(id==='terminal')window.terminalApp(c);setTimeout(()=>window.applyLanguage?.(),0)}function renderAll(){Object.keys(windows).forEach(renderApp);renderTaskbar();window.renderDesktopGoalCountdowns?.();setTimeout(()=>window.applyLanguage?.(),0)}document.addEventListener('click',e=>{if(e.target.closest('#content-today .chip,#content-today .energy button'))setTimeout(()=>window.applyLanguage?.(),0)}) let todayDraft=null,todayTitleDraft=null,todayTargetDate=null;function today(c){let m=mems().find(x=>x.date===day());if(todayDraft===null){selectedMood=m?.mood||'cloudy';selectedEnergy=m?.energy||3}let f=m||filmFor(selectedMood,selectedEnergy),text=todayDraft===null?(m?.text||''):todayDraft;c.innerHTML=`
${new Date().toLocaleDateString('en-US',{weekday:'long'}).toUpperCase()}

${fmtDate(day()).replace(/^[^,]+, /,'')}

How was your day?

`}function filmFor(m,e){const dict=LifeI18n.dictionaries[lang()==='zh'?'zh':'en'],seed=[...`${day()}|${m}|${e}`].reduce((sum,char,index)=>sum+char.charCodeAt(0)*(index+1),0),n=seed%dict.prompt.films.length;return{filmTitle:dict.prompt.films[n].title,filmSubtitle:dict.prompt.films[n].subtitle,filmLine:dict.prompt.lines[(n*3+e)%dict.prompt.lines.length]}}function rerenderToday(){let old=document.querySelector('#entry');todayDraft=old?old.value:todayDraft||'';window.today(document.querySelector('#content-today'));let entry=document.querySelector('#entry');entry?.focus();entry?.setSelectionRange(entry.value.length,entry.value.length)}function setMood(x){selectedMood=x;rerenderToday()}function setEnergy(x){selectedEnergy=x;rerenderToday()}function archiveToday(){let text=document.querySelector('#entry').value.trim(),a=mems(),old=a.find(x=>x.date===day()),f=old||filmFor(selectedMood,selectedEnergy),now=new Date().toISOString(),m={id:old?.id||'memory_'+crypto.randomUUID(),date:day(),text,mood:selectedMood,energy:selectedEnergy,filmTitle:f.filmTitle,filmSubtitle:f.filmSubtitle,filmLine:f.filmLine,createdAt:old?.createdAt||now,updatedAt:now};let status=document.querySelector('#saveStatus');status.textContent='ARCHIVING MEMORY...';setTimeout(()=>{save('lifeos_memories',[...a.filter(x=>x.date!==day()),m].sort((x,y)=>y.date.localeCompare(x.date)));todayDraft=null;status.textContent='MEMORY ARCHIVED';renderAll()},360)} @@ -23,6 +23,6 @@ function museumApp(c){let a=museum(),ms=mems();c.innerHTML=`
m.date===d.toISOString().slice(0,10))){s++;d.setDate(d.getDate()-1)}return s}function longest(a){let ds=a.map(x=>x.date).sort(),best=0,run=0,last='';ds.forEach(d=>{run=last&&((new Date(d)-new Date(last))/864e5===1)?run+1:1;best=Math.max(best,run);last=d});return best}function insights(c){let a=mems(),last=a.filter(m=>(Date.now()-new Date(m.date+'T12:00:00'))<2592e6),cnt={clear:0,light:0,cloudy:0,rain:0,storm:0};last.forEach(x=>cnt[x.mood]++);let avg=a.length?(a.reduce((s,x)=>s+x.energy,0)/a.length).toFixed(1):'—',days={};a.forEach(x=>{let d=new Date(x.date+'T12:00:00').toLocaleDateString('en-US',{weekday:'long'});days[d]=(days[d]||0)+1});let active=Object.entries(days).sort((x,y)=>y[1]-x[1])[0]?.[0]||'—';c.innerHTML=`
LAST 30 DAYS

Insights, quietly.

WEATHER
${Object.entries(cnt).map(([k,v])=>`
${cap(k)} ${v} DAYS
`).join('')}
AVERAGE ENERGY
${avg} / 5
${a.slice(0,10).reverse().map(x=>``).join('')||'No entries yet'}
MOST ACTIVE DAY
${active}
CURRENT STREAK
${streak(a)} DAYS
Longest streak: ${longest(a)} days
`} function profileApp(c){let p=profile(),a=mems(),common=Object.entries(a.reduce((o,m)=>(o[m.mood]=(o[m.mood]||0)+1,o),{})).sort((x,y)=>y[1]-x[1])[0]?.[0]||'—',avg=a.length?(a.reduce((x,m)=>x+m.energy,0)/a.length).toFixed(1):'—',started=Math.max(1,Math.floor((Date.now()-new Date(p.startDate))/864e5));c.innerHTML=`
SYSTEM PROFILE

${p.name?esc(p.name)+' · ':''}LIFE OS ONLINE

${started}DAYS ONLINE
${a.length}MEMORIES
${museum().length}MUSEUM ITEMS
${streak(a)}CURRENT STREAK
${longest(a)}LONGEST STREAK
${cap(common)}COMMON WEATHER
PERSONAL SYSTEM
AVERAGE ENERGY ${avg} / 5
`}function saveProfile(){save('lifeos_profile',{name:pname.value,birthday:pbday.value,startDate:pstart.value||day(),motto:pmotto.value});renderAll();toast('PROFILE SAVED')} function today(c){let m=mems().find(x=>x.date===day());if(todayDraft===null){selectedMood=m?.mood||'cloudy';selectedEnergy=m?.energy||3}let f=m||filmFor(selectedMood,selectedEnergy),text=todayDraft===null?(m?.text||''):todayDraft,cWeek=new Date().toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',{weekday:'long'}).toUpperCase();c.innerHTML=`
${cWeek}

${fmtDate(day()).replace(/^[^,]+, /,'')}

${i18n('How was your day?')}

`}function rerenderToday(){let old=document.querySelector('#entry');todayDraft=old?old.value:todayDraft||'';window.today(document.querySelector('#content-today'));let entry=document.querySelector('#entry');entry?.focus();entry?.setSelectionRange(entry.value.length,entry.value.length)} -function systemApp(c){let s=settings(),bytes=new Blob([JSON.stringify({mems:mems(),museum:museum(),profile:profile()})]).size;let rec=s.lastBackup&&(Date.now()-new Date(s.lastBackup)>12096e5);c.innerHTML=`
SYSTEM SETTINGS

System, local and yours.

APPEARANCE
${['dark','light','auto'].map(x=>``).join('')}
LANGUAGE
DATA
STORAGE

Local Storage
${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB

DATA STORAGE

Your data is stored locally in this browser.
Create regular backups to prevent data loss.

${s.lastBackup?'LAST BACKUP · '+fmtDate(s.lastBackup.slice(0,10)):'LAST BACKUP · NEVER'} ${rec?'
BACKUP RECOMMENDED':''}

VERSION

Life OS
Version ${window.LifeOSVersion||'1.12.0'} · Schema v2

`}function theme(x){let s=settings();s.theme=x;save('lifeos_settings',s);init();renderAll()}function exportData(){let data={app:'Life OS',version:window.LifeOSVersion||'1.12.0',schemaVersion:1,exportedAt:new Date().toISOString(),profile:profile(),memories:mems(),museum:museum(),settings:settings()};let a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));a.download='life-os-backup-'+day()+'.json';a.click();let s=settings();s.lastBackup=new Date().toISOString();save('lifeos_settings',s);renderAll();toast('BACKUP CREATED')}function readImport(el){let f=el.files[0];if(!f)return;let r=new FileReader;r.onload=()=>{try{let d=JSON.parse(r.result);if(d.app!=='Life OS'||![1,2].includes(d.schemaVersion)||!Array.isArray(d.memories)||!Array.isArray(d.museum))throw 0;importData=d;importPreview()}catch{toast('INVALID BACKUP FILE')}};r.readAsText(f)}function importPreview(){let d=importData;modal.innerHTML=``}function replaceConfirm(){modal.innerHTML=``}function applyImport(mode){let d=importData;if(mode==='replace'){save('lifeos_memories',d.memories);save('lifeos_museum',d.museum);save('lifeos_profile',d.profile||{});save('lifeos_settings',d.settings||{})}else{let a=mems(), ids=new Set(a.map(x=>x.id)),dates=new Set(a.map(x=>x.date));save('lifeos_memories',[...a,...d.memories.filter(x=>!ids.has(x.id)&&!dates.has(x.date))]);let mu=museum(),mi=new Set(mu.map(x=>x.id));save('lifeos_museum',[...mu,...d.museum.filter(x=>!mi.has(x.id))])}modal.innerHTML='';toast('RESTORING SYSTEM...');setTimeout(()=>{renderAll();toast('SYSTEM RESTORED')},500)}function resetConfirm(){modal.innerHTML=``}function toast(t){let x=document.createElement('div');x.className='toast';x.textContent=t;document.body.append(x);setTimeout(()=>x.remove(),2000)} +function systemApp(c){let s=settings(),bytes=new Blob([JSON.stringify({mems:mems(),museum:museum(),profile:profile()})]).size;let rec=s.lastBackup&&(Date.now()-new Date(s.lastBackup)>12096e5);c.innerHTML=`
SYSTEM SETTINGS

System, local and yours.

APPEARANCE
${['dark','light','auto'].map(x=>``).join('')}
LANGUAGE
DATA
STORAGE

Local Storage
${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB

DATA STORAGE

Your data is stored locally in this browser.
Create regular backups to prevent data loss.

${s.lastBackup?'LAST BACKUP · '+fmtDate(s.lastBackup.slice(0,10)):'LAST BACKUP · NEVER'} ${rec?'
BACKUP RECOMMENDED':''}

VERSION

Life OS
Version ${window.LifeOSVersion||'1.13.0'} · Schema v2

`}function theme(x){let s=settings();s.theme=x;save('lifeos_settings',s);init();renderAll()}function exportData(){let data={app:'Life OS',version:window.LifeOSVersion||'1.13.0',schemaVersion:1,exportedAt:new Date().toISOString(),profile:profile(),memories:mems(),museum:museum(),settings:settings()};let a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));a.download='life-os-backup-'+day()+'.json';a.click();let s=settings();s.lastBackup=new Date().toISOString();save('lifeos_settings',s);renderAll();toast('BACKUP CREATED')}function readImport(el){let f=el.files[0];if(!f)return;let r=new FileReader;r.onload=()=>{try{let d=JSON.parse(r.result);if(d.app!=='Life OS'||![1,2].includes(d.schemaVersion)||!Array.isArray(d.memories)||!Array.isArray(d.museum))throw 0;importData=d;importPreview()}catch{toast('INVALID BACKUP FILE')}};r.readAsText(f)}function importPreview(){let d=importData;modal.innerHTML=``}function replaceConfirm(){modal.innerHTML=``}function applyImport(mode){let d=importData;if(mode==='replace'){save('lifeos_memories',d.memories);save('lifeos_museum',d.museum);save('lifeos_profile',d.profile||{});save('lifeos_settings',d.settings||{})}else{let a=mems(), ids=new Set(a.map(x=>x.id)),dates=new Set(a.map(x=>x.date));save('lifeos_memories',[...a,...d.memories.filter(x=>!ids.has(x.id)&&!dates.has(x.date))]);let mu=museum(),mi=new Set(mu.map(x=>x.id));save('lifeos_museum',[...mu,...d.museum.filter(x=>!mi.has(x.id))])}modal.innerHTML='';toast('RESTORING SYSTEM...');setTimeout(()=>{renderAll();toast('SYSTEM RESTORED')},500)}function resetConfirm(){modal.innerHTML=``}function toast(t){let x=document.createElement('div');x.className='toast';x.textContent=t;document.body.append(x);setTimeout(()=>x.remove(),2000)} window.renderIcons=renderIcons;window.renderAll=renderAll;window.toast=toast; - + diff --git a/public/locales/en.json b/public/locales/en.json index ff5b364..7086ed5 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -206,6 +206,8 @@ "untitled": "Untitled memory", "noResults": "No memories found.", "syncPaused": "CLOUD SYNC PAUSED — CHANGES STAY ON THIS DEVICE", + "syncConflict": "SYNC CONFLICT — A LOCAL BACKUP WAS SAVED", + "syncConflictConfirm": "This record changed on another device. A local backup has been saved. Replace the cloud record with your local version? Cancel keeps the cloud version.", "syncReady": "CLOUD SYNC READY", "migrateQuestion": "Move this browser’s Life OS to the cloud?", "migrateBody": "Your existing local records will become the first synced copy for this ChatGPT account.", diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 0c3bdab..9d0bc46 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -206,6 +206,8 @@ "untitled": "未命名记忆", "noResults": "没有找到记忆。", "syncPaused": "云端同步暂停——更改仍保留在此设备", + "syncConflict": "同步冲突——本地修改已备份", + "syncConflictConfirm": "这条记录已在另一台设备更改。本地修改已备份。要用本地版本覆盖云端记录吗?取消则保留云端版本。", "syncReady": "云端同步已就绪", "migrateQuestion": "将此浏览器的 Life OS 迁移到云端?", "migrateBody": "现有本地记录将成为这个 ChatGPT 账号的第一份云端同步数据。", diff --git a/public/service-worker.js b/public/service-worker.js index 26b923e..ec67afc 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE = 'life-os-shell-v1.12.0'; +const CACHE = 'life-os-shell-v1.13.0'; const SHELL = [ '/', '/index.html', '/manifest.webmanifest', '/life-os-icon.svg' ]; diff --git a/public/terminal-app.js b/public/terminal-app.js index db2b6aa..f82c249 100644 --- a/public/terminal-app.js +++ b/public/terminal-app.js @@ -150,7 +150,7 @@ return { message: new Intl.DateTimeFormat(lang() === 'zh' ? 'zh-CN' : 'en-US', options).format(new Date()) }; } if (kind === 'uptime') { if (verb) throw new Error('usage'); const seconds = Math.floor((Date.now() - startedAt) / 1000); return { message: t('uptime', { minutes: Math.floor(seconds / 60), seconds: seconds % 60 }) }; } - if (kind === 'version') { if (verb) throw new Error('usage'); return { message: `Life OS v${window.LifeOSVersion || '1.12.0'}` }; } + if (kind === 'version') { if (verb) throw new Error('usage'); return { message: `Life OS v${window.LifeOSVersion || '1.13.0'}` }; } if (kind === 'status') { if (verb) throw new Error('usage'); const data = board(); return { message: t('status', { memories: mems().length, goals: data.goals.length, tasks: data.tasks.length }) }; } if (kind === 'apps') { if (verb) throw new Error('usage'); return { message: Object.keys(APPS).map(id => `${id} — ${LifeI18n.t(`app.${id}`)}`).join('\n') }; } if (kind === 'history') { if (verb) throw new Error('usage'); return { message: history.slice(-20).map((entry,index) => `${index + 1}. ${entry}`).join('\n') || t('empty') }; } @@ -235,7 +235,7 @@ }; window.terminalApp = node => { if (node.querySelector('.terminal-shell') && node.dataset.terminalLocale === lang()) { output(node); return; } - if (!welcomed) { lines.unshift({ value: t('welcome', { version: window.LifeOSVersion || '1.12.0' }), kind: 'system' }); welcomed = true; } + if (!welcomed) { lines.unshift({ value: t('welcome', { version: window.LifeOSVersion || '1.13.0' }), kind: 'system' }); welcomed = true; } node.dataset.terminalLocale = lang(); node.innerHTML = `
Life OS / ${t('title')}${t('hint')}
`; output(node); composer(node, Boolean(compose)); diff --git a/scripts/build.mjs b/scripts/build.mjs index 8833789..248c9a7 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -8,7 +8,7 @@ const meta = new URL("../dist/.openai/", import.meta.url); await rm(dist, { recursive: true, force: true }); await Promise.all([mkdir(server, { recursive: true }), mkdir(meta, { recursive: true })]); const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, desktopPersonalizationJs, desktopWidgetsJs, browserAppJs, dataActionsJs, terminalAppJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("desktop-widgets.js"), text("browser-app.js"), text("data-actions.js"), text("terminal-app.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]); -const release = "1.12.0"; +const release = "1.13.0"; const localeEnJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales.en=${localeEn};`; const localeZhJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales['zh-CN']=${localeZh};`; let indexHtml = rawIndexHtml.replace('src="addons.js"', `src="addons.js?v=${release}"`).replace('src="tasks.js"', `src="tasks.js?v=${release}"`).replace('src="calendar.js"', `src="calendar.js?v=${release}"`); @@ -23,5 +23,6 @@ for (const [src, code] of [ } await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const browserAppJs=${JSON.stringify(browserAppJs)};\nexport const dataActionsJs=${JSON.stringify(dataActionsJs)};\nexport const terminalAppJs=${JSON.stringify(terminalAppJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`); await copyFile(new URL("../worker/index.js", import.meta.url), new URL("index.js", server)); +await copyFile(new URL("../worker/cloud-state.js", import.meta.url), new URL("cloud-state.js", server)); await copyFile(new URL("../.openai/hosting.json", import.meta.url), new URL("hosting.json", meta)); console.log("Built Life OS Worker artifact"); diff --git a/scripts/test-cloud.mjs b/scripts/test-cloud.mjs new file mode 100644 index 0000000..a96a6b4 --- /dev/null +++ b/scripts/test-cloud.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import vm from "node:vm"; +import worker from "../dist/server/index.js"; + +const sqlite = new DatabaseSync(":memory:"); +sqlite.exec("PRAGMA foreign_keys = ON"); +sqlite.exec(readFileSync(new URL("../drizzle/0000_lifeos_cloud_state.sql", import.meta.url), "utf8")); +sqlite.exec(readFileSync(new URL("../drizzle/0001_lifeos_records.sql", import.meta.url), "utf8")); +const db = { + prepare(sql) { + return { + bind(...args) { + const statement = sqlite.prepare(sql); + return { + first: async () => statement.get(...args) ?? null, + all: async () => ({ results: statement.all(...args) }), + run: async () => ({ meta: { changes: statement.run(...args).changes } }) + }; + } + }; + }, + async batch(statements) { + sqlite.exec("BEGIN"); + try { + const result = []; + for (const statement of statements) result.push(await statement.run()); + sqlite.exec("COMMIT"); + return result; + } catch (error) { sqlite.exec("ROLLBACK"); throw error; } + } +}; +const headers = { "oai-authenticated-user-id": "alice" }; +async function call(path, method = "GET", data, as = headers) { + const response = await worker.fetch(new Request("https://life-os.test" + path, { + method, headers: { ...as, "content-type": "application/json" }, + body: data === undefined ? undefined : JSON.stringify(data) + }), { DB: db }); + return { status: response.status, body: await response.json() }; +} + +const legacy = { + profile: { name: "Alice" }, settings: { theme: "dark" }, + memories: [{ id: "m1", date: "2026-09-28", title: "Lab", tags: ["school", "lab"], text: "Notes" }], + museum: [{ id: "f1", memoryId: "m1", preservedAt: "2026-09-28" }], + system: { schemaVersion: 2, tasksBoard: { + goals: [{ id: "g1", title: "Study", targetDate: "2026-12-12" }], + tasks: [{ id: "t1", goalId: "g1", title: "Review", done: false }] + } } +}; +sqlite.prepare("INSERT INTO lifeos_states VALUES (?, ?, ?, ?, ?, ?, ?)").run( + "alice", JSON.stringify(legacy.profile), JSON.stringify(legacy.memories), + JSON.stringify(legacy.museum), JSON.stringify(legacy.settings), JSON.stringify(legacy.system), "2026-09-28" +); +let result = await call("/api/lifeos-state"); +assert.equal(result.status, 200); +assert.deepEqual(result.body.state.memories, legacy.memories); +assert.deepEqual(result.body.state.system.tasksBoard, legacy.system.tasksBoard); +assert.deepEqual(result.body.state.museum, legacy.museum); +assert.equal(result.body.versions.memories.m1, 1); +assert.deepEqual(sqlite.prepare("SELECT tag FROM lifeos_memory_tags ORDER BY tag").all().map(row => row.tag), ["lab", "school"]); +assert.equal(sqlite.prepare("SELECT COUNT(*) AS count FROM lifeos_states").get().count, 1, "legacy backup remains intact"); + +result = await call("/api/lifeos-sync", "POST", { kind: "memories", id: "m1", baseVersion: 1, value: { ...legacy.memories[0], tags: ["new"] } }); +assert.equal(result.body.version, 2); +assert.deepEqual(sqlite.prepare("SELECT tag FROM lifeos_memory_tags").all().map(row => row.tag), ["new"]); +result = await call("/api/lifeos-sync", "POST", { kind: "memories", id: "m1", baseVersion: 1, value: legacy.memories[0] }); +assert.equal(result.status, 409, "stale client cannot overwrite a record"); +result = await call("/api/lifeos-sync", "POST", { kind: "tasks", id: "t2", baseVersion: 0, value: { id: "t2", title: "New task", done: false } }); +assert.equal(result.status, 200); +result = await call("/api/lifeos-sync", "POST", { kind: "memories", id: "m1", baseVersion: 2, value: null }); +assert.equal(result.body.version, 3); +assert.equal(sqlite.prepare("SELECT COUNT(*) AS count FROM lifeos_memory_tags").get().count, 0); +assert.equal((await call("/api/lifeos-state")).body.state.memories.length, 0); +assert.equal((await call("/api/lifeos-state", "GET", undefined, { "oai-authenticated-user-id": "bob" })).body.state, null); +assert.equal((await call("/api/lifeos-state", "PUT", { state: legacy })).status, 426, "old whole-document writes are rejected"); +result = await call("/api/lifeos-initialize", "POST", { state: legacy }, { "oai-authenticated-user-id": "bob" }); +assert.equal(result.status, 200); +assert.equal(result.body.state.system.tasksBoard.tasks.length, 1); +assert.equal((await call("/api/lifeos-initialize", "POST", { state: legacy }, { "oai-authenticated-user-id": "bob" })).status, 409); +const addons = readFileSync(new URL("../public/addons.js", import.meta.url), "utf8"); +const syncCode = addons.slice(addons.indexOf(" const CLOUD_KEYS ="), addons.indexOf(" const originalSetItem =")); +assert.ok(syncCode.includes("function changes(")); +const values = new Map(); +const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, String(value)) }; +const client = vm.createContext({ + localStorage: storage, window: {}, fetch: async (path, options = {}) => worker.fetch( + new Request("https://life-os.test" + path, { ...options, headers: { ...options.headers, ...headers } }), { DB: db }), + clearTimeout, setTimeout, confirm: () => false, t: key => key, + toast: () => {}, document: { createElement() {}, body: { append() {} } } +}); +vm.runInContext(syncCode + "\nwindow.CloudTest = { ready(state, revisions) { baseline = clone(state); versions = revisions; cloudReady = true; }, changes, snapshot: cloudSnapshot, sync: uploadCloudState };", client); +const snapshot = (await call("/api/lifeos-state")).body; +for (const [kind, key] of Object.entries({ profile: "lifeos_profile", memories: "lifeos_memories", museum: "lifeos_museum", settings: "lifeos_settings", system: "lifeos_system" })) + storage.setItem(key, JSON.stringify(snapshot.state[kind])); +client.window.CloudTest.ready(snapshot.state, snapshot.versions); +const system = JSON.parse(storage.getItem("lifeos_system")); +system.tasksBoard.tasks[0].done = true; +storage.setItem("lifeos_system", JSON.stringify(system)); +const pending = client.window.CloudTest.changes(snapshot.state, client.window.CloudTest.snapshot()); +assert.deepEqual(Array.from(pending, item => item.kind), ["tasks"], "editing a task only writes one record"); +await client.window.CloudTest.sync(); +assert.equal((await call("/api/lifeos-state")).body.state.system.tasksBoard.tasks[0].done, true); +console.log("Life OS record migration, sync conflict, tags, and user isolation valid"); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 281f167..bf46445 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -13,7 +13,8 @@ assert.match(assets.addonsJs, /schemaVersion: 2/); assert.match(assets.addonsJs, /settings: settings\(\), system: sys\(\)/); assert.match(assets.addonsJs, /widgetLayout/); assert.match(assets.i18nRenderersJs, /lifeos_widget_layout_v1/); -assert.match(assets.addonsJs, /if \(mergedLocalBoard\) await uploadCloudState\(\)/); +assert.match(assets.addonsJs, /lifeos_cloud_baseline_v3/); +assert.match(assets.addonsJs, /api\/lifeos-sync/); assert.match(assets.i18nRenderersJs, /dialog\.goals/); assert.match(assets.i18nRenderersJs, /shell\.wallpaperTitle/); assert.match(assets.i18nRenderersJs, /window\.museumApp/); @@ -172,7 +173,7 @@ assert.ok(command('date').message.length > 8); assert.ok(command('time').message.includes(':')); assert.ok(command('datetime').message.length > 12); assert.equal(command('uptime').message, 'terminal.uptime'); -assert.equal(command('version').message, 'Life OS v1.12.0'); +assert.equal(command('version').message, 'Life OS v1.13.0'); assert.equal(command('status').message, 'terminal.status'); assert.match(command('apps').message, /calendar/); assert.equal(command('echo "hello Life OS"').message, 'hello Life OS'); diff --git a/worker/cloud-state.js b/worker/cloud-state.js new file mode 100644 index 0000000..0d94005 --- /dev/null +++ b/worker/cloud-state.js @@ -0,0 +1,148 @@ +// Legacy lifeos_states is retained as a read-only source for per-user migration. +const TABLES = { memories: "lifeos_memories", goals: "lifeos_goals", tasks: "lifeos_tasks", museum: "lifeos_favorites" }; +const META = new Set(["profile", "settings", "system"]); +const empty = () => ({ profile: {}, memories: [], museum: [], settings: {}, system: { schemaVersion: 2, tasksBoard: { goals: [], tasks: [] } } }); +const json = value => JSON.stringify(value); +const object = value => value && typeof value === "object" && !Array.isArray(value); +const array = value => Array.isArray(value) ? value : []; +const parse = (value, fallback) => { try { return JSON.parse(value); } catch { return fallback; } }; +const date = value => typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null; +const label = value => typeof value === "string" && value.length <= 200 ? value : null; +const cleanSystem = value => { const { tasksBoard, ...system } = object(value) ? value : {}; return { ...system, schemaVersion: 2 }; }; +const response = (value, status = 200) => Response.json(value, { status, headers: { "cache-control": "no-store" } }); + +function entries(state) { + const system = object(state.system) ? state.system : {}; + return { + profile: object(state.profile) ? state.profile : {}, + settings: object(state.settings) ? state.settings : {}, + system: cleanSystem(system), + memories: array(state.memories), museum: array(state.museum), + goals: array(system.tasksBoard?.goals), tasks: array(system.tasksBoard?.tasks) + }; +} + +function insert(db, userId, kind, value, now) { + if (META.has(kind)) return db.prepare( + "INSERT INTO lifeos_meta (user_id, kind, value_json, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING" + ).bind(userId, kind, json(value), now); + const id = label(value?.id); + if (!id) return null; + if (kind === "memories") return db.prepare( + "INSERT INTO lifeos_memories (user_id, id, memory_date, value_json, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" + ).bind(userId, id, date(value.date), json(value), now); + if (kind === "goals") return db.prepare( + "INSERT INTO lifeos_goals (user_id, id, target_date, done, value_json, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" + ).bind(userId, id, date(value.targetDate), Number(!!value.done), json(value), now); + if (kind === "tasks") return db.prepare( + "INSERT INTO lifeos_tasks (user_id, id, goal_id, due_date, done, value_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" + ).bind(userId, id, label(value.goalId), date(value.dueDate), Number(!!value.done), json(value), now); + return db.prepare( + "INSERT INTO lifeos_favorites (user_id, id, memory_id, value_json, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING" + ).bind(userId, id, label(value.memoryId), json(value), now); +} + +async function seed(db, userId, state, now) { + const data = entries(state), statements = []; + for (const kind of ["profile", "settings", "system", "memories", "goals", "tasks", "museum"]) { + for (const [index, original] of (META.has(kind) ? [data[kind]] : data[kind]).entries()) { + const value = !META.has(kind) && !label(original?.id) ? { ...original, id: `legacy_${kind}_${index}` } : original; + const statement = insert(db, userId, kind, value, now); + if (statement) statements.push(statement); + } + } + // D1 batch executes a group atomically. Keep batches bounded for larger accounts. + for (let index = 0; index < statements.length; index += 50) await db.batch(statements.slice(index, index + 50)); + await db.prepare("INSERT INTO lifeos_migrations (user_id, migrated_at) VALUES (?, ?) ON CONFLICT DO NOTHING").bind(userId, now).run(); +} + +async function migrateLegacy(db, userId) { + const marker = await db.prepare("SELECT user_id FROM lifeos_migrations WHERE user_id = ?").bind(userId).first(); + if (marker) return true; + const row = await db.prepare("SELECT profile_json, memories_json, museum_json, settings_json, system_json FROM lifeos_states WHERE user_id = ?").bind(userId).first(); + if (!row) return false; + const state = Object.fromEntries(["profile", "memories", "museum", "settings", "system"].map(kind => + [kind, parse(row[`${kind}_json`], empty()[kind])])); + await seed(db, userId, state, new Date().toISOString()); + return true; +} + +async function read(db, userId) { + if (!await migrateLegacy(db, userId)) return { state: null, versions: null }; + const [meta, memories, goals, tasks, museum] = await Promise.all([ + db.prepare("SELECT kind, value_json, revision FROM lifeos_meta WHERE user_id = ?").bind(userId).all(), + ...Object.values(TABLES).slice(0, 3).map(table => db.prepare(`SELECT id, value_json, revision FROM ${table} WHERE user_id = ? AND deleted_at IS NULL`).bind(userId).all()), + db.prepare("SELECT id, value_json, revision FROM lifeos_favorites WHERE user_id = ? AND deleted_at IS NULL").bind(userId).all() + ]); + const state = empty(), versions = { profile: {}, settings: {}, system: {}, memories: {}, goals: {}, tasks: {}, museum: {} }; + for (const row of meta.results) { + state[row.kind] = parse(row.value_json, state[row.kind]); + versions[row.kind].value = row.revision; + } + for (const [kind, rows] of Object.entries({ memories, goals, tasks, museum })) { + state[kind] = rows.results.map(row => parse(row.value_json, {})); + for (const row of rows.results) versions[kind][row.id] = row.revision; + } + state.system = { ...cleanSystem(state.system), tasksBoard: { goals: state.goals, tasks: state.tasks } }; + delete state.goals; delete state.tasks; + return { state, versions }; +} + +async function change(db, userId, op) { + const { kind, id, value, baseVersion } = op || {}; + if (!(META.has(kind) || Object.hasOwn(TABLES, kind)) || !Number.isInteger(baseVersion) || baseVersion < 0) return response({ error: "Invalid change" }, 400); + const key = META.has(kind) ? kind : label(id); + if (!key || (value !== null && (!object(value) || (!META.has(kind) && value.id !== key)))) return response({ error: "Invalid record" }, 400); + if (value !== null && json(value).length > 1_000_000) return response({ error: "Record is too large" }, 413); + if (META.has(kind) && (value === null || !object(value))) return response({ error: "Invalid metadata" }, 400); + const now = new Date().toISOString(); + if (baseVersion === 0) { + if (value === null) return response({ error: "Invalid deletion" }, 400); + const result = await insert(db, userId, kind, kind === "system" ? cleanSystem(value) : value, now).run(); + if (!result.meta.changes) return response({ error: "Conflict" }, 409); + return response({ ok: true, version: 1 }); + } + let query, bindings; + if (META.has(kind)) { + query = "UPDATE lifeos_meta SET value_json = ?, revision = revision + 1, updated_at = ? WHERE user_id = ? AND kind = ? AND revision = ? RETURNING revision"; + bindings = [json(kind === "system" ? cleanSystem(value) : value), now, userId, kind, baseVersion]; + } else { + const table = TABLES[kind]; + const fields = kind === "memories" ? ["memory_date"] : kind === "goals" ? ["target_date", "done"] : kind === "tasks" ? ["goal_id", "due_date", "done"] : ["memory_id"]; + const columns = value === null ? [] : fields; + const extra = value === null ? [] : kind === "memories" ? [date(value.date)] : + kind === "goals" ? [date(value.targetDate), Number(!!value.done)] : + kind === "tasks" ? [label(value.goalId), date(value.dueDate), Number(!!value.done)] : [label(value.memoryId)]; + query = value === null + ? `UPDATE ${table} SET deleted_at = ?, updated_at = ?, revision = revision + 1 WHERE user_id = ? AND id = ? AND revision = ? AND deleted_at IS NULL RETURNING revision` + : `UPDATE ${table} SET ${columns.map(column => `${column} = ?`).join(", ")}, value_json = ?, updated_at = ?, revision = revision + 1 WHERE user_id = ? AND id = ? AND revision = ? AND deleted_at IS NULL RETURNING revision`; + bindings = value === null ? [now, now, userId, key, baseVersion] : [...extra, json(value), now, userId, key, baseVersion]; + } + const updated = await db.prepare(query).bind(...bindings).first(); + if (!updated) return response({ error: "Conflict" }, 409); + return response({ ok: true, version: updated.revision }); +} + +export async function cloudApi(request, env, userId, pathname) { + if (!userId) return response({ error: "Authentication required" }, 401); + if (!env.DB) return response({ error: "Cloud storage is unavailable" }, 503); + try { + if (pathname === "/api/lifeos-state" && request.method === "GET") return response(await read(env.DB, userId)); + if (pathname === "/api/lifeos-state" && request.method === "PUT") return response({ error: "Refresh Life OS to use record-based sync" }, 426); + if (pathname === "/api/lifeos-initialize" && request.method === "POST") { + if (await migrateLegacy(env.DB, userId)) return response({ error: "Cloud account already exists" }, 409); + const { state } = await request.json(); + if (!object(state) || !array(state.memories) || !array(state.museum) || !object(state.system)) return response({ error: "Invalid state" }, 400); + await seed(env.DB, userId, state, new Date().toISOString()); + return response(await read(env.DB, userId)); + } + if (pathname === "/api/lifeos-sync" && request.method === "POST") { + if (!await migrateLegacy(env.DB, userId)) return response({ error: "Initialize cloud account first" }, 409); + return await change(env.DB, userId, await request.json()); + } + return response({ error: "Method not allowed" }, 405); + } catch (error) { + console.error("Life OS cloud operation failed", error); + return response({ error: "Cloud storage request failed" }, 500); + } +} diff --git a/worker/index.js b/worker/index.js index ac6bb9a..a942ee7 100644 --- a/worker/index.js +++ b/worker/index.js @@ -1,7 +1,7 @@ import { addonsJs, bootGuardJs, i18nJs, localeEn, localeZh, localeEnJs, localeZhJs, tasksJs, calendarJs, i18nRenderersJs, browserAppJs, dataActionsJs, terminalAppJs, indexHtml, manifest, serviceWorker, icon } from "./assets.js"; +import { cloudApi } from "./cloud-state.js"; const BING_ARCHIVE = "https://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=en-US"; -const STATE_FIELDS = ["profile", "memories", "museum", "settings", "system"]; async function bingMetadata() { const response = await fetch(BING_ARCHIVE, { headers: { accept: "application/json" } }); @@ -21,38 +21,11 @@ function loginPage(request) { return `Life OS
LIFE OS

${text.title}

${escape(text.description)}

${escape(text.continue)}

${escape(text.footnote)}

`; } -function validState(value) { return value && typeof value === "object" && !Array.isArray(value) && STATE_FIELDS.every((field) => Object.hasOwn(value, field)); } -function stateFromRow(row) { return row ? Object.fromEntries(STATE_FIELDS.map((field) => [field, JSON.parse(row[`${field}_json`])])) : null; } - -async function stateApi(request, env, id) { - if (!id) return Response.json({ error: "Authentication required" }, { status: 401 }); - if (!env.DB) return Response.json({ error: "Cloud storage is unavailable" }, { status: 503 }); - try { - if (request.method === "GET") { - const row = await env.DB.prepare("SELECT profile_json, memories_json, museum_json, settings_json, system_json, updated_at FROM lifeos_states WHERE user_id = ?").bind(id).first(); - return Response.json({ state: stateFromRow(row), updatedAt: row?.updated_at ?? null }, { headers: { "cache-control": "no-store" } }); - } - if (request.method === "PUT") { - const payload = await request.json(); - if (!validState(payload?.state)) return Response.json({ error: "Invalid Life OS state" }, { status: 400 }); - const state = payload.state; - const serialized = STATE_FIELDS.map((field) => JSON.stringify(state[field])); - if (serialized.some((value) => value.length > 1_000_000)) return Response.json({ error: "Life OS state is too large" }, { status: 413 }); - const updatedAt = new Date().toISOString(); - await env.DB.prepare("INSERT INTO lifeos_states (user_id, profile_json, memories_json, museum_json, settings_json, system_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET profile_json = excluded.profile_json, memories_json = excluded.memories_json, museum_json = excluded.museum_json, settings_json = excluded.settings_json, system_json = excluded.system_json, updated_at = excluded.updated_at").bind(id, ...serialized, updatedAt).run(); - return Response.json({ ok: true, updatedAt }, { headers: { "cache-control": "no-store" } }); - } - return Response.json({ error: "Method not allowed" }, { status: 405, headers: { allow: "GET, PUT" } }); - } catch (error) { - return Response.json({ error: "Cloud storage request failed", detail: String(error) }, { status: 500 }); - } -} - export default { async fetch(request, env) { const url = new URL(request.url); const id = userId(request); - if (url.pathname === "/api/lifeos-state") return stateApi(request, env, id); + if (["/api/lifeos-state", "/api/lifeos-initialize", "/api/lifeos-sync"].includes(url.pathname)) return cloudApi(request, env, id, url.pathname); try { if (url.pathname === "/api/bing-wallpaper") return Response.json(await bingMetadata(), { headers: { "cache-control": "public, max-age=3600" } }); if (url.pathname === "/api/bing-wallpaper-image") {