diff --git a/public/addons.js b/public/addons.js index 141f691..35fd590 100644 --- a/public/addons.js +++ b/public/addons.js @@ -3,25 +3,7 @@ const DRAFT_KEY = 'lifeos_today_draft_v1'; const APP_VERSION = '1.1.0'; const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'system']; - const zh = () => (typeof lang === 'function' ? lang() : 'en') === 'zh'; - // Add-on strings share the same catalogue as the desktop shell. Keeping - // this mapping here makes a new feature impossible to ship with a separate - // ad-hoc language mechanism. - const EXTRA_ZH = { - 'DRAFT RESTORED':'草稿已恢复','BROWSER LANGUAGE':'浏览器语言', - 'Detected: Chinese':'当前识别:中文','Detected: English':'当前识别:英语', - 'AUTO':'跟随浏览器','SEARCH MEMORY ARCHIVE':'搜索记忆档案', - 'Search titles, notes, moods…':'搜索标题、记录或天气…', - 'ESC to close · click a result to open it':'按 Esc 关闭 · 点击结果即可打开', - 'Untitled memory':'未命名记忆','No written note.':'尚未写下记录。', - 'No memories found.':'没有找到记忆。','CLOUD SYNC PAUSED — CHANGES STAY ON THIS DEVICE':'云端同步暂停——更改仍保留在此设备', - 'CLOUD SYNC READY':'云端同步已就绪','Move this browser’s Life OS to the cloud?':'将此浏览器的 Life OS 迁移到云端?', - 'Your existing local records will become the first synced copy for this ChatGPT account.':'现有本地记录将成为这个 ChatGPT 账号的第一份云端同步数据。', - 'MIGRATE & SYNC':'迁移并同步','START FRESH':'从空白开始','LOCAL LIFE OS MIGRATED':'本地 Life OS 已迁移', - 'CLOUD LIFE OS RESTORED':'云端 Life OS 已恢复','CLOUD STORAGE UNAVAILABLE — LOCAL MODE ACTIVE':'云端存储暂不可用——已使用本地模式' - }; - if (typeof ZH !== 'undefined') Object.assign(ZH, EXTRA_ZH); - const copy = (en, cn) => (typeof i18n === 'function' ? i18n(en, en) : (zh() ? cn : en)); + const t = (key, values) => LifeI18n.t(key, values); function readDraft() { try { return JSON.parse(localStorage.getItem(DRAFT_KEY)); } catch { return null; } @@ -35,7 +17,7 @@ if (!draft || draft.date !== today || !draft.text || entry.value) return; entry.value = draft.text; entry.dispatchEvent(new Event('input', { bubbles: true })); - if (typeof toast === 'function') toast(copy('DRAFT RESTORED', '草稿已恢复')); + if (typeof toast === 'function') toast('addon.draftRestored'); } function wireToday() { const entry = document.querySelector('#entry'); @@ -44,22 +26,7 @@ restoreDraft(entry); entry.addEventListener('input', () => saveDraft(entry.value)); } - function wireLanguageControl() { - const settingsPanel = document.querySelector('#content-system .settings'); - if (!settingsPanel || settingsPanel.querySelector('#lifeos-browser-language')) return; - const state = settings(); - const actual = typeof browserLanguage === 'function' ? browserLanguage() : 'en'; - const section = document.createElement('section'); - section.id = 'lifeos-browser-language'; - const detected = actual === 'zh' ? copy('Detected: Chinese', '当前识别:中文') : copy('Detected: English', '当前识别:英语'); - section.innerHTML = `
${copy('BROWSER LANGUAGE', '浏览器语言')}

${detected}

