diff --git a/source/app.tsx b/source/app.tsx index 65401ff..cf2a401 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -5,7 +5,7 @@ import TextInput from 'ink-text-input'; import {TodoistApi, type Task} from '@doist/todoist-api-typescript'; import {type View, commands} from './commands.js'; import {loadConfig, configPath} from './config.js'; -import EditView from './edit-view.js'; +import {Content} from './content.js'; import TaskListView from './task-list-view.js'; const config = loadConfig(); @@ -92,13 +92,8 @@ export default function App() { const [loading, setLoading] = useState(true); const [input, setInput] = useState(''); const [message, setMessage] = useState(''); - const [editingField, setEditingField] = useState(false); const refresh = useCallback(async () => { - if (view.type === 'edit') { - return; - } - setLoading(true); const projectsStale = @@ -155,7 +150,7 @@ export default function App() { const args = trimmed.slice(command.prefix.length); await command.run(args, ctx); } else { - setMessage(`Unknown command: ${trimmed}`); + setMessage(`Unknown command: ${trimmed}. Type ? for help`); } }; @@ -166,14 +161,9 @@ export default function App() { ); - const viewLabel = - view.type === 'edit' - ? `dewy ∙ edit: ${view.task.content}` - : `dewy ∙ ${view.query === homeFilter ? 'home' : view.query} ∙ ${ - tasks.length - } ${tasks.length === 1 ? 'item' : 'items'}`; - - const isEditing = view.type === 'edit'; + const viewLabel = `dewy ∙ ${ + view.query === homeFilter ? 'home' : view.query + } ∙ ${tasks.length} ${tasks.length === 1 ? 'item' : 'items'}`; return ( @@ -182,53 +172,17 @@ export default function App() { {viewLabel} {refreshIndicator} - {isEditing ? ( - <> - - { - setView({type: 'filter', query: homeFilter}); - refresh(); - }} - onEditingChange={setEditingField} - /> - - - {' '} - {editingField - ? 'Enter to save ∙ Escape to cancel' - : 'Enter to edit ∙ Escape to go back'} - - - ) : ( - <> - - - - {message || ' '} - - )} + + + + {message ? : ' '} - {!isEditing && ( - - )} + ); } diff --git a/source/commands.ts b/source/commands.ts index e64e11b..3199432 100644 --- a/source/commands.ts +++ b/source/commands.ts @@ -1,6 +1,6 @@ import {type TodoistApi, type Task} from '@doist/todoist-api-typescript'; -export type View = {type: 'filter'; query: string} | {type: 'edit'; task: Task}; +export type View = {type: 'filter'; query: string}; export type CommandContext = { api: TodoistApi; @@ -30,21 +30,89 @@ export const commands: Command[] = [ await api.closeTask(task.id); setTasks(tasks.filter(t => t.id !== task.id)); } else { - setMessage(`No task #${num}`); + setMessage(`Not found: task ${num}`); } }, }, { prefix: 'edit ', - hint: 'edit ', - run: async (args, {tasks, setView, setMessage}) => { + hint: 'edit [due|p1-p4|labels|desc|project|title]', + run: async (args, {api, tasks, projects, setTasks, setMessage}) => { + const spaceIdx = args.indexOf(' '); + const numStr = spaceIdx === -1 ? args : args.slice(0, spaceIdx); + const rest = spaceIdx === -1 ? '' : args.slice(spaceIdx + 1).trim(); + const num = Number.parseInt(numStr, 10); + const task = tasks[num - 1]; + + if (!task) { + setMessage(`Not found: task ${num}`); + return; + } + + if (!rest) { + setMessage('Missing field. Use due|p1-p4|labels|desc|project|title'); + return; + } + + let updated: Task; + + if (rest.startsWith('due ')) { + updated = await api.updateTask(task.id, { + dueString: rest.slice(4), + }); + } else if (/^p[1-4]$/.test(rest)) { + updated = await api.updateTask(task.id, { + priority: 5 - Number.parseInt(rest[1]!, 10), + }); + } else if (rest.startsWith('labels ')) { + const labels = rest + .slice(7) + .split(/\s+/) + .map(l => l.replace(/^@/, '')); + updated = await api.updateTask(task.id, {labels}); + } else if (rest.startsWith('desc ')) { + updated = await api.updateTask(task.id, { + description: rest.slice(5), + }); + } else if (rest.startsWith('project ')) { + const name = rest.slice(8).replace(/^#/, ''); + const projectId = [...projects.entries()].find( + ([, n]) => n.toLowerCase() === name.toLowerCase(), + )?.[0]; + if (!projectId) { + setMessage(`Not found: project ${name}`); + return; + } + + updated = await api.moveTask(task.id, {projectId}); + } else if (rest.startsWith('title ')) { + updated = await api.updateTask(task.id, { + content: rest.slice(6), + }); + } else { + setMessage( + `Unknown field: ${ + rest.split(' ')[0] + }. Use due|p1-p4|labels|desc|project|title`, + ); + return; + } + + setTasks(tasks.map(t => (t.id === task.id ? updated : t))); + }, + }, + + { + prefix: 'desc ', + hint: 'desc ', + run: async (args, {tasks, setMessage}) => { const num = Number.parseInt(args, 10); const task = tasks[num - 1]; if (task) { - setView({type: 'edit', task}); + setMessage(`desc ${num}: ${task.description || 'n/a'}`); } else { - setMessage(`No task #${num}`); + setMessage(`Not found: task ${num}`); } }, }, diff --git a/source/content.tsx b/source/content.tsx new file mode 100644 index 0000000..7b030f1 --- /dev/null +++ b/source/content.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import {Text} from 'ink'; +import Link from 'ink-link'; +import {parseMdLink} from './utils.js'; + +export function Content({text, bold}: {text: string; bold?: boolean}) { + const segments = parseMdLink(text); + return ( + <> + {segments.map((seg, i) => + seg.type === 'link' ? ( + + ↗ {seg.text} + + ) : ( + + {seg.text} + + ), + )} + + ); +} diff --git a/source/edit-view.tsx b/source/edit-view.tsx deleted file mode 100644 index a435de6..0000000 --- a/source/edit-view.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import React, {useState} from 'react'; -import {Text, useInput} from 'ink'; -import TextInput from 'ink-text-input'; -import {type TodoistApi, type Task} from '@doist/todoist-api-typescript'; -import {priorityColor} from './utils.js'; - -type EditViewProps = { - task: Task; - api: TodoistApi; - onBack: () => void; - onEditingChange: (editing: boolean) => void; -}; - -const fields = [ - {key: 'content', label: 'Title', placeholder: '', color: () => undefined}, - { - key: 'description', - label: 'Description', - placeholder: 'n/a', - color: () => 'gray', - }, - {key: 'due', label: 'Due', placeholder: 'no date', color: () => 'magenta'}, - { - key: 'priority', - label: 'Priority', - placeholder: 'none', - color: (v: string) => priorityColor(Number.parseInt(v, 10)), - }, - {key: 'labels', label: 'Labels', placeholder: 'none', color: () => 'yellow'}, -]; - -function getFieldValue(task: Task, key: string): string { - switch (key) { - case 'content': - return task.content; - case 'description': - return task.description; - case 'due': - return task.due?.string ?? ''; - case 'priority': - return String(5 - task.priority); - case 'labels': - return task.labels.join(', '); - default: - return ''; - } -} - -function buildUpdateArgs(key: string, value: string) { - switch (key) { - case 'content': - return {content: value}; - case 'description': - return {description: value}; - case 'due': - return value ? {dueString: value} : {dueString: 'no date'}; - case 'priority': - return { - priority: Math.max( - 1, - Math.min(4, 5 - (Number.parseInt(value, 10) || 4)), - ), - }; - case 'labels': - return { - labels: value - .split(',') - .map(l => l.trim()) - .filter(Boolean), - }; - default: - return {}; - } -} - -export default function EditView({ - task, - api, - onBack, - onEditingChange, -}: EditViewProps) { - const [cursor, setCursor] = useState(0); - const [editing, _setEditing] = useState(false); - const [editValue, setEditValue] = useState(''); - - const setEditing = (value: boolean) => { - _setEditing(value); - onEditingChange(value); - }; - const [fieldValues, setFieldValues] = useState(() => - Object.fromEntries(fields.map(f => [f.key, getFieldValue(task, f.key)])), - ); - - const handleSave = async (value: string) => { - const field = fields[cursor]!; - const args = buildUpdateArgs(fields[cursor]!.key, value); - await api.updateTask(task.id, args); - setFieldValues(prev => ({...prev, [field.key]: value})); - setEditing(false); - }; - - useInput( - (input, key) => { - if (key.upArrow || input === 'k') { - setCursor(c => Math.max(0, c - 1)); - } - - if (key.downArrow || input === 'j') { - setCursor(c => Math.min(fields.length - 1, c + 1)); - } - - if (key.escape) { - onBack(); - } - - if (key.return && !editing) { - setEditing(true); - setEditValue(fieldValues[fields[cursor]!.key] ?? ''); - } - }, - {isActive: !editing}, - ); - - useInput( - (_input, key) => { - if (key.escape) { - setEditing(false); - } - }, - {isActive: editing}, - ); - - return ( - <> - {fields.map((f, i) => ( - - {cursor === i ? '> ' : ' '} - {f.label.padEnd(14)} - {cursor === i && editing ? ( - - ) : fieldValues[f.key] ? ( - - {fieldValues[f.key]} - - ) : ( - {f.placeholder} - )} - - ))} - - ); -} diff --git a/source/task-list-view.tsx b/source/task-list-view.tsx index adb2545..90c95cd 100644 --- a/source/task-list-view.tsx +++ b/source/task-list-view.tsx @@ -1,33 +1,14 @@ import React from 'react'; import {Box, Text} from 'ink'; -import Link from 'ink-link'; import {type Task} from '@doist/todoist-api-typescript'; -import {parseMdLink, priorityColor} from './utils.js'; +import {priorityColor} from './utils.js'; +import {Content} from './content.js'; type TaskListViewProps = { tasks: Task[]; projects: Map; }; -function Content({text}: {text: string}) { - const segments = parseMdLink(text); - return ( - <> - {segments.map((seg, i) => - seg.type === 'link' ? ( - - ↗ {seg.text} - - ) : ( - - {seg.text} - - ), - )} - - ); -} - function ProjectLabel({name}: {name: string | undefined}) { if (!name) return null; return ( @@ -80,7 +61,7 @@ export default function TaskListView({tasks, projects}: TaskListViewProps) { {String(i + 1).padStart(numWidth)}. - +