todo-txt-cli
Version:
A CLI for todo.txt files - http://todotxt.org/
431 lines (408 loc) • 11.7 kB
JavaScript
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)
}
}