`; - section.querySelectorAll('button').forEach(button => button.addEventListener('click', () => setLanguage(button.dataset.language))); - [...settingsPanel.children].forEach(node => { - const label = node.querySelector?.('.eyebrow')?.textContent.trim(); - if (label === 'LANGUAGE' || label === '语言') node.remove(); - }); - settingsPanel.prepend(section); - } + function wireLanguageControl() { /* The System renderer owns this control. */ } function clearDraftAfterSave() { setTimeout(() => { const today = new Date().toISOString().slice(0, 10); @@ -75,7 +42,7 @@ closeSearch(); const box = document.createElement('div'); box.id = 'lifeos-search'; - box.innerHTML = `
${copy('SEARCH MEMORY ARCHIVE', '搜索记忆档案')}

${copy('ESC to close · click a result to open it', 'ESC 关闭 · 点击结果即可打开')}

`; + box.innerHTML = `
${t('addon.searchTitle')}

${t('addon.searchHint')}

`; document.body.append(box); const input = box.querySelector('input'); const results = box.querySelector('.lifeos-search-results'); @@ -84,7 +51,7 @@ try { memories = JSON.parse(localStorage.getItem('lifeos_memories') || '[]'); } catch {} 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); - results.innerHTML = found.length ? found.map(m => ``).join('') : `
${copy('No memories found.', '没有找到记忆。')}
`; + results.innerHTML = found.length ? found.map(m => ``).join('') : `
${t('addon.noResults')}
`; results.querySelectorAll('button').forEach(button => button.addEventListener('click', () => { closeSearch(); openApp('timeline'); setTimeout(() => detail(button.dataset.id), 20); })); }; input.addEventListener('input', render); @@ -107,25 +74,12 @@ if (next !== node.innerHTML) node.innerHTML = next; }); } - function polishLocaleChrome() { - const panel = document.querySelector('#content-system .settings'); - if (!panel) return; - const appearance = [...panel.querySelectorAll('section')].find(section => { - const label = section.querySelector('.eyebrow')?.textContent.trim(); - return label === 'APPEARANCE' || label === '外观'; - }); - if (!appearance) return; - const labels = zh() ? ['深色', '浅色', '跟随系统'] : ['DARK', 'LIGHT', 'AUTO']; - appearance.querySelectorAll('.chip').forEach((button, index) => { - if (labels[index] && button.textContent !== labels[index]) button.textContent = labels[index]; - }); - } function exportData() { const data = { app: 'Life OS', version: APP_VERSION, schemaVersion: 1, exportedAt: new Date().toISOString(), profile: profile(), memories: mems(), museum: museum(), settings: settings() }; 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(copy('BACKUP CREATED', '备份已创建')); + const state = settings(); state.lastBackup = new Date().toISOString(); save('lifeos_settings', state); renderAll(); toast('feedback.backup'); } 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; @@ -151,15 +105,15 @@ if (!cloudReady || applyingCloud) return; clearTimeout(syncTimer); syncTimer = setTimeout(() => uploadCloudState().catch(() => { - if (!syncErrorShown) { syncErrorShown = true; toast(copy('CLOUD SYNC PAUSED — CHANGES STAY ON THIS DEVICE', '云端同步暂停——更改仍保存在此设备')); } + if (!syncErrorShown) { syncErrorShown = true; toast('addon.syncPaused'); } }), 450); } function migrationPrompt(state) { const dialog = document.createElement('div'); dialog.id = 'lifeos-cloud-migration'; - dialog.innerHTML = `
${copy('CLOUD SYNC READY', '云端同步已就绪')}

${copy('Move this browser’s Life OS to the cloud?', '将此浏览器的 Life OS 迁移到云端?')}

${copy('Your existing local records will become the first synced copy for this ChatGPT account.', '现有本地记录将成为这个 ChatGPT 账号的第一份云端同步数据。')}

