diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a302de..c11043f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # 更新日志 / Changelog -本项目按 `v主版本.次版本.修订版本` 标记发布。下列内容以对应 Git tag 和已提交功能为准。 +本项目按 `v主版本.次版本.修订版本` 标记应用版本;按需创建 Git tag。下列内容以对应提交和已发布功能为准。 + +## v1.17.0 — 2026-09-28 + +- 界面扩展至联合国六种工作语文,并加入日语和繁体中文(香港、澳门、台湾),覆盖设置、记忆、目标待办、日历、通知、数据库管理、终端及每日提示。 +- 自动语言识别按浏览器首选语言与地区代码选择;旧版简体中文偏好迁移到 `zh-CN`。新增阿拉伯语从右到左布局与各语种的日期、数字格式。 +- 按本次要求更新线上站点,不创建 Git tag。 ## v1.16.0 — 2026-09-28 diff --git a/README.md b/README.md index 976195e..e4ff8f7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 以桌面操作系统为交互方式的个人记录网站。用 App 记录日常、管理目标与待办,并沿时间线回看已经留下的内容。 -在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.16.0** · [更新日志](CHANGELOG.md) +在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.17.0** · [更新日志](CHANGELOG.md) ## 功能 @@ -22,7 +22,7 @@ 通知权限需手动授予,开关和时间仅保存在当前浏览器。网页打开时检查提醒;关闭网站后无法保证定时送达,如需关闭网页后的可靠提醒,需要另行接入推送服务。 -桌面端支持单击选择、双击打开 App、多窗口、任务栏恢复、图标拖动排列和窗口布局记忆。拖动图标或小部件时会显示吸附网格;目标倒计时、时钟、今日概览与快捷操作小部件可移动、调整大小和分别开关。移动端以横排图标和全屏 App 为主,小部件在桌面下方排列。可选必应每日图片、内置背景或自定义 HTTPS 图片链接。中文与英文可自动跟随浏览器语言,也可手动选择。 +桌面端支持单击选择、双击打开 App、多窗口、任务栏恢复、图标拖动排列和窗口布局记忆。拖动图标或小部件时会显示吸附网格;目标倒计时、时钟、今日概览与快捷操作小部件可移动、调整大小和分别开关。移动端以横排图标和全屏 App 为主,小部件在桌面下方排列。可选必应每日图片、内置背景或自定义 HTTPS 图片链接。界面支持英语、简体中文、繁体中文(香港、澳门、台湾)、阿拉伯语、法语、俄语、西班牙语和日语;可按浏览器首选语言自动选择,也可手动切换。阿拉伯语使用从右到左的页面方向。 快捷键:`Ctrl / ⌘ + K` 打开启动器,`Ctrl / ⌘ + F` 搜索记忆,`Ctrl / ⌘ + S` 保存今日记忆;`Alt + 1–9` 打开原有 App,`Alt + 0` 打开终端。终端内 ↑↓ 浏览命令历史,Tab 补全命令,记忆正文输入时 `Ctrl / ⌘ + Enter` 保存。站点包含 PWA 清单和 Service Worker,离线时可打开已缓存的应用外壳;云同步、外部网页与必应图片仍需要联网。 @@ -50,7 +50,7 @@ npm run validate npm run test:cloud ``` -`build` 将页面及 App 脚本打包成 `dist/server/` 中的 Cloudflare Worker 产物;`validate` 检查产物、双语键与主要交互;`test:cloud` 使用内存 SQLite 检查旧数据回填、版本冲突与租户隔离。生产托管配置在 `.openai/hosting.json`,含 Sites 项目 ID 和 D1 逻辑绑定。直接在其他环境部署时,需要提供兼容的 Worker 环境与 `DB` 绑定;云端 API 还依赖托管层提供的 `oai-authenticated-user-id` 请求头。 +`build` 将页面及 App 脚本打包成 `dist/server/` 中的 Cloudflare Worker 产物;`validate` 检查产物、十种语言的词条、占位符与主要交互;`test:cloud` 使用内存 SQLite 检查旧数据回填、版本冲突与租户隔离。生产托管配置在 `.openai/hosting.json`,含 Sites 项目 ID 和 D1 逻辑绑定。直接在其他环境部署时,需要提供兼容的 Worker 环境与 `DB` 绑定;云端 API 还依赖托管层提供的 `oai-authenticated-user-id` 请求头。 ## 项目结构 @@ -62,11 +62,11 @@ public/browser-app.js iframe 浏览器 public/terminal-app.js 命令解析与终端界面 public/db-admin.js 数据库管理器界面 public/data-actions.js 图形界面与终端共用的数据写入 -public/i18n-renderers.js 记忆、洞察、设置等双语界面 +public/i18n-renderers.js 记忆、洞察、设置等多语言界面 public/desktop-personalization.js 图标、窗口与壁纸偏好 public/desktop-widgets.js 吸附网格与桌面小部件 public/addons.js 导入导出、云同步与辅助操作 -public/locales/ 中文、英文文案 +public/locales/ 十种语言的界面文案 public/service-worker.js PWA 缓存 worker/index.js Worker 路由与必应图片代理 worker/cloud-state.js 云端记录迁移与同步 API diff --git a/package.json b/package.json index 39338d6..ad90550 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "life-os", - "version": "1.16.0", + "version": "1.17.0", "private": true, "type": "module", "scripts": { diff --git a/public/addons.js b/public/addons.js index 52904d6..03e0391 100644 --- a/public/addons.js +++ b/public/addons.js @@ -3,7 +3,7 @@ const DRAFT_KEY = 'lifeos_today_draft_v1'; const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; }; const escapeHtml = value => String(value || '').replace(/[&<>]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); - const APP_VERSION = '1.16.0'; + const APP_VERSION = '1.17.0'; window.LifeOSVersion = APP_VERSION; const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser', 'terminal', 'database']; const t = (key, values) => LifeI18n.t(key, values); diff --git a/public/calendar.js b/public/calendar.js index 23a0d47..cb17219 100644 --- a/public/calendar.js +++ b/public/calendar.js @@ -7,7 +7,7 @@ let selected = localKey(new Date()), month = new Date(`${selected}T12:00:00`), view = 'month'; const fromKey = key => new Date(`${key}T12:00:00`); const esc = value => String(value || '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); - const formatDate = (key, options = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) => new Intl.DateTimeFormat(lang() === 'zh' ? 'zh-CN' : 'en-US', options).format(fromKey(key)); + const formatDate = (key, options = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) => new Intl.DateTimeFormat(LifeI18n.locale(), options).format(fromKey(key)); const localDatePart = value => value ? localKey(new Date(value)) : ''; const system = () => { try { return JSON.parse(localStorage.getItem('lifeos_system') || '{}'); } catch { return {}; } }; @@ -55,7 +55,7 @@ } function render(container) { - const title = new Intl.DateTimeFormat(lang() === 'zh' ? 'zh-CN' : 'en-US', { month: 'long', year: 'numeric' }).format(month); + const title = new Intl.DateTimeFormat(LifeI18n.locale(), { month: 'long', year: 'numeric' }).format(month); const heading = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map((key, index) => `${t(key)}`).join(''); container.innerHTML = `
${t('eyebrow')}

