diff --git a/package.json b/package.json index 465444a..a95705a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "life-os", - "version": "1.5.1", + "version": "1.5.2", "private": true, "type": "module", "scripts": { diff --git a/public/addons.js b/public/addons.js index 8c99922..3babd3a 100644 --- a/public/addons.js +++ b/public/addons.js @@ -2,8 +2,9 @@ (() => { 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.5.1'; + const escapeHtml = value => String(value || '').replace(/[&<>]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); + const APP_VERSION = '1.5.2'; + window.LifeOSVersion = APP_VERSION; const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system']; const t = (key, values) => LifeI18n.t(key, values); @@ -44,7 +45,7 @@ closeSearch(); const box = document.createElement('div'); box.id = 'lifeos-search'; - box.innerHTML = `
${t('addon.searchTitle')}

${t('addon.searchHint')}

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

${t('addon.searchHint')}

`; document.body.append(box); const input = box.querySelector('input'); const results = box.querySelector('.lifeos-search-results'); @@ -53,7 +54,7 @@ try { memories = JSON.parse(localStorage.getItem('lifeos_memories') || '[]'); } catch {} const query = input.value.trim().toLowerCase(); 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 => ``).join('') : `
${t('addon.noResults')}
`; + 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); @@ -65,17 +66,7 @@ const style = document.createElement('style'); style.textContent = `.lifeos-search-backdrop{position:fixed;inset:0;background:#0008;z-index:8000}.lifeos-search-panel{position:fixed;z-index:8001;top:18vh;left:50%;transform:translateX(-50%);width:min(560px,calc(100vw - 28px));background:var(--panel);border:1px solid var(--line);border-radius:15px;box-shadow:var(--shadow);padding:15px}.lifeos-search-head{display:flex;justify-content:space-between;align-items:center;font:500 11px 'DM Mono';letter-spacing:.12em;color:var(--muted);margin-bottom:13px}.lifeos-search-head button{border:0;background:transparent;color:var(--ink);font-size:20px}.lifeos-search-panel input{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:12px;outline:none}.lifeos-search-panel input:focus{border-color:var(--accent)}.lifeos-search-results{margin-top:9px;max-height:360px;overflow:auto}.lifeos-search-results button{display:grid;gap:3px;width:100%;text-align:left;border:0;border-radius:9px;padding:11px;background:transparent;color:var(--ink)}.lifeos-search-results button:hover{background:var(--panel2)}.lifeos-search-results small,.lifeos-search-results span,.lifeos-search-hint{color:var(--muted);font-size:12px}.lifeos-search-results strong{font-size:14px}.lifeos-search-results span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.lifeos-search-empty{padding:24px;color:var(--muted);text-align:center;font-size:13px}.lifeos-search-hint{margin:11px 0 0;font:10px 'DM Mono';letter-spacing:.04em}.launchitem svg{width:28px;height:28px;display:block;margin:auto;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.launchitem:hover svg{color:var(--accent)}`; document.head.append(style); - function polishSystemChrome() { - document.querySelector('#desktop')?.removeAttribute('title'); - document.querySelectorAll('.systemline').forEach(node => { - const next = node.textContent.replace(/LIFE OS v0\.1/g, `LIFE OS v${APP_VERSION}`); - if (next !== node.textContent) node.textContent = next; - }); - document.querySelectorAll('#content-system .settings section:last-child p').forEach(node => { - const next = node.innerHTML.replace(/Version 0\.1/g, `Version ${APP_VERSION}`).replace(/版本 0\.1/g, `版本 ${APP_VERSION}`); - if (next !== node.innerHTML) node.innerHTML = next; - }); - } + function exportData() { 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'); @@ -165,10 +156,9 @@ 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(); }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['title'] }); + new MutationObserver(() => { wireToday(); wireLanguageControl(); }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['title'] }); wireToday(); wireLanguageControl(); - polishSystemChrome(); 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 index f86e5a1..84344cf 100644 --- a/public/i18n-renderers.js +++ b/public/i18n-renderers.js @@ -10,6 +10,7 @@ const linkedTasks = () => sys().tasksBoard?.tasks || []; const escAttr = value => esc(value).replace(/\"/g, '"'); const titleOf = memory => memory.title?.trim() || memory.text?.trim().split('\n')[0].slice(0, 64) || t('timeline.untitled'); + const localizedFilmField = (memory, field) => { const index = LifeI18n.dictionaries.en.prompt.films.findIndex(film => film.title === memory.filmTitle); return index < 0 ? (memory[field] || '') : (LifeI18n.dictionaries[lang() === 'zh' ? 'zh' : 'en'].prompt.films[index]?.[field === 'filmTitle' ? 'title' : 'subtitle'] || memory[field] || ''); }; const tagList = value => [...new Set(String(value || '').split(/[,,]/).map(tag => tag.trim()).filter(Boolean))].slice(0, 12); const memoryMetaFields = memory => `
`; @@ -19,10 +20,11 @@ window.shortDate = short; window.addMemoryForDate = date => { todayTargetDate = date; todayDraft = null; todayTitleDraft = null; openApp('today'); }; window.closeModal = () => { rootModal().innerHTML = ''; }; - window.applyLanguage = () => { document.documentElement.lang = lang() === 'zh' ? 'zh-CN' : 'en'; document.title = lang() === 'zh' ? 'Life OS / 人生操作系统' : 'Life OS'; }; + window.applyLanguage = () => { document.documentElement.lang = lang() === 'zh' ? 'zh-CN' : 'en'; document.title = t('shell.pageTitle'); const brand = document.querySelector('#desktop-brand'), launcher = document.querySelector('#life-launcher'), boot = document.querySelector('#boot span'); if (brand) brand.textContent = t('shell.brand'); if (launcher) launcher.textContent = t('shell.life'); if (boot) boot.textContent = t('shell.initializing'); if (settings().wallpaper) applyWallpaper(settings().wallpaper); }; + window.applyWallpaper = wallpaper => { if (!wallpaper?.url) return; const desktop = document.querySelector('#desktop'); desktop.style.backgroundImage = `linear-gradient(120deg,color-mix(in srgb,var(--bg) 68%,transparent),color-mix(in srgb,var(--bg) 36%,transparent)),url('${wallpaper.url}')`; desktop.style.backgroundSize = 'cover'; desktop.style.backgroundPosition = 'center'; desktop.title = t('shell.wallpaperTitle', { title: wallpaper.title || t('wallpaper.dailyImage') }); }; window.setLanguage = value => { const state = settings(); state.language = value; state.languageMode = value === 'auto' ? 'auto' : 'manual'; save('lifeos_settings', state); - renderIcons(); renderAll(); + renderIcons(); renderAll(); applyLanguage(); toast(value === 'auto' ? t(lang() === 'zh' ? 'feedback.followingZh' : 'feedback.followingEn') : t(value === 'zh' ? 'feedback.languageZh' : 'feedback.languageEn')); }; window.today = container => { @@ -48,12 +50,16 @@ const isSaved = memory => museum().some(item => item.memoryId === memory.id); container.innerHTML = `
${t('timeline.eyebrow')}

${t('timeline.heading',{count:filtered.length,total:all.length})}

${filtered.length ? `
${filtered.map(memory => { const task = linkedTasks().find(item => item.id === memory.taskId); return `
`; }).join('')}
` : `
${all.length ? t('timeline.noMatches') : `${t('timeline.emptyTitle')}
${t('timeline.emptyBody')}`}
`}`; }; + window.museumApp = container => { + const saved = museum(), memories = mems(); + container.innerHTML = `
${t('museum.eyebrow')}

${t('museum.title')}

${t('museum.subtitle')}

${saved.length ? `
${saved.map((artifact,index) => { const memory = memories.find(item => item.id === artifact.memoryId); if (!memory) return ''; return ``; }).join('')}
` : `
${t('museum.empty')}
`}`; + }; window.setTimelineQuery = value => { window.timelineQuery = value; const field = document.querySelector('#content-timeline input[type=search]'); const start = field?.selectionStart; timeline(document.querySelector('#content-timeline')); const next = document.querySelector('#content-timeline input[type=search]'); next?.focus(); if (start !== undefined) next?.setSelectionRange(start,start); }; window.setTimelineTag = value => { window.timelineTag = value; timeline(document.querySelector('#content-timeline')); }; window.detail = id => { const memory = mems().find(item => item.id === id); if (!memory) return; const task = linkedTasks().find(item => item.id === memory.taskId), favorite = museum().some(item => item.memoryId === memory.id); - rootModal().innerHTML = ``; + rootModal().innerHTML = ``; }; window.editMemory = id => { const memory = mems().find(item => item.id === id); if (!memory) return; rootModal().innerHTML = ``; }; window.saveMemoryEdit = (event,id) => { event.preventDefault(); const all=mems(), memory=all.find(item=>item.id===id); if(!memory)return; memory.title=document.querySelector('#memory-edit-title').value.trim(); memory.text=document.querySelector('#memory-edit-note').value.trim(); memory.tags=tagList(document.querySelector('#memory-tags').value); memory.location=document.querySelector('#memory-place').value.trim(); memory.taskId=document.querySelector('#memory-task').value||''; memory.updatedAt=new Date().toISOString(); save('lifeos_memories',all); closeModal(); renderAll(); detail(id); }; @@ -72,7 +78,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(), 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')}
${lang() === 'zh' ? `${mems().length} 条记忆 · ${museum().length} 件博物馆藏品 · ${sys().tasksBoard?.tasks?.length || 0} 项待办 · 约 ${Math.ceil(bytes/1024)} KB` : `${mems().length} memories · ${museum().length} museum items · ${sys().tasksBoard?.tasks?.length || 0} tasks · Approx. ${Math.ceil(bytes/1024)} KB`}

${t('system.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')}

${lang() === 'zh' ? 'Life OS
版本 1.5.0 · 备份格式 v2 · 数据库结构 v1' : 'Life OS
Version 1.5.0 · Backup schema v2 · D1 schema v1'}

`; + 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:LifeOSVersion||'1.5.2'})}