`; + 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(copy('LOCAL LIFE OS MIGRATED', '本地 Life OS 已迁移')); }); + 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(); }); } async function startCloudSync() { @@ -170,14 +124,14 @@ 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(copy('CLOUD LIFE OS RESTORED', '云端 Life OS 已恢复')); + applyCloudState(state); cloudReady = true; toast('addon.cloudRestored'); } else if (hasLocalLife(local)) { migrationPrompt(local); } else { cloudReady = true; await uploadCloudState(); } } catch { - if (!syncErrorShown) { syncErrorShown = true; toast(copy('CLOUD STORAGE UNAVAILABLE — LOCAL MODE ACTIVE', '云端存储暂不可用——已使用本地模式')); } + if (!syncErrorShown) { syncErrorShown = true; toast('addon.cloudUnavailable'); } } } const originalSetItem = Storage.prototype.setItem; @@ -189,11 +143,10 @@ cloudStyle.textContent = `#lifeos-cloud-migration{position:fixed;inset:0;z-index:9000;background:#071008b8;display:grid;place-items:center;padding:20px}.lifeos-cloud-card{width:min(470px,100%);background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:25px;box-shadow:var(--shadow)}.lifeos-cloud-card h2{margin:10px 0;font-size:25px;letter-spacing:-.04em}.lifeos-cloud-card p{color:var(--muted);line-height:1.65;margin:0 0 20px}`; document.head.append(cloudStyle); startCloudSync(); - new MutationObserver(() => { wireToday(); wireLanguageControl(); polishSystemChrome(); polishLocaleChrome(); }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['title'] }); + new MutationObserver(() => { wireToday(); wireLanguageControl(); polishSystemChrome(); }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['title'] }); wireToday(); wireLanguageControl(); polishSystemChrome(); - polishLocaleChrome(); document.addEventListener('click', e => { if (e.target.closest('#content-today .primary')) clearDraftAfterSave(); }); document.addEventListener('click', e => { if (e.target.closest('.launchitem')) document.querySelector('#launcher')?.classList.add('hidden'); diff --git a/public/i18n-renderers.js b/public/i18n-renderers.js new file mode 100644 index 0000000..a2feea8 --- /dev/null +++ b/public/i18n-renderers.js @@ -0,0 +1,66 @@ +/* Render-time localisation layer. User-created records are deliberately never translated. */ +(() => { + const t = (key, values) => LifeI18n.t(key, values); + const locale = () => lang() === 'zh' ? 'zh-CN' : 'en-US'; + const weekday = value => new Intl.DateTimeFormat(locale(), { weekday: 'long' }).format(new Date(value + 'T12:00:00')); + const uiDate = value => new Intl.DateTimeFormat(locale(), { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }).format(new Date(value + 'T12:00:00')); + const short = value => new Intl.DateTimeFormat(locale(), { month: 'short', day: 'numeric' }).format(new Date(value + 'T12:00:00')).toUpperCase(); + const weather = mood => t(`weather.${mood}`); + const rootModal = () => document.getElementById('modal'); + + window.appName = id => t(`app.${id}`); + window.moodName = weather; + window.fmtDate = uiDate; + window.shortDate = short; + window.applyLanguage = () => { document.documentElement.lang = lang() === 'zh' ? 'zh-CN' : 'en'; document.title = lang() === 'zh' ? 'Life OS / 人生操作系统' : 'Life OS'; }; + window.setLanguage = value => { + const state = settings(); state.language = value; state.languageMode = value === 'auto' ? 'auto' : 'manual'; save('lifeos_settings', state); + renderIcons(); renderAll(); + toast(value === 'auto' ? t(lang() === 'zh' ? 'feedback.followingZh' : 'feedback.followingEn') : t(value === 'zh' ? 'feedback.languageZh' : 'feedback.languageEn')); + }; + window.today = container => { + const memory = mems().find(item => item.date === day()); + if (todayDraft === null) { selectedMood = memory?.mood || 'cloudy'; selectedEnergy = memory?.energy || 3; } + const film = memory || filmFor(selectedMood, selectedEnergy), text = todayDraft === null ? (memory?.text || '') : todayDraft; + container.innerHTML = `
${weekday(day()).toUpperCase()}