${esc(title)}

${view === 'month' ? `
${heading}
${monthCells()}
${t('memory')}${t('task')}${t('goal')}
${selectedDayMarkup()}
` : `
${selectedDayMarkup()}
`}`; } diff --git a/public/i18n-renderers.js b/public/i18n-renderers.js index b873dfc..55b6d2b 100644 --- a/public/i18n-renderers.js +++ b/public/i18n-renderers.js @@ -1,7 +1,7 @@ /* 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 locale = () => LifeI18n.locale(); 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(); @@ -10,7 +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 localizedFilmField = (memory, field) => { const index = LifeI18n.supported.reduce((found, code) => found >= 0 ? found : LifeI18n.dictionaries[code]?.prompt.films.findIndex(film => film.title === memory.filmTitle), -1); return index < 0 ? (memory[field] || '') : (LifeI18n.dictionaries[lang()]?.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 => `
`; @@ -20,7 +20,7 @@ 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 = 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.applyLanguage = () => { document.documentElement.lang = LifeI18n.locale(); document.documentElement.dir = lang() === 'ar' ? 'rtl' : 'ltr'; 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.today = container => { const entryDate = todayTargetDate || day(), memory = mems().find(item => item.date === entryDate); @@ -109,8 +109,8 @@ }; window.saveProfile = () => { save('lifeos_profile', { name: pname.value, birthday: pbday.value, startDate: pstart.value || day(), motto: pmotto.value }); renderAll(); toast(t('profile.saved')); }; window.systemApp = container => { - const state = settings(), preference = LifeI18n.preference(), bytes = new Blob([JSON.stringify({ mems: mems(), museum: museum(), profile: profile(), system: sys() })]).size, oldBackup = state.lastBackup && Date.now() - new Date(state.lastBackup) > 12096e5, detected = browserLanguage() === 'zh' ? t('system.detectedZh') : t('system.detectedEn'); - container.innerHTML = `
${t('system.eyebrow')}

