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 = ``; }; - 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 = ``; }; - 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 = `
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 && !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']);