${uiDate(day())}

${t('today.prompt')}

`; + }; + window.archiveToday = () => { + const text = document.querySelector('#entry')?.value.trim() || '', old = mems().find(item => item.date === day()), film = old || filmFor(selectedMood, selectedEnergy), now = new Date().toISOString(); + const memory = { id: old?.id || `memory_${crypto.randomUUID()}`, date: day(), text, mood: selectedMood, energy: selectedEnergy, filmTitle: film.filmTitle, filmSubtitle: film.filmSubtitle, filmLine: film.filmLine, createdAt: old?.createdAt || now, updatedAt: now }; + const status = document.querySelector('#saveStatus'); status.textContent = t('today.archiving'); + setTimeout(() => { save('lifeos_memories', [...mems().filter(item => item.date !== day()), memory].sort((a, b) => b.date.localeCompare(a.date))); todayDraft = null; status.textContent = t('today.archived'); renderAll(); }, 360); + }; + window.timeline = container => { + const all = mems(); + container.innerHTML = `
${t('timeline.eyebrow')}

${lang() === 'zh' ? `${all.length} 条记忆已归档` : `${all.length} ${all.length === 1 ? 'MEMORY' : 'MEMORIES'} ARCHIVED`}

${all.length ? `
${all.map(memory => `
`).join('')}
` : `
${t('timeline.emptyTitle')}
${t('timeline.emptyBody')}
`}`; + }; + window.detail = id => { + const memory = mems().find(item => item.id === id); if (!memory) return; + rootModal().innerHTML = ``; + }; + window.editMemory = id => { const memory = mems().find(item => item.id === id); rootModal().innerHTML = ''; openApp('today'); selectedMood = memory.mood; selectedEnergy = memory.energy; const all = mems(), index = all.findIndex(item => item.id === id); all[index].date = day(); save('lifeos_memories', all); today(document.querySelector('#content-today')); document.querySelector('#entry').value = memory.text; toast(t('timeline.editing')); }; + window.preserve = id => { const all = museum(); if (!all.some(item => item.memoryId === id)) { save('lifeos_museum', [...all, { id: `artifact_${crypto.randomUUID()}`, memoryId: id, preservedAt: new Date().toISOString() }]); toast(t('timeline.added')); } rootModal().innerHTML = ''; renderAll(); }; + window.confirmDelete = id => { rootModal().innerHTML = ``; }; + window.deleteMemory = id => { save('lifeos_memories', mems().filter(item => item.id !== id)); save('lifeos_museum', museum().filter(item => item.memoryId !== id)); rootModal().innerHTML = ''; renderAll(); toast(t('timeline.removed')); }; + window.insights = container => { + const all = mems(), recent = all.filter(item => Date.now() - new Date(item.date + 'T12:00:00') < 2592e6), counts = { clear: 0, light: 0, cloudy: 0, rain: 0, storm: 0 }; recent.forEach(item => counts[item.mood]++); + const average = all.length ? (all.reduce((sum, item) => sum + item.energy, 0) / all.length).toFixed(1) : '—', dayCounts = {}; all.forEach(item => { const name = weekday(item.date); dayCounts[name] = (dayCounts[name] || 0) + 1; }); const active = Object.entries(dayCounts).sort((a,b) => b[1] - a[1])[0]?.[0] || '—'; + container.innerHTML = `
${t('insights.eyebrow')}

${t('insights.title')}

