/* 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); })();