`; }; window.importPreview = () => { const data = importData, board = data.system?.tasksBoard || {}; rootModal().innerHTML = ``; }; window.replaceConfirm = () => { rootModal().innerHTML = ``; }; diff --git a/public/i18n.js b/public/i18n.js index 10af705..bdc1a71 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -1,46 +1,22 @@ -/* Life OS internationalisation: one locale catalogue, no DOM text rewriting. */ +/* Translation lookup and locale-aware formatting. Copy lives in /locales/*.json. */ (() => { - const en = { - app: { today: 'TODAY', timeline: 'TIMELINE', museum: 'MUSEUM', insights: 'INSIGHTS', profile: 'PROFILE', tasks: 'TASKS', calendar: 'CALENDAR', 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', archiveDate: 'SAVE MEMORY', updateDate: 'UPDATE MEMORY', backToToday: 'Back to today', weather: "TODAY'S WEATHER", energy: 'ENERGY LEVEL', film: 'DAILY PROMPT', archiving: 'ARCHIVING MEMORY...', archived: 'MEMORY ARCHIVED' }, - weather: { clear: 'Clear', light: 'Light', cloudy: 'Cloudy', rain: 'Rain', storm: 'Storm' }, - timeline: { eyebrow: 'MEMORY ARCHIVE', heading: '{count} OF {total} MEMORIES', search: 'Search memories', searchPlaceholder: 'Search notes, tags, places, tasks…', filterTag: 'Filter by tag', allTags: 'ALL TAGS', noMatches: 'No memories match these filters.', favorite: 'FAVORITE', emptyTitle: 'No memories yet.', emptyBody: 'Open Today when you are ready to keep one.', detail: 'MEMORY DETAIL', untitled: 'Untitled memory', noNote: 'No written note.', editing: 'EDITING AS TODAY — SAVE TO UPDATE', preserved: 'FAVORITED', addMuseum: 'ADD TO MUSEUM', confirmDelete: 'CONFIRM DELETE', remove: 'Remove this memory?', irreversible: 'This cannot be undone.', deleteMemory: 'DELETE MEMORY', removed: 'MEMORY REMOVED', added: 'MEMORY FAVORITED', unpreserved: 'REMOVED FROM FAVORITES' }, - memory: { tags: 'TAGS', tagsPlaceholder: 'Separate tags with commas', place: 'PLACE / SCENE (OPTIONAL)', placePlaceholder: 'Where or what was the setting?', linkTask: 'RELATED TASK (OPTIONAL)', noTask: 'NO RELATED TASK', relatedTask: 'Related task', favorite: 'ADD TO FAVORITES', edit: 'EDIT MEMORY DETAILS', note: 'NOTE', title: 'TITLE (OPTIONAL)', titlePlaceholder: 'Give this memory a title' }, - 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}', goals: 'Goals: {count}', tasks: 'Tasks: {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' }, - calendar: { eyebrow: 'MEMORIES & MILESTONES', month: 'MONTH', day: 'DAY', today: 'TODAY', mon: 'MON', tue: 'TUE', wed: 'WED', thu: 'THU', fri: 'FRI', sat: 'SAT', sun: 'SUN', previous: 'Previous month', next: 'Next month', previousDay: 'Previous day', nextDay: 'Next day', dayAtGlance: 'THAT DAY', memory: 'MEMORY', task: 'TASK', goal: 'GOAL', items: 'items', taskDue: 'TASK DUE', taskDone: 'TASK COMPLETED', goalCreated: 'GOAL CREATED', goalTarget: 'GOAL TARGET DATE', goalDone: 'GOAL COMPLETED', emptyDay: 'No memories or milestones recorded for this day.', openToday: 'Write a memory', writeMemory: 'Add memory', openTasks: 'Open tasks' }, - tasks: { eyebrow: 'GOALS & TASKS', title: 'Make room for what matters.', subtitle: 'A small, steady plan for today and the days ahead.', addGoal: 'NEW GOAL', addTask: 'ADD TASK', taskPlaceholder: 'What needs doing?', goalPlaceholder: 'What do you want to move toward?', goalDescription: 'A note about this goal (optional)', finishedGoals: 'COMPLETED GOALS', goalComplete: 'COMPLETE GOAL', goalReopen: 'REOPEN GOAL', all: 'ALL', today: 'TODAY', upcoming: 'UPCOMING', done: 'DONE', activeGoals: 'ACTIVE GOALS', noGoals: 'Start with one goal worth moving toward.', noTasks: 'Nothing on this list yet.', due: 'DUE {date}', noDate: 'NO DUE DATE', priority: { low: 'LOW', normal: 'NORMAL', high: 'HIGH' }, goal: 'GOAL', anyGoal: 'NO GOAL', save: 'SAVE', cancel: 'CANCEL', complete: 'MARK COMPLETE', reopen: 'REOPEN', delete: 'DELETE', goalTitle: 'New goal', taskTitle: 'New task', goalCreated: 'GOAL CREATED', taskAdded: 'TASK ADDED', taskRemoved: 'TASK REMOVED', goalRemoved: 'GOAL REMOVED', confirmGoalDelete: 'Delete this goal?', confirmGoalDeleteBody: 'Tasks linked to it will be kept without a goal.', target: 'TARGET DATE', completed: 'COMPLETED', progress: '{done} OF {total} DONE', desktopShow: 'SHOW COUNTDOWN', desktopHide: 'HIDE COUNTDOWN', countdown: 'GOAL COUNTDOWN', daysLeft: '{count} days left', todayTarget: 'Target is today', daysOverdue: '{count} days overdue', pinOnCreate: 'Show countdown on desktop', todayHeading: 'Focus for today', openCount: '{count} OPEN TASKS', overdue: 'OVERDUE' } - }; - const zh = { - app: { today: '今日', timeline: '时间线', museum: '人生博物馆', insights: '洞察', profile: '个人资料', tasks: '目标与待办', calendar: '日历', system: '系统' }, - common: { save: '保存', cancel: '取消', edit: '编辑', delete: '删除', close: '关闭', open: '打开', minimize: '最小化', maximize: '最大化', restore: '恢复窗口', energy: '能量', days: '天', never: '从未' }, - today: { prompt: '今天过得怎么样?', placeholder: '写下今天的片段…', archive: '归档今日', update: '更新记忆', archiveDate: '保存记忆', updateDate: '更新记忆', backToToday: '返回今天', weather: '今日天气', energy: '能量等级', film: '今日灵感', archiving: '正在归档…', archived: '记忆已归档' }, - weather: { clear: '晴朗', light: '微光', cloudy: '多云', rain: '雨天', storm: '风暴' }, - timeline: { eyebrow: '记忆档案', heading: '{count} / {total} 条记忆', search: '搜索记忆', searchPlaceholder: '搜索记录、标签、地点或待办…', filterTag: '按标签筛选', allTags: '全部标签', noMatches: '没有符合筛选条件的记忆。', favorite: '已收藏', emptyTitle: '还没有记忆。', emptyBody: '准备好记录时,打开“今日”。', detail: '记忆详情', untitled: '当天记录', noNote: '尚未写下记录。', editing: '正在以今日记录编辑 — 保存即可更新', preserved: '已收藏', addMuseum: '加入收藏', confirmDelete: '确认删除', remove: '删除这条记忆?', irreversible: '此操作无法撤销。', deleteMemory: '删除记忆', removed: '记忆已删除', added: '记忆已收藏', unpreserved: '已取消收藏' }, - memory: { tags: '标签', tagsPlaceholder: '多个标签请用逗号分隔', place: '地点 / 场景(选填)', placePlaceholder: '在哪里,或当时是什么场景?', linkTask: '关联待办(选填)', noTask: '不关联待办', relatedTask: '关联待办', favorite: '加入收藏', edit: '编辑记忆详情', note: '记录内容', title: '标题(选填)', titlePlaceholder: '为这条记忆取个标题' }, - 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}', goals: '目标:{count}', tasks: '待办:{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: '云端存储暂不可用——已使用本地模式' }, - calendar: { eyebrow: '记忆与生活节点', month: '月', day: '日', today: '今天', mon: '一', tue: '二', wed: '三', thu: '四', fri: '五', sat: '六', sun: '日', previous: '上个月', next: '下个月', previousDay: '前一天', nextDay: '后一天', dayAtGlance: '这一天', memory: '记忆', task: '待办', goal: '目标', items: '项', taskDue: '待办截止', taskDone: '完成待办', goalCreated: '创建目标', goalTarget: '目标日期', goalDone: '完成目标', emptyDay: '这一天还没有记忆或目标节点。', openToday: '写下一段记忆', writeMemory: '新增记忆', openTasks: '打开待办' }, - tasks: { eyebrow: '目标与待办', title: '把心力留给重要的事。', subtitle: '为今天和接下来的日子,排一个轻量、踏实的计划。', addGoal: '新建目标', addTask: '添加待办', taskPlaceholder: '接下来要做什么?', goalPlaceholder: '你想朝什么方向前进?', goalDescription: '关于这个目标的备注(选填)', finishedGoals: '已完成的目标', goalComplete: '完成目标', goalReopen: '重新开启目标', all: '全部', today: '今天', upcoming: '即将开始', done: '已完成', activeGoals: '进行中的目标', noGoals: '从一个值得继续靠近的目标开始。', noTasks: '这里暂时没有待办。', due: '截止 {date}', noDate: '无截止日期', priority: { low: '低', normal: '普通', high: '高' }, goal: '目标', anyGoal: '不关联目标', save: '保存', cancel: '取消', goalTitle: '新建目标', taskTitle: '新建待办', goalCreated: '目标已创建', taskAdded: '待办已添加', taskRemoved: '待办已删除', goalRemoved: '目标已删除', confirmGoalDelete: '删除这个目标?', confirmGoalDeleteBody: '关联的待办会保留,但不再关联此目标。', target: '目标日期', completed: '已完成', complete: '标记完成', reopen: '重新打开', delete: '删除', progress: '已完成 {done} / {total}', desktopShow: '显示倒计时', desktopHide: '隐藏倒计时', countdown: '目标倒计时', daysLeft: '还有 {count} 天', todayTarget: '目标就在今天', daysOverdue: '已过期 {count} 天', pinOnCreate: '在桌面显示倒计时', todayHeading: '今天要推进的事', openCount: '还有 {count} 项待办', overdue: '已逾期' } - }; - const get = (object, key) => key.split('.').reduce((value, part) => value?.[part], object); - function currentLocale() { return typeof window.lang === 'function' ? window.lang() : 'en'; } + const dictionaries = window.LifeOSLocales || {}; + const en = dictionaries.en || {}; + const zh = dictionaries['zh-CN'] || {}; + const lookup = (object, path) => path.split('.').reduce((value, part) => value?.[part], object); + const current = () => typeof window.lang === 'function' && window.lang() === 'zh' ? 'zh' : '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}}`); + const selected = current() === 'zh' ? zh : en; + const template = lookup(selected, key) ?? lookup(en, key) ?? key; + return String(template).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 } }; + + function locale() { return current() === 'zh' ? 'zh-CN' : 'en-US'; } + function date(value, options) { + const parsed = value instanceof Date ? value : new Date(typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T12:00:00` : value); + return new Intl.DateTimeFormat(locale(), options).format(parsed); + } + function number(value, options) { return new Intl.NumberFormat(locale(), options).format(value); } + window.LifeI18n = { t, date, number, locale, dictionaries: { en, zh } }; })(); diff --git a/public/index.html b/public/index.html index a20d2ac..118fff7 100644 --- a/public/index.html +++ b/public/index.html @@ -4,40 +4,24 @@ :root{--bg:#101216;--panel:#191c21;--panel2:#20242a;--ink:#f0f2f5;--muted:#939aa5;--line:#30353d;--accent:#c8ff62;--shadow:0 22px 65px #0008} [data-theme="light"]{--bg:#e9ecee;--panel:#f7f8f7;--panel2:#eef0ee;--ink:#14171a;--muted:#66707a;--line:#d5d9da;--accent:#506c00;--shadow:0 22px 65px #7b828733} *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:Manrope,system-ui;overflow:hidden}button,input,textarea{font:inherit;color:inherit}button{cursor:pointer}.desktop{height:100vh;background:radial-gradient(ellipse at 20% 0%,#3f4a503d,transparent 40%),radial-gradient(ellipse at 93% 65%,#59604224,transparent 42%),var(--bg);position:relative}.brand{position:absolute;top:28px;left:32px;font:500 11px 'DM Mono';letter-spacing:.18em;color:var(--muted)}.desktop-icons{padding:72px 32px;display:grid;grid-template-columns:repeat(3,96px);gap:22px}.app-icon{border:0;background:transparent;color:var(--ink);display:grid;gap:9px;justify-items:center;font:500 10px 'DM Mono';letter-spacing:.12em}.glyph{width:48px;height:48px;background:var(--panel);border:1px solid var(--line);border-radius:14px;display:grid;place-items:center;box-shadow:0 8px 22px #0002;font-size:20px}.app-icon:hover .glyph,.taskapp.active{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 12%,transparent)}.taskbar{position:absolute;bottom:18px;left:50%;transform:translateX(-50%);height:54px;display:flex;align-items:center;gap:5px;padding:6px;background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--line);border-radius:17px;box-shadow:var(--shadow);z-index:1000}.life,.taskapp,.clock{height:40px;border:0;border-radius:11px;background:transparent;color:var(--ink);padding:0 12px;font:500 11px 'DM Mono';letter-spacing:.1em}.life{background:var(--accent);color:#151812;font-weight:500}.taskapp{min-width:42px;font-size:17px;padding:0 10px}.clock{border-left:1px solid var(--line);line-height:1.35;text-align:left;font-size:10px}.launcher,.calendar{position:absolute;bottom:82px;background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:17px;padding:19px;z-index:1200}.launcher{left:calc(50% - 310px);width:300px}.calendar{right:calc(50% - 310px);width:230px}.hidden{display:none!important}.launchhead{display:flex;justify-content:space-between;font:500 11px 'DM Mono';letter-spacing:.12em}.launchgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:18px 0}.launchitem{border:1px solid transparent;background:var(--panel2);border-radius:10px;padding:13px 7px;color:var(--ink);font:500 9px 'DM Mono';letter-spacing:.08em}.launchitem:hover{border-color:var(--accent)}.systemline{border-top:1px solid var(--line);padding-top:12px;color:var(--muted);font:10px 'DM Mono';letter-spacing:.05em}.window{position:absolute;width:min(700px,calc(100vw - 100px));height:min(590px,calc(100vh - 130px));background:var(--panel);border:1px solid var(--line);border-radius:15px;box-shadow:var(--shadow);overflow:hidden;animation:in .18s ease;display:flex;flex-direction:column}.window.max{width:calc(100vw - 36px)!important;height:calc(100vh - 98px)!important;left:18px!important;top:18px!important}.titlebar{height:48px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:0 13px;cursor:grab;flex:none}.titlebar:active{cursor:grabbing}.titleicon{width:22px;height:22px;border:1px solid var(--line);border-radius:7px;display:grid;place-items:center;font-size:12px}.wtitle{font:500 11px 'DM Mono';letter-spacing:.13em;flex:1}.controls{display:flex;gap:5px}.controls button{width:25px;height:25px;border:0;background:transparent;color:var(--muted);border-radius:6px}.controls button:hover{background:var(--panel2);color:var(--ink)}.content{padding:24px;overflow:auto;height:100%}.eyebrow{font:10px 'DM Mono';letter-spacing:.16em;color:var(--muted);margin-bottom:8px}h1,h2,p{margin-top:0}h1{font-size:28px;letter-spacing:-.05em;margin-bottom:3px}h2{font-size:13px;letter-spacing:.07em}.sub{color:var(--muted);font-size:14px}.today-grid{display:grid;grid-template-columns:1.3fr .7fr;gap:24px}.entry{width:100%;height:190px;resize:none;background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:15px;outline:none;line-height:1.55}.entry:focus{border-color:var(--accent)}.choices{display:flex;gap:7px;flex-wrap:wrap}.chip{border:1px solid var(--line);border-radius:99px;background:transparent;padding:8px 10px;color:var(--muted);font:10px 'DM Mono';letter-spacing:.06em}.chip.selected{background:var(--accent);border-color:var(--accent);color:#171b12}.energy{display:flex;gap:7px}.energy button{width:31px;height:31px;border:1px solid var(--line);background:var(--panel2);color:var(--muted);border-radius:6px}.energy button.selected{background:var(--accent);color:#171b12;border-color:var(--accent)}.film{border-top:1px solid var(--line);margin-top:18px;padding-top:16px}.film-title{font-size:20px;letter-spacing:-.03em}.film-copy{color:var(--muted);font-size:13px;line-height:1.55}.primary,.secondary,.danger{border:0;padding:11px 14px;border-radius:9px;font:500 10px 'DM Mono';letter-spacing:.1em}.primary{background:var(--accent);color:#151812}.secondary{background:var(--panel2);border:1px solid var(--line)}.danger{background:#a94e4e;color:#fff}.actions{display:flex;gap:9px;margin-top:18px}.status{font:10px 'DM Mono';letter-spacing:.1em;color:var(--accent);align-self:center}.timeline{position:relative;margin:8px 0}.memory{border-left:1px solid var(--line);padding:0 0 23px 25px;position:relative}.memory:before{content:'';position:absolute;left:-4px;top:5px;width:7px;height:7px;background:var(--accent);border-radius:50%}.memory button{all:unset;cursor:pointer;width:100%}.memory .date{font:10px 'DM Mono';color:var(--muted);letter-spacing:.1em}.memory .mtitle{font-size:18px;margin:5px 0}.memory .snippet{color:var(--muted);font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty{display:grid;place-items:center;min-height:280px;color:var(--muted);text-align:center;font-size:14px}.museum{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.artifact{background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:17px;text-align:left;color:var(--ink)}.artifact:hover{border-color:var(--accent)}.artifact span{display:block;font:10px 'DM Mono';color:var(--muted);letter-spacing:.08em}.artifact strong{display:block;font-size:18px;margin:18px 0 7px;letter-spacing:-.04em}.insights{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.metric{border:1px solid var(--line);border-radius:12px;padding:18px;min-height:120px}.metric .big{font-size:36px;letter-spacing:-.07em}.bars{display:flex;align-items:end;height:56px;gap:5px;margin-top:12px}.bar{background:var(--accent);opacity:.78;width:12%;border-radius:3px 3px 0 0}.profile-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:20px 0}.stat{border-top:1px solid var(--line);padding-top:10px}.stat b{display:block;font-size:24px;letter-spacing:-.06em}.stat span{font:10px 'DM Mono';color:var(--muted);letter-spacing:.08em}.formrow{display:grid;grid-template-columns:1fr 1.5fr;gap:12px;align-items:center;margin:9px 0}.formrow input{border:1px solid var(--line);background:var(--panel2);border-radius:8px;padding:10px}.settings section{border-top:1px solid var(--line);padding:17px 0}.settings section:first-child{border-top:0}.settings p{color:var(--muted);font-size:13px;line-height:1.55}.modal{position:fixed;inset:0;background:#0008;display:grid;place-items:center;z-index:5000}.modalbox{position:relative;width:min(410px,calc(100vw - 36px));background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:23px;box-shadow:var(--shadow)}.modal-close{position:absolute;top:12px;right:12px;width:32px;height:32px;border:1px solid var(--line);border-radius:50%;background:transparent;color:var(--ink);font-size:24px;line-height:1;cursor:pointer}.modal-close:hover,.modal-close:focus-visible{background:var(--accent);color:#fff;outline:none}.toast{position:fixed;top:24px;left:50%;transform:translateX(-50%);background:var(--accent);color:#131611;padding:12px 15px;border-radius:9px;z-index:6000;font:500 10px 'DM Mono';letter-spacing:.1em;animation:in .18s ease}.boot{position:fixed;inset:0;background:var(--bg);z-index:9000;display:grid;place-content:center;text-align:center;gap:13px;font:12px 'DM Mono';letter-spacing:.16em}.boot b{font-size:28px;color:var(--ink)}@keyframes in{from{opacity:0;transform:scale(.98)}to{opacity:1;transform:scale(1)}} @media(max-width:650px){body{overflow:auto}.desktop{min-height:100vh}.brand{left:22px;top:20px}.desktop-icons{padding:79px 20px 100px;grid-template-columns:repeat(3,1fr);gap:28px 16px}.taskbar{bottom:11px;width:calc(100% - 22px);justify-content:space-between}.taskapp{display:inline-grid;place-items:center;min-width:32px;padding:0 6px}.clock{display:none}.window{position:fixed!important;inset:0!important;width:100%!important;height:100%!important;border:0;border-radius:0;z-index:2000!important}.window.min{display:none}.titlebar{padding-top:env(safe-area-inset-top);height:53px}.content{padding:20px}.today-grid{grid-template-columns:1fr}.launcher{left:11px;bottom:76px;width:calc(100% - 22px)}.calendar{right:11px;bottom:76px}.museum,.insights,.profile-grid{grid-template-columns:1fr 1fr}.profile-grid{gap:15px}.formrow{grid-template-columns:1fr}.boot{font-size:10px}} -
LIFE OSINITIALIZING PERSONAL SYSTEM...
LIFE OS / PERSONAL DESKTOP
+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'||![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)} + diff --git a/public/locales/en.json b/public/locales/en.json new file mode 100644 index 0000000..fffd6a1 --- /dev/null +++ b/public/locales/en.json @@ -0,0 +1,478 @@ +{ + "app": { + "today": "TODAY", + "timeline": "TIMELINE", + "museum": "MUSEUM", + "insights": "INSIGHTS", + "profile": "PROFILE", + "tasks": "TASKS", + "calendar": "CALENDAR", + "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", + "archiveDate": "SAVE MEMORY", + "updateDate": "UPDATE MEMORY", + "backToToday": "Back to today", + "weather": "TODAY'S WEATHER", + "energy": "ENERGY LEVEL", + "film": "DAILY PROMPT", + "archiving": "ARCHIVING MEMORY...", + "archived": "MEMORY ARCHIVED" + }, + "weather": { + "clear": "Clear", + "light": "Light", + "cloudy": "Cloudy", + "rain": "Rain", + "storm": "Storm" + }, + "timeline": { + "eyebrow": "MEMORY ARCHIVE", + "heading": "{count} OF {total} MEMORIES", + "search": "Search memories", + "searchPlaceholder": "Search notes, tags, places, tasks…", + "filterTag": "Filter by tag", + "allTags": "ALL TAGS", + "noMatches": "No memories match these filters.", + "favorite": "FAVORITE", + "emptyTitle": "No memories yet.", + "emptyBody": "Open Today when you are ready to keep one.", + "detail": "MEMORY DETAIL", + "untitled": "Untitled memory", + "noNote": "No written note.", + "editing": "EDITING AS TODAY — SAVE TO UPDATE", + "preserved": "FAVORITED", + "addMuseum": "ADD TO MUSEUM", + "confirmDelete": "CONFIRM DELETE", + "remove": "Remove this memory?", + "irreversible": "This cannot be undone.", + "deleteMemory": "DELETE MEMORY", + "removed": "MEMORY REMOVED", + "added": "MEMORY FAVORITED", + "unpreserved": "REMOVED FROM FAVORITES" + }, + "memory": { + "tags": "TAGS", + "tagsPlaceholder": "Separate tags with commas", + "place": "PLACE / SCENE (OPTIONAL)", + "placePlaceholder": "Where or what was the setting?", + "linkTask": "RELATED TASK (OPTIONAL)", + "noTask": "NO RELATED TASK", + "relatedTask": "Related task", + "favorite": "ADD TO FAVORITES", + "edit": "EDIT MEMORY DETAILS", + "note": "NOTE", + "title": "TITLE (OPTIONAL)", + "titlePlaceholder": "Give this memory a title" + }, + "museum": { + "eyebrow": "MUSEUM", + "title": "Preserved moments", + "subtitle": "Worth keeping for the long view.", + "empty": "A museum begins with one preserved moment.", + "preserved": "STATUS: PRESERVED", + "collection": "COLLECTION" + }, + "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" + }, + "storageSummary": "{memories} memories · {museum} museum items · {tasks} tasks · Approx. {size} KB", + "release": "Life OS
Version {version} · Backup schema v2 · D1 schema v2", + "languageEnglish": "ENGLISH", + "languageChinese": "中文" + }, + "dialog": { + "backup": "LIFE OS BACKUP", + "ready": "Ready to restore", + "exported": "Exported: {date}", + "memories": "Memories: {count}", + "museumItems": "Museum Items: {count}", + "goals": "Goals: {count}", + "tasks": "Tasks: {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", + "openMemory": "Open memory: {title}" + }, + "calendar": { + "eyebrow": "MEMORIES & MILESTONES", + "month": "MONTH", + "day": "DAY", + "today": "TODAY", + "mon": "MON", + "tue": "TUE", + "wed": "WED", + "thu": "THU", + "fri": "FRI", + "sat": "SAT", + "sun": "SUN", + "previous": "Previous month", + "next": "Next month", + "previousDay": "Previous day", + "nextDay": "Next day", + "dayAtGlance": "THAT DAY", + "memory": "MEMORY", + "task": "TASK", + "goal": "GOAL", + "items": "items", + "taskDue": "TASK DUE", + "taskDone": "TASK COMPLETED", + "goalCreated": "GOAL CREATED", + "goalTarget": "GOAL TARGET DATE", + "goalDone": "GOAL COMPLETED", + "emptyDay": "No memories or milestones recorded for this day.", + "openToday": "Write a memory", + "writeMemory": "Add memory", + "openTasks": "Open tasks" + }, + "tasks": { + "eyebrow": "GOALS & TASKS", + "title": "Make room for what matters.", + "subtitle": "A small, steady plan for today and the days ahead.", + "addGoal": "NEW GOAL", + "addTask": "ADD TASK", + "taskPlaceholder": "What needs doing?", + "goalPlaceholder": "What do you want to move toward?", + "goalDescription": "A note about this goal (optional)", + "finishedGoals": "COMPLETED GOALS", + "goalComplete": "COMPLETE GOAL", + "goalReopen": "REOPEN GOAL", + "all": "ALL", + "today": "TODAY", + "upcoming": "UPCOMING", + "done": "DONE", + "activeGoals": "ACTIVE GOALS", + "noGoals": "Start with one goal worth moving toward.", + "noTasks": "Nothing on this list yet.", + "due": "DUE {date}", + "noDate": "NO DUE DATE", + "priority": { + "low": "LOW", + "normal": "NORMAL", + "high": "HIGH" + }, + "goal": "GOAL", + "anyGoal": "NO GOAL", + "save": "SAVE", + "cancel": "CANCEL", + "complete": "MARK COMPLETE", + "reopen": "REOPEN", + "delete": "DELETE", + "goalTitle": "New goal", + "taskTitle": "New task", + "goalCreated": "GOAL CREATED", + "taskAdded": "TASK ADDED", + "taskRemoved": "TASK REMOVED", + "goalRemoved": "GOAL REMOVED", + "confirmGoalDelete": "Delete this goal?", + "confirmGoalDeleteBody": "Tasks linked to it will be kept without a goal.", + "target": "TARGET DATE", + "completed": "COMPLETED", + "progress": "{done} OF {total} DONE", + "desktopShow": "SHOW COUNTDOWN", + "desktopHide": "HIDE COUNTDOWN", + "countdown": "GOAL COUNTDOWN", + "daysLeft": "{count} days left", + "todayTarget": "Target is today", + "daysOverdue": "{count} days overdue", + "pinOnCreate": "Show countdown on desktop", + "todayHeading": "Focus for today", + "openCount": "{count} OPEN TASKS", + "overdue": "OVERDUE" + }, + "shell": { + "pageTitle": "Life OS", + "brand": "LIFE OS / PERSONAL DESKTOP", + "initializing": "INITIALIZING PERSONAL SYSTEM…", + "life": "LIFE", + "morning": "GOOD MORNING", + "afternoon": "GOOD AFTERNOON", + "evening": "GOOD EVENING", + "systemOnline": "SYSTEM ONLINE · LIFE OS v{version}", + "systemDate": "SYSTEM DATE", + "open": "Open", + "close": "Close", + "minimize": "Minimize", + "maximize": "Maximize", + "restore": "Restore window", + "openApp": "Open {app}", + "refresh": "Refresh desktop", + "newToday": "New today entry", + "systemSettings": "System settings", + "doubleClickMaximize": "Double-click to maximize", + "wallpaperTitle": "Bing daily wallpaper: {title}", + "dailyImage": "Daily image", + "todayMemories": "{memories} memories · {preserved} preserved", + "memoryCount": "{count} memories", + "menuCloseWindow": "Close window", + "todaySummary": "{memories} memories · {preserved} preserved" + }, + "wallpaper": { + "dailyImage": "Daily image" + }, + "prompt": { + "films": [ + { + "title": "A Quiet Tuesday", + "subtitle": "Drama · Slice of Life" + }, + { + "title": "The Afternoon That Stayed", + "subtitle": "Independent · Warm" + }, + { + "title": "Small Light, Long Hallway", + "subtitle": "Contemplative · Drama" + }, + { + "title": "Before the Notifications", + "subtitle": "Minimal · Coming of Age" + }, + { + "title": "Room With Open Curtains", + "subtitle": "Slice of Life · Calm" + }, + { + "title": "The Usual Way Home", + "subtitle": "Drama · Familiar" + }, + { + "title": "Notes Left Unfinished", + "subtitle": "Reflective · Short Film" + }, + { + "title": "A Weather of Thoughts", + "subtitle": "Quiet · Personal" + }, + { + "title": "The Day Between Things", + "subtitle": "Indie · Observational" + }, + { + "title": "Slow Hours, Soft Edges", + "subtitle": "Slice of Life · Dream" + }, + { + "title": "A Window Still Lit", + "subtitle": "Drama · Evening" + }, + { + "title": "Ordinary, Carefully Held", + "subtitle": "Human · Gentle" + }, + { + "title": "One More Page Tonight", + "subtitle": "Study · Slow Burn" + }, + { + "title": "The Space After Lunch", + "subtitle": "Memory · Calm" + }, + { + "title": "Late Bus, Clear Mind", + "subtitle": "Urban · Reflective" + }, + { + "title": "Little Things in Focus", + "subtitle": "Documentary · Life" + }, + { + "title": "What the Room Remembered", + "subtitle": "Poetic · Still" + }, + { + "title": "Blue Hour, No Rush", + "subtitle": "Evening · Quiet" + }, + { + "title": "The Map of a Small Day", + "subtitle": "Journey · Personal" + }, + { + "title": "Somewhere After Four", + "subtitle": "Indie · Warm" + }, + { + "title": "A Day With No Headline", + "subtitle": "Slice of Life · Soft" + }, + { + "title": "The Sound of Taking Time", + "subtitle": "Slow Cinema · Personal" + }, + { + "title": "Things That Did Not Need Fixing", + "subtitle": "Drama · Gentle" + }, + { + "title": "Halfway Through September", + "subtitle": "Memory · Youth" + }, + { + "title": "A Small Archive of Us", + "subtitle": "Personal · Quiet" + }, + { + "title": "The Night Kept Its Shape", + "subtitle": "Noir · Calm" + }, + { + "title": "Unscheduled Sunshine", + "subtitle": "Light · Personal" + }, + { + "title": "The Last Open Tab", + "subtitle": "Modern · Reflective" + }, + { + "title": "A Place for Today", + "subtitle": "Drama · Human" + }, + { + "title": "Nothing Lost to the Day", + "subtitle": "Slice of Life · Hopeful" + } + ], + "lines": [ + "Nothing remarkable happened. Maybe that was the point.", + "A small day, held carefully enough to remember.", + "The ordinary parts stayed longer than expected.", + "Not every chapter needs a dramatic ending.", + "Some thoughts arrived; some were allowed to pass.", + "A quiet record of being here.", + "The day moved at its own reasonable pace.", + "There was room for a little more softness.", + "Even the unfinished things belonged here.", + "The light changed, and so did the mood.", + "A scene worth keeping, without needing an explanation.", + "It was enough to notice the day as it was.", + "Time did not rush; neither did this memory.", + "The details were small, but they were real.", + "A pause became part of the story.", + "Somewhere, this became a day to keep.", + "The familiar became briefly luminous.", + "Nothing needed to be solved before nightfall.", + "A few moments stayed in focus.", + "The story was quiet, but it was still yours.", + "Today left a small mark.", + "A gentle frame around an ordinary hour.", + "The day asked for attention, not perfection.", + "This memory can rest here for now.", + "The world was loud; this page was not.", + "A few loose threads, honestly kept.", + "The simplest version of today was enough.", + "The day settled before it ended.", + "There was meaning in the in-between.", + "A private moment, safely archived." + ] + }, + "login": { + "title": "Your life,
in sync.", + "description": "Sign in with ChatGPT to open your personal Life OS and keep your memories safely synced across devices.", + "continue": "CONTINUE WITH CHATGPT", + "footnote": "PERSONAL SYSTEM · CLOUD SYNC" + } +} diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json new file mode 100644 index 0000000..d1784aa --- /dev/null +++ b/public/locales/zh-CN.json @@ -0,0 +1,478 @@ +{ + "app": { + "today": "今日", + "timeline": "时间线", + "museum": "人生博物馆", + "insights": "洞察", + "profile": "个人资料", + "tasks": "目标与待办", + "calendar": "日历", + "system": "系统" + }, + "common": { + "save": "保存", + "cancel": "取消", + "edit": "编辑", + "delete": "删除", + "close": "关闭", + "open": "打开", + "minimize": "最小化", + "maximize": "最大化", + "restore": "恢复窗口", + "energy": "能量", + "days": "天", + "never": "从未" + }, + "today": { + "prompt": "今天过得怎么样?", + "placeholder": "写下今天的片段…", + "archive": "归档今日", + "update": "更新记忆", + "archiveDate": "保存记忆", + "updateDate": "更新记忆", + "backToToday": "返回今天", + "weather": "今日天气", + "energy": "能量等级", + "film": "今日灵感", + "archiving": "正在归档…", + "archived": "记忆已归档" + }, + "weather": { + "clear": "晴朗", + "light": "微光", + "cloudy": "多云", + "rain": "雨天", + "storm": "风暴" + }, + "timeline": { + "eyebrow": "记忆档案", + "heading": "{count} / {total} 条记忆", + "search": "搜索记忆", + "searchPlaceholder": "搜索记录、标签、地点或待办…", + "filterTag": "按标签筛选", + "allTags": "全部标签", + "noMatches": "没有符合筛选条件的记忆。", + "favorite": "已收藏", + "emptyTitle": "还没有记忆。", + "emptyBody": "准备好记录时,打开“今日”。", + "detail": "记忆详情", + "untitled": "当天记录", + "noNote": "尚未写下记录。", + "editing": "正在以今日记录编辑 — 保存即可更新", + "preserved": "已收藏", + "addMuseum": "加入收藏", + "confirmDelete": "确认删除", + "remove": "删除这条记忆?", + "irreversible": "此操作无法撤销。", + "deleteMemory": "删除记忆", + "removed": "记忆已删除", + "added": "记忆已收藏", + "unpreserved": "已取消收藏" + }, + "memory": { + "tags": "标签", + "tagsPlaceholder": "多个标签请用逗号分隔", + "place": "地点 / 场景(选填)", + "placePlaceholder": "在哪里,或当时是什么场景?", + "linkTask": "关联待办(选填)", + "noTask": "不关联待办", + "relatedTask": "关联待办", + "favorite": "加入收藏", + "edit": "编辑记忆详情", + "note": "记录内容", + "title": "标题(选填)", + "titlePlaceholder": "为这条记忆取个标题" + }, + "museum": { + "eyebrow": "人生博物馆", + "title": "被珍藏的瞬间", + "subtitle": "值得留给漫长回望的片段。", + "empty": "从一份被珍藏的记忆开始。", + "preserved": "状态:已珍藏", + "collection": "收藏" + }, + "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": "跟随系统" + }, + "storageSummary": "{memories} 条记忆 · {museum} 件博物馆藏品 · {tasks} 项待办 · 约 {size} KB", + "release": "Life OS
版本 {version} · 备份格式 v2 · 数据库结构 v2", + "languageEnglish": "ENGLISH", + "languageChinese": "中文" + }, + "dialog": { + "backup": "Life OS 备份", + "ready": "准备恢复", + "exported": "导出时间:{date}", + "memories": "记忆:{count}", + "museumItems": "博物馆藏品:{count}", + "goals": "目标:{count}", + "tasks": "待办:{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": "云端存储暂不可用——已使用本地模式", + "openMemory": "打开记忆:{title}" + }, + "calendar": { + "eyebrow": "记忆与生活节点", + "month": "月", + "day": "日", + "today": "今天", + "mon": "一", + "tue": "二", + "wed": "三", + "thu": "四", + "fri": "五", + "sat": "六", + "sun": "日", + "previous": "上个月", + "next": "下个月", + "previousDay": "前一天", + "nextDay": "后一天", + "dayAtGlance": "这一天", + "memory": "记忆", + "task": "待办", + "goal": "目标", + "items": "项", + "taskDue": "待办截止", + "taskDone": "完成待办", + "goalCreated": "创建目标", + "goalTarget": "目标日期", + "goalDone": "完成目标", + "emptyDay": "这一天还没有记忆或目标节点。", + "openToday": "写下一段记忆", + "writeMemory": "新增记忆", + "openTasks": "打开待办" + }, + "tasks": { + "eyebrow": "目标与待办", + "title": "把心力留给重要的事。", + "subtitle": "为今天和接下来的日子,排一个轻量、踏实的计划。", + "addGoal": "新建目标", + "addTask": "添加待办", + "taskPlaceholder": "接下来要做什么?", + "goalPlaceholder": "你想朝什么方向前进?", + "goalDescription": "关于这个目标的备注(选填)", + "finishedGoals": "已完成的目标", + "goalComplete": "完成目标", + "goalReopen": "重新开启目标", + "all": "全部", + "today": "今天", + "upcoming": "即将开始", + "done": "已完成", + "activeGoals": "进行中的目标", + "noGoals": "从一个值得继续靠近的目标开始。", + "noTasks": "这里暂时没有待办。", + "due": "截止 {date}", + "noDate": "无截止日期", + "priority": { + "low": "低", + "normal": "普通", + "high": "高" + }, + "goal": "目标", + "anyGoal": "不关联目标", + "save": "保存", + "cancel": "取消", + "goalTitle": "新建目标", + "taskTitle": "新建待办", + "goalCreated": "目标已创建", + "taskAdded": "待办已添加", + "taskRemoved": "待办已删除", + "goalRemoved": "目标已删除", + "confirmGoalDelete": "删除这个目标?", + "confirmGoalDeleteBody": "关联的待办会保留,但不再关联此目标。", + "target": "目标日期", + "completed": "已完成", + "complete": "标记完成", + "reopen": "重新打开", + "delete": "删除", + "progress": "已完成 {done} / {total}", + "desktopShow": "显示倒计时", + "desktopHide": "隐藏倒计时", + "countdown": "目标倒计时", + "daysLeft": "还有 {count} 天", + "todayTarget": "目标就在今天", + "daysOverdue": "已过期 {count} 天", + "pinOnCreate": "在桌面显示倒计时", + "todayHeading": "今天要推进的事", + "openCount": "还有 {count} 项待办", + "overdue": "已逾期" + }, + "shell": { + "pageTitle": "Life OS / 人生操作系统", + "brand": "LIFE OS / 人生操作系统", + "initializing": "正在初始化个人系统…", + "life": "LIFE", + "morning": "早上好", + "afternoon": "下午好", + "evening": "晚上好", + "systemOnline": "系统在线 · LIFE OS v{version}", + "systemDate": "系统日期", + "open": "打开", + "close": "关闭", + "minimize": "最小化", + "maximize": "最大化", + "restore": "恢复窗口", + "openApp": "打开{app}", + "refresh": "刷新桌面", + "newToday": "新建今日记录", + "systemSettings": "系统设置", + "doubleClickMaximize": "双击标题栏最大化", + "wallpaperTitle": "必应每日壁纸:{title}", + "dailyImage": "每日图片", + "todayMemories": "{memories} 条记忆 · {preserved} 件已珍藏", + "todaySummary": "{memories} 条记忆 · {preserved} 件已珍藏", + "memoryCount": "{count} 条记忆", + "menuCloseWindow": "关闭窗口" + }, + "wallpaper": { + "dailyImage": "每日图片" + }, + "login": { + "title": "你的生活,同步在线。", + "description": "登录 ChatGPT,打开你的个人 Life OS,并在不同设备间安全同步记忆。", + "continue": "使用 ChatGPT 继续", + "footnote": "个人系统 · 云端同步" + }, + "prompt": { + "films": [ + { + "title": "安静的星期二", + "subtitle": "剧情 · 日常" + }, + { + "title": "停留的午后", + "subtitle": "独立电影 · 温暖" + }, + { + "title": "长廊尽头的一束微光", + "subtitle": "沉思 · 剧情" + }, + { + "title": "通知响起之前", + "subtitle": "极简 · 成长" + }, + { + "title": "窗帘敞开的房间", + "subtitle": "日常 · 平静" + }, + { + "title": "回家的老路", + "subtitle": "剧情 · 熟悉" + }, + { + "title": "未写完的笔记", + "subtitle": "回望 · 短片" + }, + { + "title": "思绪的天气", + "subtitle": "安静 · 私人" + }, + { + "title": "日常之间", + "subtitle": "独立电影 · 观察" + }, + { + "title": "慢下来的时光", + "subtitle": "日常 · 梦境" + }, + { + "title": "仍亮着灯的窗", + "subtitle": "剧情 · 夜晚" + }, + { + "title": "认真收好的寻常", + "subtitle": "生活 · 温柔" + }, + { + "title": "今晚再读一页", + "subtitle": "学习 · 慢节奏" + }, + { + "title": "午饭之后的空隙", + "subtitle": "记忆 · 平静" + }, + { + "title": "末班车,清醒的心", + "subtitle": "城市 · 回望" + }, + { + "title": "聚焦的小事", + "subtitle": "纪录片 · 生活" + }, + { + "title": "房间记得的事", + "subtitle": "诗意 · 静止" + }, + { + "title": "蓝调时刻,不必匆忙", + "subtitle": "夜晚 · 安静" + }, + { + "title": "小小一天的地图", + "subtitle": "旅程 · 私人" + }, + { + "title": "四点之后的某处", + "subtitle": "独立电影 · 温暖" + }, + { + "title": "没有标题的一天", + "subtitle": "日常 · 轻柔" + }, + { + "title": "慢慢来时的声音", + "subtitle": "慢电影 · 私人" + }, + { + "title": "不必修好的那些事", + "subtitle": "剧情 · 温柔" + }, + { + "title": "九月过半", + "subtitle": "记忆 · 青春" + }, + { + "title": "我们的微型档案", + "subtitle": "私人 · 安静" + }, + { + "title": "夜晚仍有它的轮廓", + "subtitle": "黑色电影 · 平静" + }, + { + "title": "没有安排的阳光", + "subtitle": "明亮 · 私人" + }, + { + "title": "最后一个打开的标签页", + "subtitle": "现代 · 回望" + }, + { + "title": "今天的一个位置", + "subtitle": "剧情 · 人情" + }, + { + "title": "今天并未带走什么", + "subtitle": "日常 · 希望" + } + ], + "lines": [ + "没有发生什么特别的事。也许这正是它的意义。", + "平凡的一天,也被认真地留了下来。", + "寻常的片段比想象中停留得更久。", + "不是每一章都需要一个戏剧性的结尾。", + "有些念头来了,也有些念头慢慢散去。", + "一份安静的在场记录。", + "这一天按自己的节奏向前。", + "还可以再柔软一些。", + "那些未完成的部分,也属于这一天。", + "光线变了,心情也跟着变了。", + "有些画面值得留下,不必解释。", + "如实看见今天,就已经足够。", + "时间没有催促,这段记忆也没有。", + "细节虽小,却都是真实的。", + "停顿也成了故事的一部分。", + "不知不觉间,这成了一天值得珍藏的日子。", + "熟悉的事物忽然有了光。", + "夜幕降临前,不必解决所有问题。", + "有几个瞬间留在了焦点里。", + "这个故事很安静,但仍然属于你。", + "今天留下了一点痕迹。", + "给平凡时刻一个温柔的边框。", + "这一天需要的是留心,而非完美。", + "这段记忆可以先在这里歇一会儿。", + "世界很吵,但这一页没有。", + "还有几条松散的线索,诚实地留着。", + "今天最简单的样子,也已经足够。", + "这一天在结束前慢慢安定下来。", + "那些间隙里也有意义。", + "一段私人的时刻,已好好收存。" + ] + } +} diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index d1ec48c..c266ea1 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,15 +1,18 @@ { - "name": "Life OS · 人生操作系统", + "name": "Life OS", "short_name": "Life OS", - "description": "A private personal operating system for memories, reflection, and time.", "id": "/", "start_url": "/", "scope": "/", "display": "standalone", "background_color": "#101216", "theme_color": "#111317", - "lang": "zh-CN", "icons": [ - { "src": "/life-os-icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" } + { + "src": "/life-os-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } ] } diff --git a/public/service-worker.js b/public/service-worker.js index 92501b7..0c8b071 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,7 +1,7 @@ -const CACHE = 'life-os-shell-v1.5.1'; +const CACHE = 'life-os-shell-v1.5.2'; const SHELL = [ '/', '/index.html', '/manifest.webmanifest', '/life-os-icon.svg', - '/boot-guard.js', '/i18n.js', '/tasks.js?v=1.5.1', '/calendar.js?v=1.5.1', '/i18n-renderers.js', '/addons.js?v=1.5.1' + '/boot-guard.js', '/locales/en.js', '/locales/zh-CN.js', '/i18n.js', '/tasks.js?v=1.5.2', '/calendar.js?v=1.5.2', '/i18n-renderers.js', '/addons.js?v=1.5.2' ]; self.addEventListener('install', event => { diff --git a/scripts/build.mjs b/scripts/build.mjs index 0dbcded..4a5c043 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -7,16 +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, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, manifest, serviceWorker, icon] = 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("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg")]); -const release = "1.5.1"; -// The original single-file prototype carried an obsolete English-to-Chinese -// DOM replacement block. Runtime localisation now lives in i18n.js and -// i18n-renderers.js, so exclude that dead block from the deployed document. -const withoutLegacyI18n = rawIndexHtml - .replace(/const ZH=.*?function browserLanguage/s, 'function browserLanguage') - .replace(/function localizeDynamicText.*?function i18n\(key,fallback=key\)\{return lang\(\)===\'zh\'\?\(ZH\[key\]\|\|key\):fallback\}/s, 'function i18n(key,fallback=key){return fallback}'); -const indexHtml = withoutLegacyI18n.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}"`); -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 tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`); +const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, 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("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.5.2"; +const 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}`); +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(`window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales.en=${localeEn};`)};\nexport const localeZhJs=${JSON.stringify(`window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales['zh-CN']=${localeZh};`)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\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("../.openai/hosting.json", import.meta.url), new URL("hosting.json", meta)); console.log("Built Life OS Worker artifact"); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 9606a48..bf6a7c6 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -1,11 +1,44 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; const worker = await import(new URL("../dist/server/index.js", import.meta.url)); assert.equal(typeof worker.default?.fetch, "function"); const assets = await import(new URL("../dist/server/assets.js", import.meta.url)); assert.match(assets.indexHtml, /!\[1,2\]\.includes\(d\.schemaVersion\)/); +assert.doesNotMatch(assets.indexHtml, /localizeDynamicText|createTreeWalker|NodeFilter\.SHOW_TEXT|const ZH=/); +assert.match(assets.indexHtml, /locales\/en\.js/); assert.match(assets.addonsJs, /window\.exportData = exportData/); assert.match(assets.addonsJs, /schemaVersion: 2/); assert.match(assets.addonsJs, /settings: settings\(\), system: sys\(\)/); assert.match(assets.addonsJs, /if \(mergedLocalBoard\) await uploadCloudState\(\)/); assert.match(assets.i18nRenderersJs, /dialog\.goals/); -console.log("Life OS Worker artifact is valid"); +assert.match(assets.i18nRenderersJs, /shell\.wallpaperTitle/); +assert.match(assets.i18nRenderersJs, /window\.museumApp/); +assert.equal(assets.manifest.includes('"description"'), false); +const flatten = (value, prefix = "") => Object.entries(value).flatMap(([key, child]) => child && typeof child === "object" && !Array.isArray(child) ? flatten(child, prefix ? `${prefix}.${key}` : key) : [prefix ? `${prefix}.${key}` : key]).sort(); +const enKeys = flatten(assets.localeEn), zhKeys = flatten(assets.localeZh); +assert.deepEqual(enKeys, zhKeys, "English and Chinese locale keys must match"); +const resolveKey = (locale, key) => key.split(".").reduce((value, part) => value?.[part], locale); +for (const source of [assets.i18nRenderersJs, assets.addonsJs, assets.indexHtml]) { + for (const match of source.matchAll(/(?:\bt|LifeI18n\.t)\(['"`]([A-Za-z][\w.]+)['"`]/g)) { + assert.notEqual(resolveKey(assets.localeEn, match[1]), undefined, `Missing locale key: ${match[1]}`); + assert.notEqual(resolveKey(assets.localeZh, match[1]), undefined, `Missing Chinese locale key: ${match[1]}`); + } +} +for (const locale of [assets.localeEn, assets.localeZh]) { + assert.ok(locale.login.title && locale.login.description && locale.login.continue && locale.login.footnote); + assert.equal(locale.prompt.films.length, 30); + assert.equal(locale.prompt.lines.length, 30); +} +for (const script of [assets.tasksJs, assets.calendarJs]) { + assert.doesNotMatch(script, /createTreeWalker|NodeFilter\.SHOW_TEXT/); +} +const source = await readFile(new URL("../public/index.html", import.meta.url), "utf8"); +assert.doesNotMatch(source, /localizeDynamicText|createTreeWalker|NodeFilter\.SHOW_TEXT|const ZH=/); +const zhLogin = await worker.default.fetch(new Request("https://life-os.test/", { headers: { "accept-language": "zh-CN,zh;q=0.9" } }), {}); +assert.match(await zhLogin.text(), /登录 ChatGPT/); +const enLogin = await worker.default.fetch(new Request("https://life-os.test/", { headers: { "accept-language": "en-US,en;q=0.9" } }), {}); +assert.match(await enLogin.text(), /Sign in with ChatGPT/); +const localeResponse = await worker.default.fetch(new Request("https://life-os.test/locales/zh-CN.js"), {}); +assert.equal(localeResponse.status, 200); +assert.match(await localeResponse.text(), /window\.LifeOSLocales\['zh-CN'\]/); +console.log(`Life OS artifact valid · ${enKeys.length} bilingual locale keys`); diff --git a/worker/index.js b/worker/index.js index 30b5f0b..fa69c5c 100644 --- a/worker/index.js +++ b/worker/index.js @@ -1,4 +1,4 @@ -import { addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, indexHtml, manifest, serviceWorker, icon } from "./assets.js"; +import { addonsJs, bootGuardJs, i18nJs, localeEn, localeZh, localeEnJs, localeZhJs, tasksJs, calendarJs, i18nRenderersJs, indexHtml, manifest, serviceWorker, icon } 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"]; @@ -14,8 +14,11 @@ async function bingMetadata() { function headers(type, cache = "public, max-age=3600") { return { "content-type": type, "cache-control": cache }; } function userId(request) { return request.headers.get("oai-authenticated-user-id"); } -function loginPage() { - return `Life OS
LIFE OS

Your life,
in sync.

Sign in with ChatGPT to open your personal Life OS and keep your memories safely synced across devices.

CONTINUE WITH CHATGPT

PERSONAL SYSTEM · CLOUD SYNC

`; +function loginPage(request) { + const language = request.headers.get("accept-language")?.toLowerCase().startsWith("zh") ? localeZh : localeEn; + const text = language.login; + const escape = (value) => String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[char])); + 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)); } @@ -67,11 +70,13 @@ export default { if (url.pathname === "/life-os-icon.svg") return new Response(icon, { headers: headers("image/svg+xml", "public, max-age=86400") }); if (url.pathname === "/boot-guard.js") return new Response(bootGuardJs, { 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 === "/locales/en.js") return new Response(localeEnJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); + if (url.pathname === "/locales/zh-CN.js") return new Response(localeZhJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/tasks.js") return new Response(tasksJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/calendar.js") return new Response(calendarJs, { 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") }); + if (!id) return new Response(loginPage(request), { headers: headers("text/html; charset=utf-8", "no-cache") }); return new Response(indexHtml, { headers: headers("text/html; charset=utf-8", "no-cache") }); } return new Response("Not found", { status: 404 });