${t('insights.weather')}
${Object.entries(counts).map(([key, value]) => `
${weather(key)} ${value} ${t('common.days')}
`).join('')}
${t('insights.average')}
${average} / 5
${all.slice(0,10).reverse().map(item => ``).join('') || `${t('insights.noEntries')}`}
${t('insights.active')}
${active}
${t('insights.current')}
${streak(all)} ${t('common.days')}
${t('insights.longest',{count:longest(all)})}
`; + }; + window.profileApp = container => { + const profileData = profile(), all = mems(), common = Object.entries(all.reduce((out, item) => (out[item.mood] = (out[item.mood] || 0) + 1, out), {})).sort((a,b) => b[1] - a[1])[0]?.[0] || '—', average = all.length ? (all.reduce((sum,item) => sum + item.energy,0) / all.length).toFixed(1) : '—', started = Math.max(1, Math.floor((Date.now() - new Date(profileData.startDate)) / 864e5)); + container.innerHTML = `
${t('profile.eyebrow')}

${profileData.name ? esc(profileData.name) + ' · ' : ''}${t('profile.online')}

${started}${t('profile.daysOnline')}
${all.length}${t('profile.memories')}
${museum().length}${t('profile.museumItems')}
${streak(all)}${t('profile.streak')}
${longest(all)}${t('profile.longest')}
${common === '—' ? common : weather(common)}${t('profile.commonWeather')}
${t('profile.personal')}
${t('profile.average',{value:average})}
`; + }; + 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() })]).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')}
${lang() === 'zh' ? `${mems().length} 条记忆 · ${museum().length} 件博物馆藏品 · 约 ${Math.ceil(bytes/1024)} KB` : `${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB`}

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

Life OS
Version 1.1.0 · Schema v1

`; + }; + window.importPreview = () => { const data = importData; rootModal().innerHTML = ``; }; + window.replaceConfirm = () => { rootModal().innerHTML = ``; }; + window.resetConfirm = () => { rootModal().innerHTML = ``; }; + window.toast = key => { const message = key.includes('.') ? t(key) : key; const node = document.createElement('div'); node.className = 'toast'; node.textContent = message; document.body.append(node); setTimeout(() => node.remove(), 2000); }; + window.minWin = id => { const windowNode = windows[id]; if (!windowNode) return; windowNode.classList.add('min'); if (activeWindow === id) { activeWindow = Object.keys(windows).find(key => key !== id && !windows[key].classList.contains('min')) || null; if (activeWindow) focus(windows[activeWindow]); } renderTaskbar(); toast('feedback.minimized'); }; + // Render every surface again after the overrides are installed; no DOM walking is involved. + renderIcons(); renderAll(); applyLanguage(); +})(); diff --git a/public/i18n.js b/public/i18n.js new file mode 100644 index 0000000..68884de --- /dev/null +++ b/public/i18n.js @@ -0,0 +1,40 @@ +/* Life OS internationalisation: one locale catalogue, no DOM text rewriting. */ +(() => { + const en = { + app: { today: 'TODAY', timeline: 'TIMELINE', museum: 'MUSEUM', insights: 'INSIGHTS', profile: 'PROFILE', system: 'SYSTEM' }, + common: { save: 'SAVE', cancel: 'CANCEL', edit: 'EDIT', delete: 'DELETE', close: 'Close', open: 'Open', minimize: 'Minimize', maximize: 'Maximize', restore: 'Restore window', energy: 'ENERGY', days: 'DAYS', never: 'NEVER' }, + today: { prompt: 'How was your day?', placeholder: 'Write something about today...', archive: 'ARCHIVE TODAY', update: 'UPDATE MEMORY', weather: "TODAY'S WEATHER", energy: 'ENERGY LEVEL', film: "TODAY'S FILM", archiving: 'ARCHIVING MEMORY...', archived: 'MEMORY ARCHIVED' }, + weather: { clear: 'Clear', light: 'Light', cloudy: 'Cloudy', rain: 'Rain', storm: 'Storm' }, + timeline: { eyebrow: 'MEMORY ARCHIVE', emptyTitle: 'No memories yet.', emptyBody: 'Open Today when you are ready to keep one.', detail: 'MEMORY DETAIL', noNote: 'No written note.', editing: 'EDITING AS TODAY — SAVE TO UPDATE', preserved: 'PRESERVED', addMuseum: 'ADD TO MUSEUM', confirmDelete: 'CONFIRM DELETE', remove: 'Remove this memory?', irreversible: 'This cannot be undone.', deleteMemory: 'DELETE MEMORY', removed: 'MEMORY REMOVED', added: 'MEMORY PRESERVED' }, + museum: { eyebrow: 'MUSEUM', title: 'Preserved moments', subtitle: 'Worth keeping for the long view.', empty: 'A museum begins with one preserved moment.', preserved: 'STATUS: PRESERVED' }, + insights: { eyebrow: 'LAST 30 DAYS', title: 'Insights, quietly.', weather: 'WEATHER', average: 'AVERAGE ENERGY', active: 'MOST ACTIVE DAY', current: 'CURRENT STREAK', longest: 'Longest streak: {count} days', noEntries: 'No entries yet' }, + profile: { eyebrow: 'SYSTEM PROFILE', online: 'LIFE OS ONLINE', daysOnline: 'DAYS ONLINE', memories: 'MEMORIES', museumItems: 'MUSEUM ITEMS', streak: 'CURRENT STREAK', longest: 'LONGEST STREAK', commonWeather: 'COMMON WEATHER', personal: 'PERSONAL SYSTEM', name: 'Display Name', birthday: 'Birthday (optional)', start: 'Life OS Start Date', motto: 'Personal Motto', save: 'SAVE PROFILE', saved: 'PROFILE SAVED', average: 'AVERAGE ENERGY {value} / 5' }, + system: { eyebrow: 'SYSTEM SETTINGS', title: 'System, local and yours.', appearance: 'APPEARANCE', language: 'LANGUAGE', browserLanguage: 'BROWSER LANGUAGE', detectedZh: 'Detected: Chinese', detectedEn: 'Detected: English', auto: 'AUTO', data: 'DATA', storage: 'STORAGE', dataStorage: 'DATA STORAGE', export: 'EXPORT DATA', import: 'IMPORT DATA', reset: 'RESET LIFE OS', local: 'Local Storage', stored: 'Your data is stored locally in this browser.', backups: 'Create regular backups to prevent data loss.', lastBackup: 'LAST BACKUP · {value}', recommended: 'BACKUP RECOMMENDED', version: 'VERSION', theme: { dark: 'DARK', light: 'LIGHT', auto: 'AUTO' } }, + dialog: { backup: 'LIFE OS BACKUP', ready: 'Ready to restore', exported: 'Exported: {date}', memories: 'Memories: {count}', museumItems: 'Museum Items: {count}', merge: 'MERGE', replaceAll: 'REPLACE ALL DATA', final: 'FINAL CONFIRMATION', replaceQuestion: 'Replace all Life OS data?', replaceWarning: 'This will replace all current Life OS data.', replace: 'REPLACE DATA', resetQuestion: 'Reset all local data?', resetWarning: 'Export a backup first if you want to keep it.', resetData: 'RESET DATA' }, + feedback: { backup: 'BACKUP CREATED', invalid: 'INVALID BACKUP FILE', restored: 'SYSTEM RESTORED', restoring: 'RESTORING SYSTEM...', minimized: 'MINIMIZED — TAP THE APP ICON TO RESTORE', languageZh: 'Language set to Chinese', languageEn: 'Language set to English', followingZh: 'Following browser language: Chinese', followingEn: 'Following browser language: English' }, + addon: { draftRestored: 'DRAFT RESTORED', searchTitle: 'SEARCH MEMORY ARCHIVE', searchPlaceholder: 'Search titles, notes, moods…', searchHint: 'ESC to close · click a result to open it', untitled: 'Untitled memory', noResults: 'No memories found.', syncPaused: 'CLOUD SYNC PAUSED — CHANGES STAY ON THIS DEVICE', 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.', migrate: 'MIGRATE & SYNC', fresh: 'START FRESH', migrated: 'LOCAL LIFE OS MIGRATED', cloudRestored: 'CLOUD LIFE OS RESTORED', cloudUnavailable: 'CLOUD STORAGE UNAVAILABLE — LOCAL MODE ACTIVE' } + }; + const zh = { + app: { today: '今日', timeline: '时间线', museum: '人生博物馆', insights: '洞察', profile: '个人资料', system: '系统' }, + common: { save: '保存', cancel: '取消', edit: '编辑', delete: '删除', close: '关闭', open: '打开', minimize: '最小化', maximize: '最大化', restore: '恢复窗口', energy: '能量', days: '天', never: '从未' }, + today: { prompt: '今天过得怎么样?', placeholder: '写下今天的片段…', archive: '归档今日', update: '更新记忆', weather: '今日天气', energy: '能量等级', film: '今日电影', archiving: '正在归档…', archived: '记忆已归档' }, + weather: { clear: '晴朗', light: '微光', cloudy: '多云', rain: '雨天', storm: '风暴' }, + timeline: { eyebrow: '记忆档案', emptyTitle: '还没有记忆。', emptyBody: '准备好记录时,打开“今日”。', detail: '记忆详情', noNote: '尚未写下记录。', editing: '正在以今日记录编辑 — 保存即可更新', preserved: '已珍藏', addMuseum: '加入博物馆', confirmDelete: '确认删除', remove: '删除这条记忆?', irreversible: '此操作无法撤销。', deleteMemory: '删除记忆', removed: '记忆已删除', added: '记忆已珍藏' }, + museum: { eyebrow: '人生博物馆', title: '被珍藏的瞬间', subtitle: '值得留给漫长回望的片段。', empty: '从一份被珍藏的记忆开始。', preserved: '状态:已珍藏' }, + insights: { eyebrow: '最近 30 天', title: '安静地看见时间。', weather: '天气', average: '平均能量', active: '最活跃日', current: '当前连续记录', longest: '最长连续记录:{count} 天', noEntries: '暂无记录' }, + profile: { eyebrow: '系统档案', online: 'LIFE OS 在线', daysOnline: '系统运行天数', memories: '记忆', museumItems: '博物馆藏品', streak: '当前连续记录', longest: '最长连续记录', commonWeather: '常见天气', personal: '个人系统', name: '显示名称', birthday: '生日(选填)', start: 'Life OS 启动日期', motto: '个人格言', save: '保存资料', saved: '资料已保存', average: '平均能量 {value} / 5' }, + system: { eyebrow: '系统设置', title: '你的个人系统。', appearance: '外观', language: '语言', browserLanguage: '浏览器语言', detectedZh: '当前识别:中文', detectedEn: '当前识别:英语', auto: '跟随浏览器', data: '数据', storage: '存储', dataStorage: '数据存储', export: '导出数据', import: '导入数据', reset: '重置 Life OS', local: '本地存储', stored: '你的数据仅存储在此浏览器中。', backups: '定期备份,让记忆一直在。', lastBackup: '上次备份 · {value}', recommended: '建议备份', version: '版本', theme: { dark: '深色', light: '浅色', auto: '跟随系统' } }, + dialog: { backup: 'Life OS 备份', ready: '准备恢复', exported: '导出时间:{date}', memories: '记忆:{count}', museumItems: '博物馆藏品:{count}', merge: '合并', replaceAll: '替换全部数据', final: '最终确认', replaceQuestion: '替换全部 Life OS 数据?', replaceWarning: '这将覆盖当前所有 Life OS 数据。', replace: '替换数据', resetQuestion: '重置所有本地数据?', resetWarning: '如需保留数据,请先导出备份。', resetData: '重置数据' }, + feedback: { backup: '备份已创建', invalid: '无效备份文件', restored: '系统已恢复', restoring: '正在恢复系统…', minimized: '已最小化 — 点击任务栏图标即可恢复', languageZh: '语言已切换为中文', languageEn: 'Language set to English', followingZh: '已跟随浏览器语言:中文', followingEn: 'Following browser language: English' }, + addon: { draftRestored: '草稿已恢复', searchTitle: '搜索记忆档案', searchPlaceholder: '搜索标题、记录或天气…', searchHint: '按 Esc 关闭 · 点击结果即可打开', untitled: '未命名记忆', noResults: '没有找到记忆。', syncPaused: '云端同步暂停——更改仍保留在此设备', syncReady: '云端同步已就绪', migrateQuestion: '将此浏览器的 Life OS 迁移到云端?', migrateBody: '现有本地记录将成为这个 ChatGPT 账号的第一份云端同步数据。', migrate: '迁移并同步', fresh: '从空白开始', migrated: '本地 Life OS 已迁移', cloudRestored: '云端 Life OS 已恢复', cloudUnavailable: '云端存储暂不可用——已使用本地模式' } + }; + const get = (object, key) => key.split('.').reduce((value, part) => value?.[part], object); + function currentLocale() { return typeof window.lang === 'function' ? window.lang() : 'en'; } + function t(key, values = {}) { + const dictionary = currentLocale() === 'zh' ? zh : en; + const fallback = get(en, key) || key; + return String(get(dictionary, key) ?? fallback).replace(/\{(\w+)\}/g, (_, name) => values[name] ?? `{${name}}`); + } + function date(value, options) { return new Intl.DateTimeFormat(currentLocale() === 'zh' ? 'zh-CN' : 'en-US', options).format(new Date(value)); } + window.LifeI18n = { t, date, dictionaries: { en, zh } }; +})(); diff --git a/public/index.html b/public/index.html index dd8eb6f..50543a3 100644 --- a/public/index.html +++ b/public/index.html @@ -40,4 +40,4 @@ function streak(a){let s=0,d=new Date();while(a.some(m=>m.date===d.toISOString() 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||'';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 0.1 · Schema v1