${t('system.title')}

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

${detected}

${window.LifeOSNotifications?.markup() || ''}
${t('system.data')}
${t('system.storage')}

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

${t('system.dataStorage')}

${t('system.stored')}
${t('system.backups')}

${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `
${t('system.recommended')}` : ''}

${t('system.version')}

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

`; + const state = settings(), preference = LifeI18n.preference(), bytes = new Blob([JSON.stringify({ mems: mems(), museum: museum(), profile: profile(), system: sys() })]).size, oldBackup = state.lastBackup && Date.now() - new Date(state.lastBackup) > 12096e5, detected = t('system.browserDetected', { name: LifeI18n.names[LifeI18n.browserLanguage()] }); + container.innerHTML = `
${t('system.eyebrow')}

${t('system.title')}

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

${detected}

${LifeI18n.supported.map(code => ``).join('')}
${window.LifeOSNotifications?.markup() || ''}
${t('system.data')}
${t('system.storage')}

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

${t('system.dataStorage')}

${t('system.stored')}
${t('system.backups')}

${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `
${t('system.recommended')}` : ''}

${t('system.version')}

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

`; }; 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 cc852a0..4ffad76 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -1,53 +1,57 @@ /* Translation lookup and locale-aware formatting. Copy lives in /locales/*.json. */ (() => { const dictionaries = window.LifeOSLocales || {}; - const en = dictionaries.en || {}; - const zh = dictionaries['zh-CN'] || {}; + const supported = ['en', 'zh-CN', 'zh-HK', 'zh-MO', 'zh-TW', 'ar', 'fr', 'ru', 'es', 'ja']; + const names = { en: 'English', 'zh-CN': '简体中文', 'zh-HK': '繁體中文(香港)', 'zh-MO': '繁體中文(澳門)', 'zh-TW': '繁體中文(臺灣)', ar: 'العربية', fr: 'Français', ru: 'Русский', es: 'Español', ja: '日本語' }; const PREFERENCE_KEY = 'lifeos_language_preference_v1'; - const validPreference = value => ['auto', 'en', 'zh'].includes(value); + const normalize = value => { + const code = String(value || '').replace(/_/g, '-').toLowerCase(); + if (code === 'zh' || code.startsWith('zh-hans') || code.startsWith('zh-cn') || code.startsWith('zh-sg')) return 'zh-CN'; + if (code.startsWith('zh-hk') || code.startsWith('zh-hant-hk')) return 'zh-HK'; + if (code.startsWith('zh-mo') || code.startsWith('zh-hant-mo')) return 'zh-MO'; + if (code.startsWith('zh-tw') || code.startsWith('zh-hant')) return 'zh-TW'; + return supported.find(item => item === code || code.startsWith(item + '-')) || null; + }; + const validPreference = value => value === 'auto' || Boolean(normalize(value)); const lookup = (object, path) => path.split('.').reduce((value, part) => value?.[part], object); const detectBrowserLanguage = () => { const preferences = navigator.languages?.length ? navigator.languages : [navigator.language || navigator.userLanguage || 'en']; - return preferences.some(value => String(value).toLowerCase().startsWith('zh')) ? 'zh' : 'en'; + return preferences.map(normalize).find(Boolean) || 'en'; }; const readPreference = () => { try { const saved = localStorage.getItem(PREFERENCE_KEY); - if (validPreference(saved)) return saved; + if (validPreference(saved)) return saved === 'auto' ? 'auto' : normalize(saved); let oldSettings = {}; try { oldSettings = JSON.parse(localStorage.getItem('lifeos_settings') || '{}'); } catch {} const legacyMode = oldSettings.languageMode ?? (['en', 'zh'].includes(oldSettings.language) ? 'manual' : 'auto'); - const migrated = legacyMode === 'manual' && ['en', 'zh'].includes(oldSettings.language) ? oldSettings.language : 'auto'; + const migrated = legacyMode === 'manual' && normalize(oldSettings.language) ? normalize(oldSettings.language) : 'auto'; localStorage.setItem(PREFERENCE_KEY, migrated); return migrated; } catch { return 'auto'; } }; let preference = readPreference(); const language = () => preference === 'auto' ? detectBrowserLanguage() : preference; - function t(key, values = {}) { - const selected = language() === 'zh' ? zh : en; - const template = lookup(selected, key) ?? lookup(en, key) ?? key; + const template = lookup(dictionaries[language()], key) ?? lookup(dictionaries.en, key) ?? key; return String(template).replace(/\{(\w+)\}/g, (_, name) => values[name] ?? `{${name}}`); } - - function locale() { return language() === 'zh' ? 'zh-CN' : 'en-US'; } + const locale = () => language() === 'en' ? 'en-US' : language(); 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); } + const number = (value, options) => new Intl.NumberFormat(locale(), options).format(value); function setLanguage(value) { if (!validPreference(value)) return; - preference = value; - try { localStorage.setItem(PREFERENCE_KEY, value); } catch {} + preference = value === 'auto' ? 'auto' : normalize(value); + try { localStorage.setItem(PREFERENCE_KEY, preference); } catch {} window.renderIcons?.(); window.renderAll?.(); window.applyLanguage?.(); - window.toast?.(value === 'auto' ? (language() === 'zh' ? 'feedback.followingZh' : 'feedback.followingEn') : (value === 'zh' ? 'feedback.languageZh' : 'feedback.languageEn')); + window.toast?.(preference === 'auto' ? t('feedback.followingBrowser', { name: names[language()] }) : t('feedback.languageSelected', { name: names[language()] })); } - window.lang = language; window.setLanguage = setLanguage; - window.LifeI18n = { t, date, number, locale, language, preference: () => preference, dictionaries: { en, zh } }; + window.LifeI18n = { t, date, number, locale, language, preference: () => preference, browserLanguage: detectBrowserLanguage, names, supported, dictionaries: { ...dictionaries, zh: dictionaries['zh-CN'] } }; })(); diff --git a/public/index.html b/public/index.html index 126ddbb..cdacb57 100644 --- a/public/index.html +++ b/public/index.html @@ -10,19 +10,19 @@ document.head.insertAdjacentHTML('beforeend','
LIFE OS

${text.title}

${escape(text.description)}

${escape(text.continue)}

${escape(text.footnote)}

`; + return `Life OS
LIFE OS

${text.title}

${escape(text.description)}

${escape(text.continue)}

${escape(text.footnote)}

`; } export default { @@ -47,6 +55,8 @@ export default { 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") }); + const extraLocale = /^\/locales\/(zh-HK|zh-MO|zh-TW|ar|fr|ru|es|ja)\.js$/.exec(url.pathname)?.[1]; + if (extraLocale) return new Response(localeScripts[extraLocale], { 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 === "/data-actions.js") return new Response(dataActionsJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") });