diff --git a/public/data-actions.js b/public/data-actions.js
index f3c8083..121713c 100644
--- a/public/data-actions.js
+++ b/public/data-actions.js
@@ -34,6 +34,27 @@
task.done = done; task.completedAt = done ? new Date().toISOString() : null;
persistBoard(data); return task;
},
+ setGoalDone(id, done) {
+ const data = board(), goal = data.goals.find(item => item.id === id);
+ if (!goal) throw new Error('notFound');
+ goal.done = done; goal.completedAt = done ? new Date().toISOString() : null;
+ persistBoard(data); return goal;
+ },
+ deleteTask(id) {
+ const data = board(), task = data.tasks.find(item => item.id === id);
+ if (!task) throw new Error('notFound');
+ data.tasks = data.tasks.filter(item => item.id !== id);
+ const all = mems();
+ if (all.some(item => item.taskId === id)) save('lifeos_memories', all.map(item => item.taskId === id ? { ...item, taskId: '', updatedAt: new Date().toISOString() } : item));
+ persistBoard(data); return task;
+ },
+ deleteGoal(id) {
+ const data = board(), goal = data.goals.find(item => item.id === id);
+ if (!goal) throw new Error('notFound');
+ data.goals = data.goals.filter(item => item.id !== id);
+ data.tasks.forEach(task => { if (task.goalId === id) task.goalId = ''; });
+ persistBoard(data); return goal;
+ },
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');
@@ -45,6 +66,13 @@
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;
+ },
+ deleteMemory(id) {
+ const all = mems(), memory = all.find(item => item.id === id);
+ if (!memory) throw new Error('notFound');
+ save('lifeos_memories', all.filter(item => item.id !== id));
+ save('lifeos_museum', museum().filter(item => item.memoryId !== id));
+ renderAll(); return memory;
}
};
})();
diff --git a/public/i18n-renderers.js b/public/i18n-renderers.js
index 0a7bd16..cdeecd8 100644
--- a/public/i18n-renderers.js
+++ b/public/i18n-renderers.js
@@ -60,7 +60,7 @@
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); };
window.preserve = id => { const all = museum(), alreadyPreserved = all.some(item => item.memoryId === id); if (alreadyPreserved) { save('lifeos_museum', all.filter(item => item.memoryId !== id)); toast(t('timeline.unpreserved')); } else { save('lifeos_museum', [...all, { id: `artifact_${crypto.randomUUID()}`, memoryId: id, preservedAt: new Date().toISOString() }]); toast(t('timeline.added')); } closeModal(); renderAll(); };
window.confirmDelete = id => { rootModal().innerHTML = `
${t('timeline.confirmDelete')}
${t('timeline.remove')}
${t('timeline.irreversible')}
`; };
- window.deleteMemory = id => { save('lifeos_memories', mems().filter(item => item.id !== id)); save('lifeos_museum', museum().filter(item => item.memoryId !== id)); rootModal().innerHTML = ''; renderAll(); toast(t('timeline.removed')); };
+ window.deleteMemory = id => { window.LifeOSActions.deleteMemory(id); rootModal().innerHTML = ''; toast(t('timeline.removed')); };
let insightView = 'week', insightOffset = 0;
const reportDateKey = value => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
const reportDayNumber = value => Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()) / 864e5;
diff --git a/public/locales/en.json b/public/locales/en.json
index a02df5f..3a6833f 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -564,7 +564,7 @@
"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.",
+ "notFound": "The referenced record 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.",
@@ -584,6 +584,23 @@
"location": "Place or scene? [{value}]",
"taskId": "Related task? [{value}] — enter a list number, title, or ID\n{tasks}",
"controls": "Enter keeps the displayed value; back/cancel controls the flow. Use - to clear an optional field."
+ },
+ "goalCompleted": "Goal completed: {title}",
+ "goalReopened": "Goal reopened: {title}",
+ "confirmAnswer": "Type yes to confirm or no to cancel.",
+ "flow": {
+ "title": "Title? (required; current: {value})",
+ "description": "Description? [{value}] (optional)",
+ "targetDate": "Target date? [{value}] — YYYY-MM-DD, today, or tomorrow",
+ "showOnDesktop": "Show countdown on desktop? [{value}] — yes/no",
+ "dueDate": "Due date? [{value}] — YYYY-MM-DD, today, or tomorrow",
+ "goalId": "Link to a goal? [{value}] — number, title, or ID\n{items}",
+ "priority": "Priority? [{value}] — high / normal / low",
+ "pick": "Choose a {kind} by number, title, date, or ID:",
+ "confirmDelete": "Delete {kind}: {title}? Type yes to delete or no to cancel.",
+ "confirmCreate": "Create {kind}: {title}? Type yes to save or no to cancel.",
+ "cancelled": "Operation cancelled.",
+ "deleted": "Deleted {kind}: {title}"
}
}
}
diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json
index 3f85f34..1464951 100644
--- a/public/locales/zh-CN.json
+++ b/public/locales/zh-CN.json
@@ -564,7 +564,7 @@
"invalidDate": "日期请使用真实的 YYYY-MM-DD 日期,或 today、tomorrow。",
"invalidPriority": "优先级只能是 high、normal 或 low。",
"invalidValue": "天气或能量数值无效。",
- "notFound": "找不到对应的待办或目标。",
+ "notFound": "找不到对应的记录。",
"ambiguous": "多个条目使用了相同标题,请改用 ID。",
"memoryExists": "这一天已有记忆,请在“今日”或“时间线”编辑。",
"invalidOption": "选项无效或缺少值;输入 help 查看用法。",
@@ -584,6 +584,23 @@
"location": "地点或场景?[{value}]",
"taskId": "关联待办?[{value}] — 输入列表编号、标题或 ID\n{tasks}",
"controls": "回车保留显示的值;back 返回,cancel 取消。输入 - 可清空选填项。"
+ },
+ "goalCompleted": "已完成目标:{title}",
+ "goalReopened": "已重新开启目标:{title}",
+ "confirmAnswer": "输入 yes 确认,或 no 取消。",
+ "flow": {
+ "title": "标题?(必填;当前:{value})",
+ "description": "描述?[{value}](选填)",
+ "targetDate": "目标日期?[{value}] — YYYY-MM-DD、today 或 tomorrow",
+ "showOnDesktop": "在桌面显示倒计时?[{value}] — yes/no",
+ "dueDate": "截止日期?[{value}] — YYYY-MM-DD、today 或 tomorrow",
+ "goalId": "关联目标?[{value}] — 输入编号、标题或 ID\n{items}",
+ "priority": "优先级?[{value}] — high / normal / low",
+ "pick": "选择要操作的{kind}:输入编号、标题、日期或 ID",
+ "confirmDelete": "确定删除{kind}「{title}」?输入 yes 删除,或 no 取消。",
+ "confirmCreate": "确定创建{kind}「{title}」?输入 yes 保存,或 no 取消。",
+ "cancelled": "已取消操作。",
+ "deleted": "已删除{kind}:{title}"
}
}
}
diff --git a/public/tasks.js b/public/tasks.js
index b6eefeb..2b96b7c 100644
--- a/public/tasks.js
+++ b/public/tasks.js
@@ -46,7 +46,7 @@
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 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.deleteTask = id => { window.LifeOSActions.deleteTask(id); toast('tasks.taskRemoved'); };
window.openGoalForm = () => {
document.querySelector('#modal').innerHTML = ``;
setTimeout(() => document.querySelector('#goal-new-title')?.focus(), 0);
@@ -59,11 +59,11 @@
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('')}`; };
setInterval(window.renderDesktopGoalCountdowns, 60000);
- window.setGoalDone = (id, done) => { const data = board(), goal = data.goals.find(item => item.id === id); if (!goal) return; goal.done = done; goal.completedAt = done ? new Date().toISOString() : null; persist(data); };
+ window.setGoalDone = (id, done) => { window.LifeOSActions.setGoalDone(id, done); };
window.deleteGoal = id => {
document.querySelector('#modal').innerHTML = `${t('eyebrow')}
${t('confirmGoalDelete')}
${t('confirmGoalDeleteBody')}
`;
};
- window.confirmDeleteGoal = id => { const data = board(); data.goals = data.goals.filter(goal => goal.id !== id); data.tasks.forEach(task => { if (task.goalId === id) task.goalId = ''; }); document.querySelector('#modal').innerHTML = ''; persist(data); toast('tasks.goalRemoved'); };
+ window.confirmDeleteGoal = id => { window.LifeOSActions.deleteGoal(id); document.querySelector('#modal').innerHTML = ''; toast('tasks.goalRemoved'); };
const style = document.createElement('style');
style.textContent = `.task-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}.task-head h1{max-width:430px}.task-head .secondary{white-space:nowrap}.task-focus{display:flex;align-items:center;justify-content:space-between;margin:18px 0;padding:18px 20px;background:linear-gradient(115deg,color-mix(in srgb,var(--accent) 15%,var(--panel2)),var(--panel2));border:1px solid var(--line);border-radius:13px}.task-focus .eyebrow{margin-bottom:5px}.task-focus strong{font-size:30px;letter-spacing:-.06em}.task-focus span{margin-left:10px;color:var(--muted);font-size:12px}.task-focus-mark{font-size:26px;color:var(--accent)}.task-quick{display:grid;grid-template-columns:minmax(130px,1fr) 145px 120px 95px auto;gap:7px}.task-quick input,.task-quick select,.goal-form input,.goal-form textarea{min-width:0;border:1px solid var(--line);background:var(--panel2);border-radius:8px;padding:10px 11px;outline:none}.task-quick input:focus,.task-quick select:focus,.goal-form input:focus,.goal-form textarea:focus{border-color:var(--accent)}.task-quick select{font-size:12px}.task-quick .primary{padding:10px 11px;white-space:nowrap}.task-filters{display:flex;gap:7px;flex-wrap:wrap;margin:19px 0 8px}.task-filters .chip{padding:7px 12px}.task-list{display:grid;gap:7px}.task-row{display:flex;align-items:center;gap:12px;padding:12px;background:color-mix(in srgb,var(--panel2) 55%,transparent);border:1px solid var(--line);border-radius:10px}.task-check{flex:none;width:21px;height:21px;border:1px solid var(--muted);border-radius:7px;background:transparent;color:var(--accent);font-weight:700}.task-check[aria-pressed=true]{background:var(--accent);border-color:var(--accent);color:var(--bg)}.task-copy{min-width:0;flex:1}.task-copy>strong{display:block;font-size:13px;font-weight:550;overflow-wrap:anywhere}.task-meta{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-top:5px;color:var(--muted);font-size:10px}.task-meta span{display:inline-flex;align-items:center;gap:4px}.task-meta b,.is-overdue .task-meta{color:#df8a70}.task-priority{font:9px 'DM Mono';letter-spacing:.05em}.task-priority.high{color:#df8a70}.task-priority.low{color:var(--muted)}.is-done .task-copy>strong{text-decoration:line-through;color:var(--muted)}.task-delete{width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--muted);font-size:19px;line-height:1}.task-delete:hover{background:var(--panel2);color:var(--ink)}.task-empty,.goal-empty{padding:21px;text-align:center;color:var(--muted);font-size:13px}.task-empty span{font-size:23px;color:var(--accent)}.goal-section{margin-top:24px;padding-top:16px;border-top:1px solid var(--line)}.goal-section-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}.goal-section-head .eyebrow{margin:0}.goal-count{font:11px 'DM Mono';color:var(--muted)}.goal-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px}.goal-card{min-width:0;padding:14px;border:1px solid var(--line);border-radius:11px;background:color-mix(in srgb,var(--panel2) 55%,transparent)}.goal-card-top{display:flex;justify-content:space-between;align-items:center}.goal-actions{display:flex;align-items:center;gap:4px;flex-wrap:wrap;justify-content:flex-end}.goal-desktop-toggle{border:1px solid var(--line);border-radius:7px;background:transparent;color:var(--muted);font:9px 'DM Mono';padding:6px 8px;white-space:nowrap}.goal-desktop-toggle.selected{color:var(--accent);border-color:var(--accent)}.goal-form label.goal-pin-option{display:flex!important;grid-template-columns:18px 1fr;align-items:center;gap:8px!important}.goal-form label.goal-pin-option input{width:16px;height:16px;min-width:16px;padding:0;accent-color:var(--accent)}.goal-finish{border:0;background:transparent;color:var(--muted);font:9px 'DM Mono';letter-spacing:.04em;padding:5px}.goal-finish:hover{color:var(--accent)}.finished-goals{margin-top:14px}.finished-goals summary{cursor:pointer;color:var(--muted);font:10px 'DM Mono';letter-spacing:.08em;margin-bottom:9px}.is-goal-done{opacity:.72}.goal-mark{font-size:20px;color:var(--accent)}.goal-card h3{margin:8px 0 4px;font-size:15px;letter-spacing:-.025em;overflow-wrap:anywhere}.goal-card p{margin:0 0 12px;color:var(--muted);font-size:11px;line-height:1.5}.goal-progress-line{display:flex;justify-content:space-between;gap:8px;margin-top:11px;font:9px 'DM Mono';color:var(--muted)}.goal-progress{height:4px;background:var(--line);border-radius:5px;margin-top:5px;overflow:hidden}.goal-progress i{display:block;height:100%;background:var(--accent);border-radius:5px}.goal-date{margin-top:10px;color:var(--muted);font-size:10px}.goal-form{display:grid;gap:13px}.goal-form h2{font-size:21px;margin-bottom:0}.goal-form label{display:grid;gap:6px;color:var(--muted);font-size:12px}.goal-form textarea{resize:vertical}.goal-form .actions{margin:0}.desktop-goal-countdowns{position:absolute;z-index:1;top:24px;right:28px;width:min(310px,30vw);max-height:72vh;overflow:auto;display:grid;gap:8px}.desktop-goal-countdowns.hidden{display:none}.desktop-goal-heading{color:var(--muted);font:10px 'DM Mono';letter-spacing:.15em;text-align:right}.desktop-goal-card{display:grid;gap:5px;width:100%;padding:13px 15px;text-align:left;background:color-mix(in srgb,var(--panel) 88%,transparent);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);backdrop-filter:blur(12px)}.desktop-goal-card span{font-size:13px;font-weight:600;overflow-wrap:anywhere}.desktop-goal-card strong{color:var(--accent);font:500 13px 'DM Mono'}.desktop-goal-card small{color:var(--muted);font-size:11px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:650px){.desktop-goal-countdowns{position:relative;top:auto;right:auto;width:auto;max-width:none;max-height:30vh;margin:12px 16px 92px;overflow:auto;grid-template-columns:repeat(2,minmax(0,1fr));align-items:start}.desktop-goal-heading{grid-column:1/-1;text-align:left}.desktop-icons{padding:76px 14px 18px;display:flex;flex-wrap:wrap;justify-content:flex-start;gap:16px 4px}.app-icon{flex:0 0 calc(25% - 4px);min-width:0;justify-items:center;font-size:10px;letter-spacing:.04em}.desktop-goal-card{padding:11px 12px}.desktop-goal-card span{font-size:12px}}@media(min-width:651px){.desktop-icons{padding:72px 32px;display:grid;grid-template-columns:96px;justify-items:start;align-content:start;gap:14px}.app-icon{width:96px;justify-items:center;font-size:11px}.desktop-goal-countdowns{top:26px;right:30px;width:min(330px,28vw);max-height:calc(100vh - 110px)}}@media(max-width:700px){.task-quick{grid-template-columns:1fr 1fr}.task-quick input:first-of-type{grid-column:1/-1}.task-quick .primary{grid-column:1/-1}.task-head{align-items:flex-start}.task-head .secondary{font-size:9px;padding:9px 10px}.goal-grid{grid-template-columns:1fr}.task-meta{gap:6px}.task-focus{padding:14px}.task-focus span{display:block;margin:3px 0 0}}`;
diff --git a/public/terminal-app.js b/public/terminal-app.js
index 4b9fd30..b5edff7 100644
--- a/public/terminal-app.js
+++ b/public/terminal-app.js
@@ -46,6 +46,63 @@
const help = () => ['help', 'memory add / memory add "…" — ' + t('guidedHelp'), '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 wizardFields = ['title', 'date', 'tags', 'energy', 'mood', 'location', 'taskId'];
let wizard = null;
+ let flow = null;
+ const itemsFor = kind => kind === 'memory' ? mems() : board()[kind === 'goal' ? 'goals' : 'tasks'];
+ const itemLabel = (kind, item) => kind === 'memory' ? `${item.date} ${item.title || item.text?.slice(0, 40) || '—'}` : item.title;
+ const findItem = (kind, value) => {
+ const items = itemsFor(kind);
+ if (/^[1-9]\d*$/.test(value) && items[Number(value) - 1]) return items[Number(value) - 1];
+ const matches = items.filter(item => item.id === value || (kind === 'memory' && item.date === value) || item.title?.toLocaleLowerCase() === value.toLocaleLowerCase());
+ if (!matches.length) throw new Error('notFound');
+ if (matches.length > 1) throw new Error('ambiguous');
+ return matches[0];
+ };
+ const flowFields = { goal: ['title','description','targetDate','showOnDesktop'], task: ['title','dueDate','goalId','priority'] };
+ const flowPrompt = () => {
+ if (flow.operation === 'delete' && !flow.target) return { message: `${t('flow.pick', { kind: t(flow.kind) })}\n${itemsFor(flow.kind).map((item,index) => `${index + 1}. ${itemLabel(flow.kind,item)}`).slice(0,30).join('\n')}\n${t('wizard.controls')}` };
+ if (flow.operation === 'delete') return { message: t('flow.confirmDelete', { kind: t(flow.kind), title: itemLabel(flow.kind,flow.target) }) };
+ const field = flowFields[flow.kind][flow.step];
+ if (!field) return { message: t('flow.confirmCreate', { kind: t(flow.kind), title: flow.draft.title }) };
+ const items = field === 'goalId' ? board().goals.map((goal,index) => `${index + 1}. ${goal.title}`).slice(0,20).join('\n') || '—' : '';
+ const value = field === 'goalId' ? board().goals.find(goal => goal.id === flow.draft.goalId)?.title : flow.draft[field];
+ return { message: `${t(`flow.${field}`, { value: value || '—', items })}\n${t('wizard.controls')}` };
+ };
+ const startFlow = (kind, operation, value = '') => {
+ if (operation === 'delete') { if (!itemsFor(kind).length) return { message: t('empty') }; flow = { kind, operation, target: value ? findItem(kind,value) : null }; return flowPrompt(); }
+ flow = { kind, operation, step: value ? 1 : 0, draft: kind === 'goal' ? { title: value, description: '', targetDate: '', showOnDesktop: false } : { title: value, dueDate: '', goalId: '', priority: 'normal' } };
+ return flowPrompt();
+ };
+ const answerFlow = input => {
+ const value = input.trim(), current = flow;
+ if (value === 'cancel') { flow = null; return { message: t('flow.cancelled'), cancel: true }; }
+ if (value === 'back') {
+ if (current.operation === 'delete') current.target = null;
+ else current.step = Math.max(0,current.step - 1);
+ return flowPrompt();
+ }
+ if (current.operation === 'delete') {
+ if (!current.target) { if (!value) throw new Error('usage'); current.target = findItem(current.kind,value); return flowPrompt(); }
+ if (!['yes','y'].includes(value.toLowerCase())) { if (!['no','n'].includes(value.toLowerCase())) throw new Error('confirmAnswer'); flow = null; return { message: t('flow.cancelled'), cancel: true }; }
+ const item = itemsFor(current.kind).find(record => record.id === current.target.id);
+ if (!item) { flow = null; throw new Error('notFound'); }
+ const label = itemLabel(current.kind,item);
+ actions()[`delete${current.kind[0].toUpperCase()}${current.kind.slice(1)}`](item.id);
+ flow = null; return { message: t('flow.deleted', { kind: t(current.kind), title: label }) };
+ }
+ const field = flowFields[current.kind][current.step], draft = current.draft;
+ if (!field) {
+ if (!['yes','y'].includes(value.toLowerCase())) { if (!['no','n'].includes(value.toLowerCase())) throw new Error('confirmAnswer'); flow = null; return { message: t('flow.cancelled'), cancel: true }; }
+ const item = current.kind === 'goal' ? actions().createGoal(draft) : actions().createTask(draft);
+ flow = null; return { message: t(current.kind === 'goal' ? 'goalSaved' : 'taskSaved', { title: item.title }) };
+ }
+ if (field === 'title') { if (!value && !draft.title || value.length > (current.kind === 'goal' ? 100 : 140)) throw new Error('invalidTitle'); draft.title = value || draft.title; }
+ if (field === 'description') { if (value.length > 240) throw new Error('invalidValue'); if (value) draft.description = value === '-' ? '' : value; }
+ if (field === 'targetDate' || field === 'dueDate') { const selected = value === '-' ? '' : date(value) || draft[field]; if (selected && !validDay(selected)) throw new Error('invalidDate'); draft[field] = selected; }
+ if (field === 'showOnDesktop') { if (value && !['yes','y','no','n'].includes(value.toLowerCase())) throw new Error('invalidValue'); draft.showOnDesktop = value ? ['yes','y'].includes(value.toLowerCase()) : draft.showOnDesktop; }
+ if (field === 'goalId' && value) draft.goalId = value === '-' ? '' : resolve(board().goals,value);
+ if (field === 'priority') { if (value && !['high','normal','low'].includes(value.toLowerCase())) throw new Error('invalidPriority'); draft.priority = value ? value.toLowerCase() : draft.priority; }
+ current.step++; return flowPrompt();
+ };
const wizardPrompt = () => {
if (wizard.step >= wizardFields.length) return { compose: { ...wizard.draft } };
const field = wizardFields[wizard.step], tasks = field === 'taskId' ? board().tasks.map((item, index) => `${index + 1}. ${item.title}`).slice(0, 20).join('\n') || '—' : '';
@@ -80,6 +137,7 @@
return nextStep();
};
const run = line => {
+ if (flow) return answerFlow(line);
if (wizard) return answer(line);
const [kind, verb, ...rest] = words(line.trim());
if (!kind) return {};
@@ -107,7 +165,9 @@
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 === 'memory' && verb === 'delete') return startFlow('memory','delete',rest.join(' '));
if (kind === 'goal' && verb === 'add') {
+ if (!rest.some(part => part.startsWith('--'))) return startFlow('goal','add',rest.join(' '));
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 }) };
}
@@ -115,7 +175,14 @@
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 === 'goal' && verb === 'delete') return startFlow('goal','delete',rest.join(' '));
+ if (kind === 'goal' && ['done','reopen'].includes(verb)) {
+ if (!rest.length) throw new Error('usage');
+ const goal = actions().setGoalDone(findItem('goal',rest.join(' ')).id,verb === 'done');
+ return { message: t(verb === 'done' ? 'goalCompleted' : 'goalReopened', { title: goal.title }) };
+ }
if (kind === 'task' && verb === 'add') {
+ if (!rest.some(part => part.startsWith('--'))) return startFlow('task','add',rest.join(' '));
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 }) };
}
@@ -123,6 +190,7 @@
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' && verb === 'delete') return startFlow('task','delete',rest.join(' '));
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');
@@ -132,7 +200,9 @@
throw new Error('unknownCommand');
};
const finishWizard = () => { wizard = null; };
- window.LifeOSTerminalEngine = { run, words, backStep, finishWizard, active: () => Boolean(wizard) };
+ const cancelSession = () => { wizard = null; flow = null; };
+ const active = () => Boolean(wizard || flow);
+ window.LifeOSTerminalEngine = { run, words, backStep, finishWizard, cancelSession, active };
const lines = [], history = [];
let historyIndex = 0, compose = null, noteDraft = '';
@@ -155,7 +225,7 @@
node.innerHTML = ` `;
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 && !wizard) return;
+ event.preventDefault(); const input = node.querySelector('.terminal-input'), line = input.value.trim(); if (!line && !active()) return;
if (line) 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); } if (result.cancel) { compose = null; noteDraft = ''; } }
catch (error) { addLine(t(error.message in LifeI18n.dictionaries[lang() === 'zh' ? 'zh' : 'en'].terminal ? error.message : 'unknownCommand'), 'error'); }
@@ -163,9 +233,9 @@
});
node.querySelector('.terminal-input').addEventListener('keydown', event => {
const input = event.currentTarget;
- if (event.key === 'Escape' && wizard) { event.preventDefault(); event.stopPropagation(); addLine(t('cancelled')); finishWizard(); output(node); return; }
+ if (event.key === 'Escape' && active()) { event.preventDefault(); event.stopPropagation(); addLine(t('flow.cancelled')); cancelSession(); output(node); return; }
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]; } }
+ if (event.key === 'Tab' && !active()) { const roots = ['help','memory add ','memory list','memory delete','goal add ','goal list','goal delete','task add ','task list','task delete','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; noteDraft = ''; finishWizard(); composer(node, false); } catch (error) { addLine(t(error.message in LifeI18n.dictionaries[lang() === 'zh' ? 'zh' : 'en'].terminal ? error.message : 'unknownCommand'), 'error'); } output(node); };
diff --git a/scripts/validate.mjs b/scripts/validate.mjs
index 67e84c1..1493517 100644
--- a/scripts/validate.mjs
+++ b/scripts/validate.mjs
@@ -79,6 +79,7 @@ const terminalContext = vm.createContext({
APPS: { calendar: ['icon', 'CALENDAR'] }, openApp: id => terminalRenders.push(id),
crypto: globalThis.crypto, day: () => '2026-09-28',
mems: () => terminalStorage.get('lifeos_memories') || [],
+ museum: () => terminalStorage.get('lifeos_museum') || [],
sys: () => terminalStorage.get('lifeos_system') || { schemaVersion: 2 },
save: (key, value) => terminalStorage.set(key, structuredClone(value)),
renderAll: () => terminalRenders.push('render'),
@@ -125,6 +126,38 @@ terminalUiNodes['.terminal-note'].value = 'A note from the guided form';
terminalUiNodes['.terminal-compose'].listeners.click({ target: { closest: () => ({ dataset: { note: 'save' } }) } });
assert.equal(terminalStorage.get('lifeos_memories').find(item => item.date === '2026-10-01').text, 'A note from the guided form');
assert.equal(terminalUiNodes['.terminal-compose'].hidden, true);
+assert.match(command('goal add').message, /terminal\.flow\.title/);
+assert.match(command('A guided goal').message, /terminal\.flow\.description/);
+assert.match(command('').message, /terminal\.flow\.targetDate/);
+assert.match(command('2027-01-02').message, /terminal\.flow\.showOnDesktop/);
+assert.match(command('yes').message, /terminal\.flow\.confirmCreate/);
+assert.equal(command('yes').message, 'terminal.goalSaved');
+assert.equal(terminalStorage.get('lifeos_system').tasksBoard.goals[0].showOnDesktop, true);
+assert.match(command('task add').message, /terminal\.flow\.title/);
+assert.match(command('A guided task').message, /terminal\.flow\.dueDate/);
+assert.match(command('').message, /terminal\.flow\.goalId/);
+assert.match(command('1').message, /terminal\.flow\.priority/);
+assert.match(command('high').message, /terminal\.flow\.confirmCreate/);
+assert.equal(command('yes').message, 'terminal.taskSaved');
+assert.equal(terminalStorage.get('lifeos_system').tasksBoard.tasks[1].goalId, terminalStorage.get('lifeos_system').tasksBoard.goals[0].id);
+assert.match(command('task delete').message, /terminal\.flow\.pick/);
+assert.match(command('2').message, /terminal\.flow\.confirmDelete/);
+assert.equal(command('no').cancel, true);
+assert.equal(terminalStorage.get('lifeos_system').tasksBoard.tasks.length, 2);
+assert.match(command('task delete 2').message, /terminal\.flow\.confirmDelete/);
+assert.match(command('yes').message, /terminal\.flow\.deleted/);
+assert.equal(terminalStorage.get('lifeos_system').tasksBoard.tasks.length, 1);
+assert.match(command('memory delete 2026-09-30').message, /terminal\.flow\.confirmDelete/);
+assert.equal(command('yes').message.includes('terminal.flow.deleted'), true);
+assert.equal(terminalStorage.get('lifeos_memories').some(item => item.date === '2026-09-30'), false);
+terminalContext.LifeOSActions.createTask({ title: 'Linked task', goalId: terminalStorage.get('lifeos_system').tasksBoard.goals[0].id });
+assert.match(command('goal delete 1').message, /terminal\.flow\.confirmDelete/);
+assert.match(command('yes').message, /terminal\.flow\.deleted/);
+assert.equal(terminalStorage.get('lifeos_system').tasksBoard.tasks[1].goalId, '');
+terminalContext.LifeOSActions.createMemory({ date: '2026-10-02', title: 'Linked memory', taskId: terminalStorage.get('lifeos_system').tasksBoard.tasks[0].id });
+assert.match(command('task delete 1').message, /terminal\.flow\.confirmDelete/);
+assert.match(command('yes').message, /terminal\.flow\.deleted/);
+assert.equal(terminalStorage.get('lifeos_memories').find(item => item.date === '2026-10-02').taskId, '');
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']);