From 8a1dee3c49537608238d46439e13ae08934fe7c1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 28 Sep 2026 12:07:57 +0900 Subject: [PATCH] feat(terminal): add Life OS command interface --- CHANGELOG.md | 6 ++ README.md | 9 ++- package.json | 2 +- public/addons.js | 6 +- public/data-actions.js | 50 +++++++++++++++ public/i18n-renderers.js | 10 +-- public/index.html | 12 ++-- public/locales/en.json | 34 +++++++++- public/locales/zh-CN.json | 34 +++++++++- public/service-worker.js | 2 +- public/tasks.js | 10 ++- public/terminal-app.js | 132 ++++++++++++++++++++++++++++++++++++++ scripts/build.mjs | 8 +-- scripts/validate.mjs | 36 ++++++++++- worker/index.js | 4 +- 15 files changed, 321 insertions(+), 34 deletions(-) create mode 100644 public/data-actions.js create mode 100644 public/terminal-app.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 380d0a5..b4a50f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 本项目按 `v主版本.次版本.修订版本` 标记发布。下列内容以对应 Git tag 和已提交功能为准。 +## v1.10.0 — 2026-09-28 + +- 增加交互式终端 App:创建和查询记忆、目标与待办,完成或重新开启待办,并打开其他 App。 +- 支持带引号的标题、选项、命令历史、Tab 补全与多行记忆输入;终端提示提供中文和英文。 +- 记忆、目标与待办的图形表单与终端共用数据写入入口,沿用现有云同步和备份结构。 + ## v1.9.0 — 2026-09-28 - 增加轻量浏览器 App,默认在 iframe 中打开 Google,支持地址栏搜索、后退、前进与刷新。 diff --git a/README.md b/README.md index 9bdcdb2..eca1afd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 以桌面操作系统为交互方式的个人记录网站。用 App 记录日常、管理目标与待办,并沿时间线回看已经留下的内容。 -在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.9.0** · [更新日志](CHANGELOG.md) +在线站点:[Life OS](https://life-os-personal-desktop.biss0.chatgpt.site) · 当前应用版本:**v1.10.0** · [更新日志](CHANGELOG.md) ## 功能 @@ -16,11 +16,14 @@ | 洞察 | 周报和月报展示记录频率、完成任务数、常见天气、能量记录及收藏或标记高光的记忆;仅依据已保存的数据统计。 | | 档案 | 查看记录概况并维护个人资料。 | | 浏览器 | 在桌面窗口里打开网页或使用 Google 搜索,支持后退、前进、刷新和外部打开。默认打开 Google;第三方网站可能禁止 iframe 嵌入。 | +| 终端 | 用交互式命令创建、查询记忆、目标与待办,完成或重新开启待办,并打开其他 App。 | | 系统设置 | 选择语言、主题、壁纸、桌面快捷方式和开机 App,管理备份。 | 桌面端支持单击选择、双击打开 App、多窗口、任务栏恢复、图标拖动排列和窗口布局记忆。拖动图标或小部件时会显示吸附网格;目标倒计时、时钟、今日概览与快捷操作小部件可移动、调整大小和分别开关。移动端以横排图标和全屏 App 为主,小部件在桌面下方排列。可选必应每日图片、内置背景或自定义 HTTPS 图片链接。中文与英文可自动跟随浏览器语言,也可手动选择。 -快捷键:`Ctrl / ⌘ + K` 打开启动器,`Ctrl / ⌘ + F` 搜索记忆,`Ctrl / ⌘ + S` 保存今日记忆;`Alt + 1–9` 打开对应 App。站点包含 PWA 清单和 Service Worker,离线时可打开已缓存的应用外壳;云同步、外部网页与必应图片仍需要联网。 +快捷键:`Ctrl / ⌘ + K` 打开启动器,`Ctrl / ⌘ + F` 搜索记忆,`Ctrl / ⌘ + S` 保存今日记忆;`Alt + 1–9` 打开原有 App,`Alt + 0` 打开终端。终端内 ↑↓ 浏览命令历史,Tab 补全命令,记忆正文输入时 `Ctrl / ⌘ + Enter` 保存。站点包含 PWA 清单和 Service Worker,离线时可打开已缓存的应用外壳;云同步、外部网页与必应图片仍需要联网。 + +终端输入 `help` 查看命令。例如 `memory add "实验记录" --date 2026-09-28 --tag 学习` 会进入正文输入模式,`task add "背单词" --due tomorrow --priority high` 新增待办,`goal add "准备六级" --due 2026-12-12 --pin` 新增并在桌面显示倒计时。命令只执行明确支持的 Life OS 操作,不是系统 shell;同一天已有记忆时,终端会提示在“今日”或“时间线”编辑。 ## 数据与备份 @@ -48,6 +51,8 @@ public/index.html 桌面框架与窗口基础交互 public/tasks.js 目标与待办 public/calendar.js 日历 public/browser-app.js iframe 浏览器 +public/terminal-app.js 命令解析与终端界面 +public/data-actions.js 图形界面与终端共用的数据写入 public/i18n-renderers.js 记忆、洞察、设置等双语界面 public/desktop-personalization.js 图标、窗口与壁纸偏好 public/desktop-widgets.js 吸附网格与桌面小部件 diff --git a/package.json b/package.json index f0a93b5..62dd2f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "life-os", - "version": "1.9.0", + "version": "1.10.0", "private": true, "type": "module", "scripts": { diff --git a/public/addons.js b/public/addons.js index f56b0a8..6ea8f8a 100644 --- a/public/addons.js +++ b/public/addons.js @@ -3,9 +3,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.9.0'; + const APP_VERSION = '1.10.0'; window.LifeOSVersion = APP_VERSION; - const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser']; + const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser', 'terminal']; const t = (key, values) => LifeI18n.t(key, values); function readDraft() { @@ -171,7 +171,7 @@ if (mod && e.key.toLowerCase() === 'k') { e.preventDefault(); toggleLauncher(); } if (mod && e.key.toLowerCase() === 'f') { e.preventDefault(); openSearch(); } if (mod && e.key.toLowerCase() === 's' && document.querySelector('#entry')) { e.preventDefault(); archiveToday(); } - if (e.altKey && /^[1-9]$/.test(e.key)) { e.preventDefault(); openApp(appIds[Number(e.key) - 1]); } + if (e.altKey && /^[0-9]$/.test(e.key)) { e.preventDefault(); openApp(appIds[e.key === '0' ? 9 : Number(e.key) - 1]); } if (e.key === 'Escape') { closeSearch(); document.querySelector('#launcher')?.classList.add('hidden'); document.querySelector('#calendar')?.classList.add('hidden'); document.querySelector('#life-context')?.remove(); } }); })(); diff --git a/public/data-actions.js b/public/data-actions.js new file mode 100644 index 0000000..f3c8083 --- /dev/null +++ b/public/data-actions.js @@ -0,0 +1,50 @@ +/* Shared record writes for forms and the Life OS terminal. */ +(() => { + const board = () => { + const current = sys().tasksBoard; + return current && Array.isArray(current.goals) && Array.isArray(current.tasks) ? current : { goals: [], tasks: [] }; + }; + const validDate = value => !value || (/^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T12:00:00`)) && new Date(`${value}T12:00:00`).getFullYear() === Number(value.slice(0, 4)) && new Date(`${value}T12:00:00`).getMonth() + 1 === Number(value.slice(5, 7)) && new Date(`${value}T12:00:00`).getDate() === Number(value.slice(8))); + const required = (value, max) => { + const result = String(value || '').trim(); + if (!result || result.length > max) throw new Error('invalidTitle'); + return result; + }; + const persistBoard = data => { const state = sys(); state.tasksBoard = data; save('lifeos_system', state); renderAll(); }; + window.LifeOSActions = { + board, + createGoal({ title, description = '', targetDate = '', showOnDesktop = false }) { + title = required(title, 100); + if (!validDate(targetDate)) throw new Error('invalidDate'); + const goal = { id: `goal_${crypto.randomUUID()}`, title, description: String(description).trim().slice(0, 240), targetDate, showOnDesktop: Boolean(targetDate && showOnDesktop), done: false, createdAt: new Date().toISOString() }; + const data = board(); data.goals.unshift(goal); persistBoard(data); return goal; + }, + createTask({ title, dueDate = '', goalId = '', priority = 'normal' }) { + title = required(title, 140); + if (!validDate(dueDate)) throw new Error('invalidDate'); + if (!['normal', 'high', 'low'].includes(priority)) throw new Error('invalidPriority'); + const data = board(); + if (goalId && !data.goals.some(goal => goal.id === goalId)) throw new Error('notFound'); + const task = { id: `task_${crypto.randomUUID()}`, title, dueDate, goalId, priority, done: false, createdAt: new Date().toISOString() }; + data.tasks.push(task); persistBoard(data); return task; + }, + setTaskDone(id, done) { + const data = board(), task = data.tasks.find(item => item.id === id); + if (!task) throw new Error('notFound'); + task.done = done; task.completedAt = done ? new Date().toISOString() : null; + persistBoard(data); return task; + }, + createMemory({ date = day(), title, text = '', mood = 'cloudy', energy = 3, tags = [], location = '', taskId = '', replace = false }) { + title = String(title || '').trim(); + if (title.length > 120) throw new Error('invalidTitle'); + if (!validDate(date) || !date) throw new Error('invalidDate'); + if (!['clear', 'light', 'cloudy', 'rain', 'storm'].includes(mood) || !Number.isInteger(Number(energy)) || Number(energy) < 1 || Number(energy) > 5) throw new Error('invalidValue'); + if (taskId && !board().tasks.some(task => task.id === taskId)) throw new Error('notFound'); + const all = mems(), old = all.find(item => item.date === date); + if (old && !replace) throw new Error('memoryExists'); + const film = filmFor(mood, Number(energy)), now = new Date().toISOString(); + const memory = { ...old, id: old?.id || `memory_${crypto.randomUUID()}`, date, title, text: String(text).trim(), mood, energy: Number(energy), tags: [...new Set(tags.map(tag => String(tag).trim()).filter(Boolean))].slice(0, 12), location: String(location).trim().slice(0, 100), taskId, filmTitle: film.filmTitle, filmSubtitle: film.filmSubtitle, filmLine: film.filmLine, createdAt: old?.createdAt || now, updatedAt: now }; + save('lifeos_memories', [...all.filter(item => item.date !== date), memory].sort((a, b) => b.date.localeCompare(a.date))); renderAll(); return memory; + } + }; +})(); diff --git a/public/i18n-renderers.js b/public/i18n-renderers.js index af0e03d..a780c6a 100644 --- a/public/i18n-renderers.js +++ b/public/i18n-renderers.js @@ -29,10 +29,10 @@ container.innerHTML = `
${weekday(entryDate).toUpperCase()}

