todo-txt-cli
Version:
A CLI for todo.txt files - http://todotxt.org/
417 lines (385 loc) • 8.78 kB
JavaScript
import os from 'os'
import { Task } from '#task.js'
import { sorter, sorting } from '#sorter.js'
import { logger } from '#logger.js'
import {
today,
escapeRegExp,
priorityToRegExp,
isoOrRelativeDate,
formatDate,
range
} from '#utils.js'
/**
* @typedef {import('#task.js').StringifyOptions} StringifyOptions
* @typedef {{limit?: number}} TaskLimit
* @typedef {StringifyOptions & TaskLimit} TaskSortOrder
*/
const LF = os.platform() === 'win32' ? '\r\n' : '\n'
const log = logger('tasks')
export class Tasks {
_index = 0
/** @type {Task[]} */
tasks = []
/**
* @param {string} [content]
*/
constructor(content = '') {
if (content) {
this.add(content)
}
}
getId() {
return this._idFn()
}
/**
* @private
*/
_idFn() {
return '' + ++this._index
}
push(task) {
this.tasks.push(task)
return this
}
get length() {
return this.tasks.length
}
/**
* add one or many tasks to tasks list
* @param {string} content
* @param {{timestamp?: boolean}} [options]
* @returns {Tasks|undefined}
*/
add(content = '', options) {
const { timestamp = false } = options || {}
const added = new Tasks()
const lines = content.split(/[\r\n]/)
for (const line of lines) {
if (line.trim() === '') {
continue
}
const task = Task.parse(line, this.getId())
if (timestamp) task.createdAt = today()
this.tasks.push(task)
added.push(task)
}
return added
}
/**
* deletes task with id. If `term` is given then only term is removed from the
* task
* @param {string} id
* @param {string} [term]
* @returns {Task|undefined}
*/
remove(id, term) {
const { task, index } = this.findIndex(id)
if (!task) {
return
}
if (term) {
if (!task) return
task.update(task.raw.replace(term, ''))
} else {
this.tasks.splice(index, 1)
}
return task
}
/**
* append `term` to task with `id`
* @param {string} id
* @param {string} term
* @returns {Task|undefined}
*/
append(id, term = '') {
const { task } = this.findIndex(id)
if (!task || !term) {
return
}
task.update(`${task.raw} ${term.trim()}`)
return task
}
/**
* prepend `term` to task with `id`
* @param {string} id
* @param {string} term
* @returns {Task|undefined}
*/
prepend(id, term) {
const { task } = this.findIndex(id)
if (!task || !term) {
return
}
task.update(`${term.trim()} ${task.raw}`)
return task
}
replace(id, content) {
const { task } = this.findIndex(id)
if (!task || !content) {
return
}
task.update(content)
return task
}
/**
* @param {string[]} ids
* @returns {Tasks}
*/
done(ids) {
const tasks = new Tasks('')
const _ids = range(ids)
for (const id of _ids) {
const { task } = this.findIndex(id)
if (!task) {
continue
}
task.complete()
tasks.push(task)
}
return tasks
}
/**
* @param {string[]} ids
* @param {string} dateStr iso-date YYYY-MM-DD or relative-date +1month2weeks3days (1m2w3d)
* @returns {Tasks|undefined}
*/
due(ids, dateStr) {
const date = isoOrRelativeDate(dateStr)
if (!date && dateStr !== 'del') {
return
}
const tasks = new Tasks('')
const _ids = range(ids)
for (const id of _ids) {
const { task } = this.findIndex(id)
if (!task) {
continue
}
task.due(date)
tasks.push(task)
}
return tasks
}
/**
* correct overdue tasks
* @param {string} dateStr iso-date YYYY-MM-DD or relative-date +1month2weeks3days (1m2w3d)
* @returns {Tasks|undefined}
*/
overdue(dateStr = formatDate(today())) {
const date = isoOrRelativeDate(dateStr)
if (!date && dateStr !== 'del') {
return
}
const now = today()
const tasks = new Tasks('')
for (const task of this.tasks) {
if (task.isCompleted || !task.dueAt || task.dueAt > now) continue
task.due(date)
tasks.push(task)
}
return tasks
}
/**
* @param {string[]} ids
* @param {string} [priority]
* @return {Tasks}
*/
prioritize(ids, priority) {
const tasks = new Tasks()
if (!priority) {
return tasks
}
const _ids = range(ids)
for (const id of _ids) {
const { task } = this.findIndex(id)
if (!task) {
continue
}
task.prioritize(priority)
tasks.push(task)
}
return tasks
}
/**
* @param {string[]} ids
* @return {Tasks}
*/
dePrioritize(ids) {
const tasks = new Tasks()
const _ids = range(ids)
for (const id of _ids) {
const { task } = this.findIndex(id)
if (!task) {
continue
}
task.prioritize()
tasks.push(task)
}
return tasks
}
/**
* archive completed tasks
* @return {Tasks}
*/
archive() {
const active = []
const completed = new Tasks('')
for (const task of this.tasks) {
if (task.isCompleted) {
completed.push(task)
} else {
active.push(task)
}
}
this.tasks = active
return completed
}
/**
* list (filter) tasks according to terms
* @param {(string|RegExp)[]} [terms]
* @param {{
* priorities?: string
* onlyProjects?: boolean
* onlyContexts?: boolean
* showCompleted?: boolean
* }} [options]
* @return {Tasks}
*/
list(terms = [], options) {
const {
priorities = '',
onlyProjects = false,
onlyContexts = false,
showCompleted = false
} = options || {}
const incRes = []
const excRes = []
for (let term of terms) {
let exclude = false
if (typeof term === 'string' && term.startsWith('-')) {
exclude = true
term = term.slice(1)
}
const re = term instanceof RegExp ? term : new RegExp(escapeRegExp(term))
if (exclude) {
excRes.push(re)
} else {
incRes.push(re)
}
}
if (!incRes.length) {
incRes.push(new RegExp('.'))
}
const filtered = new Tasks()
const prioRe = priorityToRegExp(priorities)
// inappropriate priorities filter -> early exit
if (priorities && !prioRe) {
return filtered
}
for (const task of this.tasks) {
let search = ''
if (onlyProjects) {
search = [...task.projects].join(' ')
} else if (onlyContexts) {
search = [...task.contexts].join(' ')
} else {
search = task.raw
}
if (!showCompleted && task.isCompleted) {
continue
}
if (prioRe && !prioRe.test(task.priority || '')) {
continue
}
let exclude = false
for (const exc of excRes) {
if (exc.test(search)) {
exclude = true
break
}
}
if (exclude) continue
for (const inc of incRes) {
if (!inc.test(search)) {
exclude = true
break
}
}
if (!exclude) filtered.push(task)
}
return filtered
}
/**
* @param {string} id
* @returns {{index: number, task?: Task}}
*/
findIndex(id) {
const index = this.tasks.findIndex((task) => task.id === id)
return { index, task: index > -1 ? this.tasks[index] : undefined }
}
/**
* sort task list by sort order
* @param {string[]} [sortOrder]
* @returns {this}
*/
sortBy(sortOrder) {
sortOrder = sortOrder?.length ? sortOrder : sorting.due
log.debug('sortOrder %j', sortOrder)
this.tasks.sort(sorter(sortOrder))
return this
}
/**
* sort task projects and contexts
* @param {TaskSortOrder} taskSortOrder
* @returns {this}
*/
sortTaskContent(taskSortOrder) {
for (const task of this.tasks) {
task.raw = task.stringify(taskSortOrder)
}
return this
}
/**
* reverse task list sort order
* @returns {this}
*/
reverse() {
this.tasks.reverse()
return this
}
/**
* @param {TaskSortOrder & {reverse?: boolean}} [options]
* @returns {string}
*/
stringify(options) {
const { reverse, limit, ...opts } = options || {}
const list = []
let i = 0
for (const task of this.tasks) {
list.push(task.stringify(opts))
i++
if (limit && i >= limit) {
break
}
}
return (reverse ? list.reverse() : list).join(LF)
}
/**
* @param {string[]} ids
* @returns {Tasks}
*/
undo(ids) {
const tasks = new Tasks('')
const _ids = range(ids)
for (const id of _ids) {
const { task } = this.findIndex(id)
if (!task?.isCompleted) {
continue
}
task.complete(false)
tasks.push(task)
}
return tasks
}
}