`}function theme(x){let s=settings();s.theme=x;save('lifeos_settings',s);init();renderAll()}function exportData(){let data={app:'Life OS',version:'0.1',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'||d.schemaVersion!==1||!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)}init(); - + diff --git a/scripts/build.mjs b/scripts/build.mjs index 15bc99f..cfbc3d1 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -7,10 +7,10 @@ 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] = await Promise.all([text("index.html"), text("addons.js")]); +const [rawIndexHtml, addonsJs, i18nJs, i18nRenderersJs] = await Promise.all([text("index.html"), text("addons.js"), text("i18n.js"), text("i18n-renderers.js")]); const release = "1.1.0"; const indexHtml = rawIndexHtml.replace('src="addons.js"', `src="addons.js?v=${release}"`); -await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\n`); +await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\n`); await copyFile(new URL("../worker/index.js", import.meta.url), new URL("index.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/worker/index.js b/worker/index.js index 667d24c..299416b 100644 --- a/worker/index.js +++ b/worker/index.js @@ -1,4 +1,4 @@ -import { addonsJs, indexHtml } from "./assets.js"; +import { addonsJs, i18nJs, i18nRenderersJs, indexHtml } from "./assets.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"]; @@ -62,6 +62,8 @@ export default { return Response.json({ error: "Bing wallpaper is temporarily unavailable", detail: String(error) }, { status: 502 }); } if (url.pathname === "/addons.js") return new Response(addonsJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); + if (url.pathname === "/i18n.js") return new Response(i18nJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); + if (url.pathname === "/i18n-renderers.js") return new Response(i18nRenderersJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/" || url.pathname === "/index.html") { if (!id) return new Response(loginPage(), { headers: headers("text/html; charset=utf-8", "no-cache") }); return new Response(indexHtml, { headers: headers("text/html; charset=utf-8", "no-cache") });