${uiDate(entryDate)}

${t('today.prompt')} ${entryDate !== day() ? `` : ''}

${memoryMetaFields(memory)}
`; }; window.archiveToday = () => { - const entryDate = todayTargetDate || day(), text = document.querySelector('#entry')?.value.trim() || '', old = mems().find(item => item.date === entryDate), film = filmFor(selectedMood, selectedEnergy), now = new Date().toISOString(); - const memory = { ...old, id: old?.id || `memory_${crypto.randomUUID()}`, date: entryDate, text, title: document.querySelector('#memory-title')?.value.trim() || '', mood: selectedMood, energy: selectedEnergy, tags: tagList(document.querySelector('#memory-tags')?.value), location: document.querySelector('#memory-place')?.value.trim() || '', taskId: document.querySelector('#memory-task')?.value || '', filmTitle: film.filmTitle, filmSubtitle: film.filmSubtitle, filmLine: film.filmLine, createdAt: old?.createdAt || now, updatedAt: now }; - const status = document.querySelector('#saveStatus'); status.textContent = t('today.archiving'); - setTimeout(() => { save('lifeos_memories', [...mems().filter(item => item.date !== entryDate), memory].sort((a, b) => b.date.localeCompare(a.date))); todayDraft = null; todayTitleDraft = null; status.textContent = t('today.archived'); renderAll(); }, 360); + const entryDate = todayTargetDate || day(), status = document.querySelector('#saveStatus'); + const draft = { date: entryDate, text: document.querySelector('#entry')?.value.trim() || '', title: document.querySelector('#memory-title')?.value.trim() || '', mood: selectedMood, energy: selectedEnergy, tags: tagList(document.querySelector('#memory-tags')?.value), location: document.querySelector('#memory-place')?.value.trim() || '', taskId: document.querySelector('#memory-task')?.value || '', replace: true }; + status.textContent = t('today.archiving'); + setTimeout(() => { todayDraft = null; todayTitleDraft = null; window.LifeOSActions.createMemory(draft); }, 360); }; window.timeline = container => { const all = mems(), tags = [...new Set(all.flatMap(item => item.tags || []))].sort((a,b) => a.localeCompare(b)); @@ -110,7 +110,7 @@ window.saveProfile = () => { save('lifeos_profile', { name: pname.value, birthday: pbday.value, startDate: pstart.value || day(), motto: pmotto.value }); renderAll(); toast(t('profile.saved')); }; window.systemApp = container => { const state = settings(), preference = LifeI18n.preference(), bytes = new Blob([JSON.stringify({ mems: mems(), museum: museum(), profile: profile(), system: sys() })]).size, oldBackup = state.lastBackup && Date.now() - new Date(state.lastBackup) > 12096e5, detected = browserLanguage() === 'zh' ? t('system.detectedZh') : t('system.detectedEn'); - container.innerHTML = `
${t('system.eyebrow')}

