4 Commits
11 changed files with 85 additions and 61 deletions

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@
- `lifeos_settings` - `lifeos_settings`
- `lifeos_system` - `lifeos_system`
备份格式包含 `schemaVersion: 1`,为后续数据迁移预留兼容空间。目标和待办保存在 `lifeos_system.tasksBoard`,记忆及其标签、地点、关联待办保存在 `lifeos_memories`,都随现有云端数据同步并包含在 JSON 备份中。旧版记忆继续兼容,新增信息为选填。 JSON 备份使用 `schemaVersion: 2`,导入仍兼容 v1 备份。D1 表结构保持 v1:目标和待办保存在 `lifeos_system.tasksBoard`,随现有 `system_json` 字段同步;记忆及其标签、地点、关联待办保存在 `lifeos_memories`。云端恢复时会把本机尚未同步的目标与待办合并并补传。旧版记忆继续兼容,新增信息为选填。
## 运行 ## 运行
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "life-os", "name": "life-os",
"version": "1.4.0", "version": "1.5.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+26 -11
View File
@@ -1,7 +1,9 @@
/* Life OS reliability and productivity add-ons. */ /* Life OS reliability and productivity add-ons. */
(() => { (() => {
const DRAFT_KEY = 'lifeos_today_draft_v1'; const DRAFT_KEY = 'lifeos_today_draft_v1';
const APP_VERSION = '1.4.0'; 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;' }[char]));
const APP_VERSION = '1.5.1';
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system']; const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system'];
const t = (key, values) => LifeI18n.t(key, values); const t = (key, values) => LifeI18n.t(key, values);
@@ -9,11 +11,11 @@
try { return JSON.parse(localStorage.getItem(DRAFT_KEY)); } catch { return null; } try { return JSON.parse(localStorage.getItem(DRAFT_KEY)); } catch { return null; }
} }
function saveDraft(text) { function saveDraft(text) {
localStorage.setItem(DRAFT_KEY, JSON.stringify({ date: new Date().toISOString().slice(0, 10), text, updatedAt: new Date().toISOString() })); localStorage.setItem(DRAFT_KEY, JSON.stringify({ date: (typeof todayTargetDate !== 'undefined' && todayTargetDate) || localDay(), text, updatedAt: new Date().toISOString() }));
} }
function restoreDraft(entry) { function restoreDraft(entry) {
const draft = readDraft(); const draft = readDraft();
const today = new Date().toISOString().slice(0, 10); const today = (typeof todayTargetDate !== 'undefined' && todayTargetDate) || localDay();
if (!draft || draft.date !== today || !draft.text || entry.value) return; if (!draft || draft.date !== today || !draft.text || entry.value) return;
entry.value = draft.text; entry.value = draft.text;
entry.dispatchEvent(new Event('input', { bubbles: true })); entry.dispatchEvent(new Event('input', { bubbles: true }));
@@ -29,7 +31,7 @@
function wireLanguageControl() { /* The System renderer owns this control. */ } function wireLanguageControl() { /* The System renderer owns this control. */ }
function clearDraftAfterSave() { function clearDraftAfterSave() {
setTimeout(() => { setTimeout(() => {
const today = new Date().toISOString().slice(0, 10); const today = (typeof todayTargetDate !== 'undefined' && todayTargetDate) || localDay();
try { try {
const saved = JSON.parse(localStorage.getItem('lifeos_memories') || '[]').some(m => m.date === today); const saved = JSON.parse(localStorage.getItem('lifeos_memories') || '[]').some(m => m.date === today);
if (saved) localStorage.removeItem(DRAFT_KEY); if (saved) localStorage.removeItem(DRAFT_KEY);
@@ -50,8 +52,8 @@
let memories = []; let memories = [];
try { memories = JSON.parse(localStorage.getItem('lifeos_memories') || '[]'); } catch {} try { memories = JSON.parse(localStorage.getItem('lifeos_memories') || '[]'); } catch {}
const query = input.value.trim().toLowerCase(); const query = input.value.trim().toLowerCase();
const found = memories.filter(m => !query || [m.filmTitle, m.text, m.mood, m.date].join(' ').toLowerCase().includes(query)).slice(0, 8); const found = memories.filter(m => !query || [m.title, m.filmTitle, m.text, m.mood, m.date].join(' ').toLowerCase().includes(query)).slice(0, 8);
results.innerHTML = found.length ? found.map(m => `<button data-id="${m.id}"><small>${m.date} · ${t(`weather.${m.mood || 'cloudy'}`).toUpperCase()}</small><strong>${m.filmTitle || t('addon.untitled')}</strong><span>${(m.text || t('timeline.noNote')).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}</span></button>`).join('') : `<div class="lifeos-search-empty">${t('addon.noResults')}</div>`; results.innerHTML = found.length ? found.map(m => `<button data-id="${m.id}"><small>${m.date} · ${t(`weather.${m.mood || 'cloudy'}`).toUpperCase()}</small><strong>${escapeHtml(m.title || m.text?.trim().split('\n')[0].slice(0, 64) || t('timeline.untitled'))}</strong><span>${(m.text || t('timeline.noNote')).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}</span></button>`).join('') : `<div class="lifeos-search-empty">${t('addon.noResults')}</div>`;
results.querySelectorAll('button').forEach(button => button.addEventListener('click', () => { closeSearch(); openApp('timeline'); setTimeout(() => detail(button.dataset.id), 20); })); results.querySelectorAll('button').forEach(button => button.addEventListener('click', () => { closeSearch(); openApp('timeline'); setTimeout(() => detail(button.dataset.id), 20); }));
}; };
input.addEventListener('input', render); input.addEventListener('input', render);
@@ -75,33 +77,44 @@
}); });
} }
function exportData() { 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'); const link = document.createElement('a');
link.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })); link.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
link.download = `life-os-backup-${day()}.json`; link.click(); 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'); 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' }; 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;
const cloudFallback = { profile: {}, memories: [], museum: [], settings: {}, system: {} }; const cloudFallback = { profile: {}, memories: [], museum: [], settings: {}, system: { schemaVersion: 2 } };
function cloudSnapshot() { 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]]; } 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 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) { function applyCloudState(state) {
let localSystem = {}; let localSystem = {};
try { localSystem = JSON.parse(localStorage.getItem('lifeos_system') || '{}'); } catch {} try { localSystem = JSON.parse(localStorage.getItem('lifeos_system') || '{}'); } catch {}
const localBoard = localSystem.tasksBoard, cloudBoard = state.system?.tasksBoard; 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) { 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) } } }; state = { ...state, system: { ...state.system, tasksBoard: { goals: mergeById(cloudBoard?.goals, localBoard.goals), tasks: mergeById(cloudBoard?.tasks, localBoard.tasks) } } };
} }
applyingCloud = true; applyingCloud = true;
Object.entries(CLOUD_KEYS).forEach(([field, key]) => localStorage.setItem(key, JSON.stringify(state[field] ?? cloudFallback[field]))); Object.entries(CLOUD_KEYS).forEach(([field, key]) => localStorage.setItem(key, JSON.stringify(state[field] ?? cloudFallback[field])));
applyingCloud = false; applyingCloud = false;
todayDraft = null; renderIcons(); renderAll(); applyLanguage(); todayDraft = null; renderIcons(); renderAll(); applyLanguage();
return mergedLocalBoard || systemNeedsMigration;
} }
async function uploadCloudState() { async function uploadCloudState() {
if (!cloudReady) return; if (!cloudReady) return;
@@ -131,7 +144,9 @@
const local = cloudSnapshot(); const local = cloudSnapshot();
if (state) { if (state) {
if (hasLocalLife(local)) localStorage.setItem('lifeos_precloud_backup_v1', JSON.stringify({ exportedAt: new Date().toISOString(), state: local })); 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)) { } else if (hasLocalLife(local)) {
migrationPrompt(local); migrationPrompt(local);
} else { } else {
Loaded 3 of 11 files, more files were not shown because too many files have changed in this diff. Show more