UNPKG

todo-txt-cli

Version:

A CLI for todo.txt files - http://todotxt.org/

431 lines (408 loc) 11.7 kB
import fs from 'node:fs' import { FileAdapter } from '#adapter-file.js' import { initLogger, logger } from '#logger.js' import { digits, formatDate, today, isInteger } from '#utils.js' import { getColors } from '#colors.js' import { sorting } from '#sorter.js' import { launchEditor, AppConfig } from '#config.js' import { Tasks } from '#tasks.js' import { renderHelp } from '#help.js' const log = logger('cli') const PACKAGE_JSON = '../package.json' const itemNText = (argv) => { const [item, ...text] = argv return [item, text.join(' ')] } const actions = { add: (c, argv) => (c.add = argv.join(' ')), append: (c, argv) => (c.append = itemNText(argv)), archive: (c) => (c.archive = true), del: (c, argv) => (c.remove = argv.slice(0, 2)), depri: (c, argv) => (c.dePrioritize = argv), done: (c, argv) => (c.done = argv), due: (c, argv) => (c.due = argv), edit: (c, argv) => (c.edit = argv), help: (c, argv) => (c.help = argv), list: (c, argv) => { c.list = argv c.showCompleted = false }, listall: (c, argv) => { c.list = argv c.showCompleted = true }, listcon: (c, argv) => { c.list = argv c.onlyContexts = true }, listpri: (c, argv) => { const [priorities, ...terms] = argv c.list = terms c.priorities = priorities || '.' }, listprj: (c, argv) => { c.list = argv c.onlyProjects = true }, overdue: (c, argv) => (c.overdue = argv[0] || formatDate(today())), prepend: (c, argv) => (c.prepend = itemNText(argv)), pri: (c, argv) => (c.prioritize = argv), replace: (c, argv) => (c.replace = itemNText(argv)), set: (c, argv) => (c.set = argv), sort: (c, argv) => (c.sort = argv[0] || 'due'), undo: (c, argv) => (c.undo = argv), version: (c) => (c.version = true) } actions.a = actions.add actions.app = actions.append actions.rm = actions.del actions.d = actions.done actions.dp = actions.depri actions.ls = actions.list actions.lsa = actions.listall actions.lsc = actions.listcon actions.lsp = actions.listpri actions.lsprj = actions.listprj actions.pre = actions.prepend // maintain for backwards compatibility actions.prep = actions.prepend actions.p = actions.pri const LIMIT_FROM_CONF = -1 const options = { '-h': (o) => (o.help = true), '-v': (o) => (o.version = true), '-@': (o) => (o.hideContexts = true), '-+': (o) => (o.hideProjects = true), '-c': (o) => (o.useColor = true), '-C': (o) => (o.useColor = false), '-d': (o, argv) => { // disallow action as next arg. // If directory is a valid action key use `./ls` instead of `ls` const n = nextArg(argv, true) o.todoDir = n || process.cwd() }, '-l': (o, argv) => { let n = Number(argv[0]) if (isInteger(n) && n > 0) { argv.shift() } else { n = LIMIT_FROM_CONF } o.limit = n }, '-r': (o) => (o.reverse = true), '-s': (o, argv) => { const n = nextArg(argv) if (n) { o.sort = n } }, '-t': (o) => (o.timestamp = true), '-T': (o) => (o.timestamp = false) } options['--help'] = options['-h'] options['--version'] = options['-v'] options['--no-projects'] = options['-+'] options['--no-contexts'] = options['-@'] options['--color'] = options['-c'] options['--no-color'] = options['-C'] options['--timestamp'] = options['-t'] options['--no-timestamp'] = options['-T'] options['--dir'] = options['-d'] options['--limit'] = options['-l'] options['--reverse'] = options['-r'] options['--sort'] = options['-s'] /** * argument parser * @param {string[]} args * @returns {{cmd: object, opts: object}} */ export function argvParse(args) { initLogger() const argv = expand(args || process.argv.slice(2)) log.debug(argv) const opts = {} const cmd = {} while (argv.length) { const arg = argv.shift() if (actions[arg]) { actions[arg](cmd, argv) return { cmd, opts } } if (options[arg]) { options[arg](opts, argv) } } return { cmd, opts } } /** * expand options * @param {string[]} argv * @returns {string[]} */ function expand(argv) { const nArgv = [] for (const arg of argv) { if (/^-[a-zA-Z@+]+$/.test(arg)) { const shortArgs = arg.slice(1).split('') for (const short of shortArgs) { nArgv.push(`-${short}`) } } else { nArgv.push(arg) } } return nArgv } /** * try to get next argument from `argv` * @param {string[]} argv * @param {boolean} [filterAction] * @returns {string|undefined} */ function nextArg(argv, filterAction) { const next = argv[0] || '' const isAction = filterAction ? actions[next] : false if (next.indexOf('-') === 0 || isAction) { return } return argv.shift() } let _console = console /** * @private * @param {object} obj console log like object */ export function _injectConsole(obj) { _console = obj } const displayInfo = (msg) => { _console.info(`TODO: ${msg}`) } const displayError = (msg, color = (s) => s) => { _console.error(color(`TODO: ERROR: ${msg}`)) } const display = (msg) => _console.log(msg) const version = () => { const packageJson = new URL(PACKAGE_JSON, import.meta.url) const { version } = JSON.parse(fs.readFileSync(packageJson, 'utf-8')) display(version) } /** * execute todo from the commandline * @param {string[]} args * @returns {Promise<void>} */ export async function cli(args) { const { cmd, opts } = argvParse(args) log.debug({ cmd, opts, args }) const appConf = await AppConfig.load(opts) const colors = getColors({ ...appConf.getColorConfig(), ...opts }) try { if (cmd.help || opts.help) { display(renderHelp(colors, cmd.help[0])) return } if (cmd.version || opts.version) { version() return } let todoFile let tasks if (opts.todoDir) { todoFile = new FileAdapter(opts) tasks = await todoFile.load() } else { log.debug('loading todo.txt from global config') // no todo.txt then use todoDir from global config const todoDir = appConf.getTodoDir() todoFile = new FileAdapter({ ...opts, todoDir }) tasks = await todoFile.load() } if (!tasks) { tasks = new Tasks('') } const showIndex = digits(tasks.length) if (!Object.keys(cmd).length) { cmd.list = [] cmd.showCompleted = false } if (cmd.add) { const timestamp = opts.timestamp ?? true const added = tasks.add(cmd.add, { timestamp }) if (!added?.length) { // TODO read task from line with readline displayInfo('No task added') } else { await todoFile.store(tasks) display(added.stringify({ showIndex, colors })) } } else if (cmd.append) { const [id, term] = cmd.append const task = tasks.append(id, term) if (!task) { displayInfo(`No task ${id}`) } else { await todoFile.store(tasks) display(task.stringify({ showIndex, colors })) } } else if (cmd.archive) { const completed = tasks.archive() if (!completed?.length) { displayInfo('Nothing to archive') } else { await todoFile.store(tasks) await todoFile.archive(completed) display(completed.stringify({ showIndex: 0, colors })) } } else if (cmd.remove) { const [id, term] = cmd.remove const task = tasks.remove(id, term) if (!task) { displayInfo(`No task ${id}`) } else { await todoFile.store(tasks) display(task.stringify({ showIndex, colors })) } } else if (cmd.dePrioritize) { const dePrio = tasks.dePrioritize(cmd.dePrioritize) if (!dePrio?.length) { displayInfo(`No tasks found`) } else { await todoFile.store(tasks) display(dePrio.stringify({ showIndex, colors })) } } else if (cmd.done) { const done = tasks.done(cmd.done) if (!done?.length) { displayInfo(`No tasks found`) } else { await todoFile.store(tasks) display(done.stringify({ showIndex, colors })) } } else if (cmd.due) { const dateStr = cmd.due.pop() const done = tasks.due(cmd.due, dateStr) if (!done?.length) { displayInfo(`No tasks changed`) } else { await todoFile.store(tasks) display(done.stringify({ showIndex, colors })) } } else if (cmd.edit) { let editor = appConf.getEditor() || cmd.edit const e = editor.length ? [...editor, todoFile.todoFile] : [undefined, todoFile.todoFile] // @ts-expect-error launchEditor(...e) } else if (cmd.list) { const { list: terms, priorities, onlyProjects, onlyContexts, showCompleted } = cmd const { limit: optsLimit, timestamp, reverse } = opts const limit = !optsLimit ? undefined : optsLimit === LIMIT_FROM_CONF ? appConf.get('limit') || 10 : optsLimit const filtered = tasks.list(terms, { priorities, onlyProjects, onlyContexts, showCompleted }) if (!filtered?.length) { displayInfo(`No tasks found`) } else { const sortOrder = sorting[opts.sort] || (priorities ? sorting.pri : onlyProjects ? sorting.prj : onlyContexts ? sorting.con : sorting.due) filtered.sortBy(sortOrder) display( filtered.stringify({ reverse, showIndex, colors, limit, hideCreatedAt: !timestamp }) ) } } else if (cmd.overdue) { const done = tasks.overdue(cmd.overdue) if (!done?.length) { displayInfo(`No tasks changed`) } else { await todoFile.store(tasks) display(done.stringify({ showIndex, colors })) } } else if (cmd.prepend) { const [id, term] = cmd.prepend const task = tasks.prepend(id, term) if (!task) { displayInfo(`No task ${id}`) } else { await todoFile.store(tasks) display(task.stringify({ showIndex, colors })) } } else if (cmd.prioritize) { const priority = cmd.prioritize.pop() const ids = cmd.prioritize const task = tasks.prioritize(ids, priority) if (!task) { displayInfo(`No tasks found`) } else { await todoFile.store(tasks) display(task.stringify({ showIndex, colors })) } } else if (cmd.replace) { const [id, content] = cmd.replace const task = tasks.replace(id, content) if (!task) { displayInfo(`No task ${id}`) } else { await todoFile.store(tasks) display(task.stringify({ showIndex, colors })) } } else if (cmd.set) { const [key, val] = cmd.set const filename = AppConfig.getFilename(opts) const appConf = new AppConfig(filename) await appConf.read().catch(() => null) if (key) { appConf.set(key, val, !filename) await appConf.write() } // display config display(JSON.stringify(appConf.config, null, 2)) } else if (cmd.sort) { const sortOrder = sorting[cmd.sort] tasks.sortBy(sortOrder) await todoFile.store(tasks) } else if (cmd.undo) { const undo = tasks.undo(cmd.undo) if (!undo?.length) { displayInfo(`No tasks found`) } else { await todoFile.store(tasks) display(undo.stringify({ showIndex, colors })) } } else { displayError('unknown action', colors.error) } } catch (/** @type {Error|any} */ err) { log.debug(err) displayError(err.message, colors.error) } }