${t('system.title')}

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

${detected}

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

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

${t('system.dataStorage')}

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

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

${t('system.version')}

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

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

${t('system.title')}

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

${detected}

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

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

${t('system.dataStorage')}

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

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

${t('system.version')}

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

`; }; window.importPreview = () => { const data = importData, board = data.system?.tasksBoard || {}; rootModal().innerHTML = ``; }; window.replaceConfirm = () => { rootModal().innerHTML = ``; }; diff --git a/public/index.html b/public/index.html index cfa90fd..73c82f5 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ *{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...
+ diff --git a/public/locales/en.json b/public/locales/en.json index ba16371..fe0dcc2 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -8,7 +8,8 @@ "tasks": "TASKS", "calendar": "CALENDAR", "system": "SYSTEM", - "browser": "BROWSER" + "browser": "BROWSER", + "terminal": "TERMINAL" }, "common": { "save": "SAVE", @@ -540,5 +541,36 @@ "frame": "Embedded web page", "embedHint": "Some websites do not allow embedding. If the page is blank or blocked, open it in a new tab.", "invalid": "Enter a valid web address or search." + }, + "terminal": { + "title": "TERMINAL", + "hint": "Type help to see commands", + "command": "Life OS command", + "memory": "Memory", + "task": "Task", + "goal": "Goal", + "notePrompt": "Write your memory below. Ctrl/⌘ + Enter saves; Escape cancels.", + "saveNote": "Save memory", + "cancel": "Cancel", + "cancelled": "Memory creation cancelled.", + "opened": "Opened {app}.", + "empty": "No matching records.", + "memorySaved": "Memory saved: {title} · {date}", + "goalSaved": "Goal created: {title}", + "taskSaved": "Task created: {title}", + "taskCompleted": "Task completed: {title}", + "taskReopened": "Task reopened: {title}", + "invalidTitle": "Add a title within the allowed length.", + "invalidDate": "Use a real date in YYYY-MM-DD format, today, or tomorrow.", + "invalidPriority": "Priority must be high, normal, or low.", + "invalidValue": "Weather or energy value is invalid.", + "notFound": "The referenced task or goal was not found.", + "ambiguous": "More than one item has this title. Use its ID instead.", + "memoryExists": "A memory already exists for that date. Edit it in Today or Timeline.", + "invalidOption": "Unknown option or missing option value. Type help.", + "unclosedQuote": "A quote was not closed.", + "usage": "Missing title or item number. Type help.", + "unknownApp": "Unknown App. Type help.", + "unknownCommand": "Unknown command. Type help." } } diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 6f76519..bc63939 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -8,7 +8,8 @@ "tasks": "目标与待办", "calendar": "日历", "system": "系统", - "browser": "浏览器" + "browser": "浏览器", + "terminal": "终端" }, "common": { "save": "保存", @@ -540,5 +541,36 @@ "frame": "嵌入式网页", "embedHint": "部分网站禁止嵌入;若页面空白或被拦截,请在新标签页打开。", "invalid": "请输入有效网址或搜索词。" + }, + "terminal": { + "title": "终端", + "hint": "输入 help 查看命令", + "command": "Life OS 命令", + "memory": "记忆", + "task": "待办", + "goal": "目标", + "notePrompt": "在下方书写记忆。Ctrl/⌘ + Enter 保存,Escape 取消。", + "saveNote": "保存记忆", + "cancel": "取消", + "cancelled": "已取消创建记忆。", + "opened": "已打开{app}。", + "empty": "没有符合条件的记录。", + "memorySaved": "已保存记忆:{title} · {date}", + "goalSaved": "已创建目标:{title}", + "taskSaved": "已创建待办:{title}", + "taskCompleted": "已完成待办:{title}", + "taskReopened": "已重新开启待办:{title}", + "invalidTitle": "请输入未超过长度限制的标题。", + "invalidDate": "日期请使用真实的 YYYY-MM-DD 日期,或 today、tomorrow。", + "invalidPriority": "优先级只能是 high、normal 或 low。", + "invalidValue": "天气或能量数值无效。", + "notFound": "找不到对应的待办或目标。", + "ambiguous": "多个条目使用了相同标题,请改用 ID。", + "memoryExists": "这一天已有记忆,请在“今日”或“时间线”编辑。", + "invalidOption": "选项无效或缺少值;输入 help 查看用法。", + "unclosedQuote": "引号没有闭合。", + "usage": "缺少标题或编号;输入 help 查看用法。", + "unknownApp": "找不到该 App;输入 help 查看用法。", + "unknownCommand": "未知命令;输入 help 查看用法。" } } diff --git a/public/service-worker.js b/public/service-worker.js index 97731f3..4be61f1 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,4 +1,4 @@ -const CACHE = 'life-os-shell-v1.9.0'; +const CACHE = 'life-os-shell-v1.10.0'; const SHELL = [ '/', '/index.html', '/manifest.webmanifest', '/life-os-icon.svg' ]; diff --git a/public/tasks.js b/public/tasks.js index 62bc619..b6eefeb 100644 --- a/public/tasks.js +++ b/public/tasks.js @@ -43,11 +43,9 @@ event.preventDefault(); const form = event.currentTarget, input = form.querySelector('#task-new-title'), title = input.value.trim(); if (!title) return; - const data = board(); - data.tasks.push({ id: `task_${crypto.randomUUID()}`, title, dueDate: form.querySelector('#task-new-date').value || '', goalId: form.querySelector('#task-new-goal').value || '', priority: form.querySelector('#task-new-priority').value || 'normal', done: false, createdAt: new Date().toISOString() }); - persist(data); toast('tasks.taskAdded'); + window.LifeOSActions.createTask({ title, dueDate: form.querySelector('#task-new-date').value || '', goalId: form.querySelector('#task-new-goal').value || '', priority: form.querySelector('#task-new-priority').value || 'normal' }); toast('tasks.taskAdded'); }; - window.toggleTask = id => { const data = board(), task = data.tasks.find(item => item.id === id); if (!task) return; task.done = !task.done; task.completedAt = task.done ? new Date().toISOString() : null; persist(data); }; + window.toggleTask = id => { const task = board().tasks.find(item => item.id === id); if (task) window.LifeOSActions.setTaskDone(id, !task.done); }; window.deleteTask = id => { const data = board(); data.tasks = data.tasks.filter(item => item.id !== id); persist(data); toast('tasks.taskRemoved'); }; window.openGoalForm = () => { document.querySelector('#modal').innerHTML = ``; @@ -55,8 +53,8 @@ }; window.saveGoal = event => { event.preventDefault(); const title = document.querySelector('#goal-new-title').value.trim(); if (!title) return; - const data = board(); data.goals.unshift({ id: `goal_${crypto.randomUUID()}`, title, description: document.querySelector('#goal-new-description').value.trim(), targetDate: document.querySelector('#goal-new-date').value || '', showOnDesktop: document.querySelector('#goal-new-desktop').checked, done: false, createdAt: new Date().toISOString() }); - document.querySelector('#modal').innerHTML = ''; persist(data); toast('tasks.goalCreated'); + window.LifeOSActions.createGoal({ title, description: document.querySelector('#goal-new-description').value.trim(), targetDate: document.querySelector('#goal-new-date').value || '', showOnDesktop: document.querySelector('#goal-new-desktop').checked }); + document.querySelector('#modal').innerHTML = ''; toast('tasks.goalCreated'); }; window.toggleGoalDesktop = id => { const data = board(), goal = data.goals.find(item => item.id === id); if (!goal || !goal.targetDate) return; goal.showOnDesktop = !goal.showOnDesktop; persist(data); }; window.renderDesktopGoalCountdowns = () => { const container = document.querySelector('#desktop-goal-countdowns'); if (!container) return; const goals = board().goals.filter(goal => !goal.done && goal.targetDate && goal.showOnDesktop); if (!goals.length) { container.innerHTML = ''; container.classList.add('hidden'); return; } container.classList.remove('hidden'); const now = new Date(), todayUtc = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); container.innerHTML = `
${t('countdown')}
${goals.map(goal => { const [year, month, date] = goal.targetDate.split('-').map(Number), delta = Math.round((Date.UTC(year, month - 1, date) - todayUtc) / 864e5), count = Math.abs(delta), label = delta === 0 ? t('todayTarget') : delta > 0 ? t('daysLeft', { count }) : t('daysOverdue', { count }); return ``; }).join('')}`; }; diff --git a/public/terminal-app.js b/public/terminal-app.js new file mode 100644 index 0000000..ebede3e --- /dev/null +++ b/public/terminal-app.js @@ -0,0 +1,132 @@ +/* Life OS command line: only the listed application commands are executable. */ +(() => { + const t = (key, values) => LifeI18n.t(`terminal.${key}`, values); + const actions = () => window.LifeOSActions; + const localDate = offset => { const value = new Date(); value.setDate(value.getDate() + offset); return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`; }; + const date = value => value === 'today' ? localDate(0) : value === 'tomorrow' ? localDate(1) : value || ''; + const words = line => { + const result = []; let current = '', quote = '', escaped = false, started = false; + for (const char of line) { + if (escaped) { current += char; escaped = false; started = true; } + else if (char === '\\' && quote) escaped = true; + else if (quote) { if (char === quote) quote = ''; else current += char; } + else if (char === '"' || char === "'") { quote = char; started = true; } + else if (/\s/.test(char)) { if (started) { result.push(current); current = ''; started = false; } } + else { current += char; started = true; } + } + if (quote || escaped) throw new Error('unclosedQuote'); + if (started) result.push(current); + return result; + }; + const options = (input, allowed) => { + const flags = {}, values = []; + for (let index = 0; index < input.length; index++) { + const value = input[index]; + if (value.startsWith('--')) { + const key = value.slice(2); + if (!allowed.includes(key)) throw new Error('invalidOption'); + if (key === 'pin') { flags.pin = true; continue; } + if (input[index + 1] === undefined || input[index + 1].startsWith('--')) throw new Error('invalidOption'); + if (key === 'tag') (flags.tag ||= []).push(input[++index]); else flags[key] = input[++index]; + } else values.push(value); + } + return { flags, values }; + }; + const resolve = (items, value) => { + if (!value) return ''; + const matches = items.filter(item => item.id === value || item.title.toLocaleLowerCase() === value.toLocaleLowerCase()); + if (!matches.length) throw new Error('notFound'); + if (matches.length > 1) throw new Error('ambiguous'); + return matches[0].id; + }; + const board = () => actions().board(); + const requireTitle = value => { if (!value?.trim()) throw new Error('usage'); return value.trim(); }; + const help = () => ['help', 'memory add "…" [--date YYYY-MM-DD] [--tag …] [--energy 1-5] [--weather cloudy] [--place …] [--task …] [--text …]', 'memory list [--tag …] [--query …]', 'goal add "…" [--due YYYY-MM-DD] [--description …] [--pin]', 'goal list', 'task add "…" [--due tomorrow] [--goal …] [--priority high|normal|low]', 'task list', 'task done <#> / task reopen <#>', 'open / clear'].join('\n'); + const run = line => { + const [kind, verb, ...rest] = words(line.trim()); + if (!kind) return {}; + if (kind === 'help') return { message: help() }; + if (kind === 'clear') return { clear: true }; + if (kind === 'open') { + if (!Object.hasOwn(APPS, verb)) throw new Error('unknownApp'); + openApp(verb); return { message: t('opened', { app: LifeI18n.t(`app.${verb}`) }) }; + } + if (kind === 'memory' && verb === 'add') { + const { flags, values } = options(rest, ['date','tag','energy','weather','place','task','text']), title = requireTitle(values.join(' ')); + const draft = { title, date: date(flags.date) || localDate(0), tags: flags.tag || [], energy: flags.energy === undefined ? 3 : Number(flags.energy), mood: flags.weather || 'cloudy', location: flags.place || '', taskId: resolve(board().tasks, flags.task), text: flags.text || '' }; + if (mems().some(item => item.date === draft.date)) throw new Error('memoryExists'); + if (flags.text === undefined) return { compose: draft }; + const memory = actions().createMemory(draft); return { message: t('memorySaved', { title: memory.title, date: memory.date }) }; + } + if (kind === 'memory' && verb === 'list') { + const { flags, values } = options(rest, ['tag','query']); if (values.length) throw new Error('usage'); + const found = mems().filter(item => (!flags.tag?.length || flags.tag.every(tag => (item.tags || []).includes(tag))) && (!flags.query || [item.title, item.text, item.location].join(' ').toLocaleLowerCase().includes(flags.query.toLocaleLowerCase()))); + return { message: found.length ? found.slice(0, 30).map(item => `${item.date} ${item.title || item.text?.slice(0, 50) || '—'}`).join('\n') : t('empty') }; + } + if (kind === 'goal' && verb === 'add') { + const { flags, values } = options(rest, ['due','description','pin']); const goal = actions().createGoal({ title: requireTitle(values.join(' ')), targetDate: date(flags.due), description: flags.description || '', showOnDesktop: Boolean(flags.pin) }); + return { message: t('goalSaved', { title: goal.title }) }; + } + if (kind === 'goal' && verb === 'list') { + const goals = board().goals; + return { message: goals.length ? goals.map((goal, index) => `${index + 1}. ${goal.done ? '✓' : '○'} ${goal.title}${goal.targetDate ? ` ${goal.targetDate}` : ''}`).join('\n') : t('empty') }; + } + if (kind === 'task' && verb === 'add') { + const { flags, values } = options(rest, ['due','goal','priority']); const task = actions().createTask({ title: requireTitle(values.join(' ')), dueDate: date(flags.due), goalId: resolve(board().goals, flags.goal), priority: flags.priority || 'normal' }); + return { message: t('taskSaved', { title: task.title }) }; + } + if (kind === 'task' && verb === 'list') { + const tasks = board().tasks; + return { message: tasks.length ? tasks.map((task, index) => `${index + 1}. ${task.done ? '✓' : '○'} ${task.title}${task.dueDate ? ` ${task.dueDate}` : ''}`).join('\n') : t('empty') }; + } + if (kind === 'task' && ['done', 'reopen'].includes(verb)) { + const index = Number(rest[0]); + if (!Number.isInteger(index) || index < 1 || rest.length !== 1 || !board().tasks[index - 1]) throw new Error('notFound'); + const task = actions().setTaskDone(board().tasks[index - 1].id, verb === 'done'); + return { message: t(verb === 'done' ? 'taskCompleted' : 'taskReopened', { title: task.title }) }; + } + throw new Error('unknownCommand'); + }; + window.LifeOSTerminalEngine = { run, words }; + + const lines = [], history = []; + let historyIndex = 0, compose = null; + const addLine = (value, kind = 'result') => { lines.push({ value, kind }); if (lines.length > 150) lines.shift(); }; + const output = node => { + const screen = node.querySelector('.terminal-output'); if (!screen) return; + screen.replaceChildren(); + for (const line of lines) { const pre = document.createElement('pre'); pre.className = `terminal-line ${line.kind}`; pre.textContent = line.value; screen.append(pre); } + screen.scrollTop = screen.scrollHeight; + }; + const composer = (node, active) => { + node.querySelector('.terminal-compose').hidden = !active; + node.querySelector('.terminal-form').hidden = active; + if (active) { node.querySelector('.terminal-note').value = ''; node.querySelector('.terminal-note').focus(); } + else node.querySelector('.terminal-input').focus(); + }; + window.terminalApp = node => { + if (node.querySelector('.terminal-shell') && node.dataset.terminalLocale === lang()) { output(node); return; } + node.dataset.terminalLocale = lang(); + node.innerHTML = `
Life OS / ${t('title')}${t('hint')}
`; + output(node); composer(node, Boolean(compose)); + node.querySelector('.terminal-form').addEventListener('submit', event => { + event.preventDefault(); const input = node.querySelector('.terminal-input'), line = input.value.trim(); if (!line) return; + history.push(line); historyIndex = history.length; input.value = ''; addLine(`lifeos > ${line}`, 'command'); + try { const result = run(line); if (result.clear) lines.length = 0; if (result.message) addLine(result.message); if (result.compose) { compose = result.compose; addLine(t('notePrompt')); composer(node, true); } } + catch (error) { addLine(t(error.message in LifeI18n.dictionaries[lang() === 'zh' ? 'zh' : 'en'].terminal ? error.message : 'unknownCommand'), 'error'); } + output(node); + }); + node.querySelector('.terminal-input').addEventListener('keydown', event => { + const input = event.currentTarget; + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); historyIndex = Math.max(0, Math.min(history.length, historyIndex + (event.key === 'ArrowUp' ? -1 : 1))); input.value = history[historyIndex] || ''; } + if (event.key === 'Tab') { const roots = ['help','memory add ','memory list','goal add ','goal list','task add ','task list','task done ','task reopen ','open ','clear']; const matches = roots.filter(item => item.startsWith(input.value)); if (matches.length === 1) { event.preventDefault(); input.value = matches[0]; } } + }); + node.querySelector('.terminal-shortcuts').addEventListener('click', event => { const command = event.target.closest('[data-command]')?.dataset.command; if (command) { const input = node.querySelector('.terminal-input'); input.value = command; input.focus(); } }); + const saveNote = () => { try { const memory = actions().createMemory({ ...compose, text: node.querySelector('.terminal-note').value }); addLine(t('memorySaved', { title: memory.title, date: memory.date })); compose = null; composer(node, false); } catch (error) { addLine(t(error.message in LifeI18n.dictionaries[lang() === 'zh' ? 'zh' : 'en'].terminal ? error.message : 'unknownCommand'), 'error'); } output(node); }; + node.querySelector('.terminal-compose').addEventListener('click', event => { const action = event.target.closest('[data-note]')?.dataset.note; if (action === 'save') saveNote(); if (action === 'cancel') { compose = null; addLine(t('cancelled')); composer(node, false); output(node); } }); + node.querySelector('.terminal-note').addEventListener('keydown', event => { if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); saveNote(); } if (event.key === 'Escape') { event.stopPropagation(); compose = null; addLine(t('cancelled')); composer(node, false); output(node); } }); + }; + const style = document.createElement('style'); + style.textContent = `#content-terminal{padding:0;overflow:hidden}.terminal-shell{height:100%;display:flex;flex-direction:column;background:#111a16;color:#d4efd2;font:13px/1.55 'DM Mono',ui-monospace,monospace}.terminal-header{display:flex;justify-content:space-between;gap:10px;padding:12px 15px;border-bottom:1px solid #375040;color:#a8ed9c}.terminal-header span{color:#94a899;font-size:11px}.terminal-output{flex:1;min-height:0;overflow:auto;padding:15px;white-space:pre-wrap;overflow-wrap:anywhere}.terminal-line{font:inherit;margin:0 0 10px;white-space:pre-wrap}.terminal-line.command{color:#a8ed9c}.terminal-line.error{color:#ff9e99}.terminal-shortcuts{display:flex;gap:6px;overflow:auto;padding:7px 12px;border-top:1px solid #375040}.terminal-shortcuts button,.terminal-compose button,.terminal-form button{border:1px solid #456047;background:#233329;color:#d4efd2;border-radius:7px;padding:5px 9px;white-space:nowrap}.terminal-form{display:flex;align-items:center;gap:8px;border-top:1px solid #375040;padding:10px 14px}.terminal-form label{color:#a8ed9c;white-space:nowrap}.terminal-input{flex:1;min-width:0;background:transparent;color:#f0fff0;border:0;outline:none;font:inherit}.terminal-compose{border-top:1px solid #375040;padding:12px;display:grid;gap:8px}.terminal-compose[hidden],.terminal-form[hidden]{display:none}.terminal-note{width:100%;resize:vertical;background:#1c2a21;color:#f0fff0;border:1px solid #456047;border-radius:7px;padding:10px;font:inherit}.terminal-compose>div{display:flex;gap:8px}@media(max-width:650px){.terminal-header{display:block}.terminal-header span{display:block}.terminal-shell{font-size:12px}}`; + document.head.append(style); +})(); diff --git a/scripts/build.mjs b/scripts/build.mjs index 2e9e22c..a4dc9eb 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -7,21 +7,21 @@ const meta = new URL("../dist/.openai/", import.meta.url); await rm(dist, { recursive: true, force: true }); await Promise.all([mkdir(server, { recursive: true }), mkdir(meta, { recursive: true })]); -const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, desktopPersonalizationJs, desktopWidgetsJs, browserAppJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("desktop-widgets.js"), text("browser-app.js"), text("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.9.0"; +const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, desktopPersonalizationJs, desktopWidgetsJs, browserAppJs, dataActionsJs, terminalAppJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("desktop-widgets.js"), text("browser-app.js"), text("data-actions.js"), text("terminal-app.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]); +const release = "1.10.0"; const localeEnJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales.en=${localeEn};`; const localeZhJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales['zh-CN']=${localeZh};`; let indexHtml = rawIndexHtml.replace('src="addons.js"', `src="addons.js?v=${release}"`).replace('src="tasks.js"', `src="tasks.js?v=${release}"`).replace('src="calendar.js"', `src="calendar.js?v=${release}"`); for (const [src, code] of [ ['boot-guard.js', bootGuardJs], ['locales/en.js', localeEnJs], ['locales/zh-CN.js', localeZhJs], ['i18n.js', i18nJs], ['tasks.js', tasksJs], ['calendar.js', calendarJs], - ['addons.js', addonsJs], ['i18n-renderers.js', i18nRenderersJs], ['desktop-personalization.js', desktopPersonalizationJs], ['desktop-widgets.js', desktopWidgetsJs], ['browser-app.js', browserAppJs] + ['addons.js', addonsJs], ['i18n-renderers.js', i18nRenderersJs], ['desktop-personalization.js', desktopPersonalizationJs], ['desktop-widgets.js', desktopWidgetsJs], ['browser-app.js', browserAppJs], ['data-actions.js', dataActionsJs], ['terminal-app.js', terminalAppJs] ]) { const tag = ``; if (!indexHtml.includes(tag)) throw new Error(`Missing Life OS script: ${src}`); indexHtml = indexHtml.replace(tag, ``); } -await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const browserAppJs=${JSON.stringify(browserAppJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`); +await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const browserAppJs=${JSON.stringify(browserAppJs)};\nexport const dataActionsJs=${JSON.stringify(dataActionsJs)};\nexport const terminalAppJs=${JSON.stringify(terminalAppJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`); await copyFile(new URL("../worker/index.js", import.meta.url), new URL("index.js", server)); await copyFile(new URL("../.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 db9b509..0db4dd0 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -41,6 +41,7 @@ assert.doesNotMatch(source, /localizeDynamicText|createTreeWalker|NodeFilter\.SH assert.match(source, /id==='system'\)window\.systemApp\(c\)/); assert.match(source, /id==='today'\)window\.today\(c\)/); assert.match(source, /id==='browser'\)window\.browserApp\(c\)/); +assert.match(source, /id==='terminal'\)window\.terminalApp\(c\)/); assert.match(assets.i18nJs, /window\.setLanguage = setLanguage/); assert.match(assets.i18nRenderersJs, /window\.renderAll\(\)/); assert.match(assets.indexHtml, /setTimeout\(\(\)=>window\.applyLanguage\?\.\(\),0\)/, "missing locale UI code cannot abort desktop initialization"); @@ -48,11 +49,11 @@ assert.doesNotMatch(assets.indexHtml, /setTimeout\(applyLanguage\s*,/); assert.doesNotMatch(assets.addonsJs, /(?([\s\S]*?)<\/script>/g)].map(match => match[1]); -assert.equal(shippedScripts.length, 13, "the complete desktop script chain is present in the served HTML"); +assert.equal(shippedScripts.length, 15, "the complete desktop script chain is present in the served HTML"); assert.doesNotMatch(assets.indexHtml, / [key, { value: '', href: '', disabled: false, listeners: {}, addEventListener(type, handler) { this.listeners[type] = handler; }, getAttribute(name) { return this[name] ?? null; } }])); const browserContainer = { dataset: {}, innerHTML: '', querySelector(selector) { return selector === '.browser-shell' ? (this.innerHTML ? {} : null) : browserNodes[selector]; } }; const browserMessages = []; @@ -71,6 +72,35 @@ assert.equal(browserNodes.iframe.src, 'https://example.org/path', 'unsafe scheme assert.equal(browserMessages.at(-1), 'browser.invalid'); browserNodes['.browser-toolbar'].listeners.click({ target: { closest: () => ({ dataset: { action: 'back' } }) } }); assert.equal(browserNodes.iframe.src, 'https://www.google.com/webhp?igu=1'); +const terminalStorage = new Map(), terminalRenders = []; +const terminalContext = vm.createContext({ + document: { createElement: () => ({ textContent: '' }), head: { append() {} } }, + LifeI18n: { t: key => key }, lang: () => 'en', + APPS: { calendar: ['icon', 'CALENDAR'] }, openApp: id => terminalRenders.push(id), + crypto: globalThis.crypto, day: () => '2026-09-28', + mems: () => terminalStorage.get('lifeos_memories') || [], + sys: () => terminalStorage.get('lifeos_system') || { schemaVersion: 2 }, + save: (key, value) => terminalStorage.set(key, structuredClone(value)), + renderAll: () => terminalRenders.push('render'), + filmFor: () => ({ filmTitle: 'Prompt', filmSubtitle: '', filmLine: '' }) +}); +terminalContext.window = terminalContext; +vm.runInContext(assets.dataActionsJs, terminalContext); +vm.runInContext(assets.terminalAppJs, terminalContext); +const command = terminalContext.LifeOSTerminalEngine.run; +assert.equal(command('goal add "Sixth form" --due 2026-12-12 --pin').message, 'terminal.goalSaved'); +assert.equal(command('task add "Review words" --goal "Sixth form" --priority high').message, 'terminal.taskSaved'); +assert.equal(command('task done 1').message, 'terminal.taskCompleted'); +assert.equal(terminalStorage.get('lifeos_system').tasksBoard.tasks[0].done, true); +assert.equal(command('memory add "Experiment" --date 2026-09-28 --tag school --energy 4 --text "Finished lab"').message, 'terminal.memorySaved'); +assert.equal(terminalStorage.get('lifeos_memories')[0].text, 'Finished lab'); +assert.equal(terminalStorage.get('lifeos_memories')[0].tags[0], 'school'); +assert.throws(() => command('memory add "Again" --text "Duplicate"'), /memoryExists/); +assert.throws(() => command('goal add "Invalid date" --due 2026-02-30'), /invalidDate/); +assert.equal(command('memory add "Multiline" --date 2026-09-29').compose.title, 'Multiline'); +assert.equal(command('open calendar').message, 'terminal.opened'); +assert.ok(terminalRenders.includes('calendar')); +assert.deepEqual(Array.from(terminalContext.LifeOSTerminalEngine.words('task add "quoted title" --priority high')), ['task','add','quoted title','--priority','high']); assert.doesNotMatch(source, /Version 0\.1|Schema v1/); const makeStorage = initial => { const values = new Map(Object.entries(initial || {})); @@ -153,7 +183,7 @@ const uiContext = vm.createContext({ ...Object.fromEntries(["icons", "tasks", "launcher", "calendar", "desktop", "boot", "modal"].map(id => [id, uiNodes.get(`#${id}`)])) }); uiContext.window = uiContext; -for (const index of [0, 2, 3, 4, 8, 9]) vm.runInContext(shippedScripts[index], uiContext); +for (const index of [0, 2, 3, 4, 5, 9, 10]) vm.runInContext(shippedScripts[index], uiContext); vm.runInContext("windows.system={classList:{contains(){return false}}}", uiContext); uiContext.setLanguage("zh"); assert.equal(uiContext.LifeI18n.language(), "zh"); diff --git a/worker/index.js b/worker/index.js index 0ebb323..ac6bb9a 100644 --- a/worker/index.js +++ b/worker/index.js @@ -1,4 +1,4 @@ -import { addonsJs, bootGuardJs, i18nJs, localeEn, localeZh, localeEnJs, localeZhJs, tasksJs, calendarJs, i18nRenderersJs, browserAppJs, indexHtml, manifest, serviceWorker, icon } from "./assets.js"; +import { addonsJs, bootGuardJs, i18nJs, localeEn, localeZh, localeEnJs, localeZhJs, tasksJs, calendarJs, i18nRenderersJs, browserAppJs, dataActionsJs, terminalAppJs, 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"]; @@ -74,6 +74,8 @@ export default { 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 === "/data-actions.js") return new Response(dataActionsJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); + if (url.pathname === "/terminal-app.js") return new Response(terminalAppJs, { headers: headers("application/javascript; charset=utf-8", "no-cache") }); if (url.pathname === "/browser-app.js") return new Response(browserAppJs, { 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") {