todo-txt-cli
Version:
A CLI for todo.txt files - http://todotxt.org/
477 lines (449 loc) • 12.9 kB
JavaScript
import { getColors } from '#colors.js'
import { today, formatDate, isDate, isoOrRelativeDate } from '#utils.js'
/**
* @typedef {import('#colors.js').Colors} Colors
*/
/**
* @typedef {{
* id?: string
* isCompleted: boolean
* priority?: string
* projects?: string[]
* contexts?: string[]
* fields?: Record<string, string[]>
* createdAt?: string | Date
* dueAt?: string | Date
* completedAt?: string | Date
* task?: string
* }} JTask
*/
/**
* @typedef {object} StringifyOptions
* @property {number} [sortTask=0] sorting: 0 = none, 1 = ascending
* @property {number} [orderTask=0] ordering: 0 = normal, 1 = dueDate, project,
* context before task description
* @property {number} [showIndex=0] show task index num chars wide
* @property {boolean} [hideProjects=false] hide project names
* @property {boolean} [hideContexts=false] hide context names
* @property {boolean} [hideCreatedAt=false] hide createdAt timestamp names
* @property {Colors} [colors] colors for displaying tasks
*/
/**
* @typedef {object} TaskOptions
* @property {number} [sortTask=0] sorting for projects, contexts, fields: 0 ==
* none, 1 = ascending
* @property {number} [orderTask=0] order for stringify(): 0 == normal, 1 == if
* tasks are longer dueDate, projects, contexts before task description
*/
const RE_PRIO = /^\([A-Z]\)$/
const RE_DATE = /^\d{4}-\d{2}-\d{2}$/
const RE_PROJECT = /^[+].{1,50}$/u
const RE_CONTEXT = /^@.{1,50}$/u
// field key must not contain ASCII Punctuation & Symbols
const RE_KEY = /^[^\u0020-\u002f]{1,50}$/
const RE_FIELDS = /^[^\u0020-\u002f]{1,50}:.{1,512}/u
const RE_URL_LIKE = /^[a-zA-Z]{1,16}:\/\//
export class Task {
isCompleted = false
/** @type {string|undefined} */
priority = undefined
/** @type {Set<string>} */
projects = new Set()
/** @type {Set<string>} */
contexts = new Set()
/** @type {Map<string, Set<string>>} */
fields = new Map()
/** @type {Date|undefined} */
createdAt = undefined
/** @type {Date|undefined} */
dueAt = undefined
/** @type {Date|undefined} */
completedAt = undefined
/** @type {string} */
task = ''
/** @type {{type: string, value: string, key?: string}[]} */
tokens = []
/**
* @see https://github.com/todotxt/todo.txt
* @param {string} raw
* @param {string} [id]
*/
constructor(raw, id) {
this.id = id
this.update(raw)
}
get raw() {
return this.stringify()
}
set raw(raw) {
this.update(raw)
}
_clear() {
this.isCompleted = false
this.priority = undefined
/** @type {Set<string>} */
this.projects = new Set()
/** @type {Set<string>} */
this.contexts = new Set()
/** @type {Map<string, Set<string>>} */
this.fields = new Map()
this.createdAt = undefined
this.dueAt = undefined
this.completedAt = undefined
this.task = ''
this.tokens = []
}
/**
* @param {string} raw
*/
update(raw) {
this._clear()
const tokens = raw.trim().split(/\s+/)
const addToTask = (token) => (this.task += `${token} `)
const setField = (token) => {
const [key] = token.split(/:/)
token = token.slice(key.length + 1)
this._setField(key, token)
return { key, token }
}
let i = 0
for (let token of tokens) {
let type = ''
let key
if (i === 0 && token === 'x') {
this.isCompleted = true
type = 'x'
} else if (RE_PRIO.test(token)) {
if (this.priority) {
this.task += `${token} `
} else {
this.priority = token.slice(1, -1)
type = 'pri'
}
} else if (RE_PROJECT.test(token)) {
token = token.slice(1)
this.projects.add(token)
type = '+'
} else if (RE_CONTEXT.test(token)) {
token = token.slice(1)
this.contexts.add(token)
type = '@'
} else if (token.startsWith('due:')) {
const dueAtDate = isoOrRelativeDate(token.slice(4))
if (!isDate(dueAtDate)) {
continue
} else if (!this.dueAt) {
this.dueAt = new Date(formatDate(dueAtDate))
type = 'dueDate'
} else {
const ret = setField(token)
key = ret.key
token = ret.token
type = 'field'
}
} else if (RE_DATE.test(token)) {
if (isDate(token) && !this.task) {
const date = new Date(token)
if (this.isCompleted && !this.completedAt) {
// first date on completed task is the completion date
this.completedAt = date
type = 'completedAt'
} else {
this.createdAt = date
type = 'createdAt'
}
} else {
addToTask(token)
}
} else if (RE_FIELDS.test(token) && !RE_URL_LIKE.test(token)) {
const ret = setField(token)
key = ret.key
token = ret.token
type = 'field'
} else {
addToTask(token)
}
this.tokens.push({ type, value: token, key })
i++
}
this.task = this.task.trim()
}
/**
* @param {string} raw
* @param {string} [id]
* @returns {Task}
*/
static parse(raw, id) {
return new Task(raw, id)
}
/**
* @param {object} json
* @param {string} [id]
* @returns {Task}
*/
static fromJson(json, id) {
const task = new Task('', id)
for (const key of Object.keys(json)) {
if (key in task && typeof task[key] !== 'function') {
const value = json[key]
if (['completedAt', 'createdAt'].includes(key) && isDate(value)) {
task[key] = new Date(value)
} else if (key === 'fields' && typeof value === 'object') {
task[key] = new Map()
for (const [k, v] of Object.entries(value)) {
if (!Array.isArray(v)) {
throw new TypeError(`fields[${k}] is not an array`)
}
task[key].set(k, new Set(v.map((i) => '' + i)))
}
} else if (
['projects', 'contexts'].includes(key) &&
Array.isArray(value)
) {
task[key] = new Set(value)
} else {
task[key] = value
}
}
}
return task
}
/**
* mark task as completed. If `flag==false` task is "undone"
* @param {boolean} flag
*/
complete(flag = true) {
this.isCompleted = flag
this.completedAt = undefined
if (flag) {
this.completedAt = today()
}
}
prioritize(priority) {
if (!priority) {
this.priority = undefined
return
}
if (!/^[A-Z]$/i.test(priority)) {
throw new Error('priority must be anywhere from A to Z')
}
this.priority = priority.toUpperCase()
}
/**
* @private
* @param {string} key
* @param {string} value
*/
_setField(key, value = '') {
if (!RE_KEY.test(key)) {
throw new TypeError('invalid key')
}
const values = this.fields.get(key) || new Set()
values.add(value)
this.fields.set(key, values)
}
/**
* @param {string} key
* @param {string} value
*/
setField(key, value = '') {
const _value = value?.trim()
this._setField(key, _value)
let found = false
for (const token of this.tokens) {
const { type, key: _key } = token
if (type === 'field' && _key === key) {
found = true
return
}
}
if (!found) {
this.tokens.push({ type: 'field', key, value: _value })
}
}
/**
* Delete field value. If value == `null` then key with all values is deleted
* @param {string} key
* @param {string|undefined} value
* @returns {boolean}
*/
deleteField(key, value = '') {
const _value = value?.trim()
if (key === 'due') {
if (!_value) {
this.dueAt = undefined
this.tokens = this.tokens.filter(({ type }) => type !== 'dueDate')
return true
} else if (isDate(_value)) {
this.dueAt = new Date(_value)
return true
}
return false
}
const values = this.fields.get(key)
if (!values || (_value && !values.has(_value))) {
return false
}
if (_value) {
values.delete(_value)
this.fields.set(key, values)
} else {
this.fields.delete(key)
}
return true
}
/**
* @param {Date|undefined} date
*/
due(date) {
if (isDate(date)) {
this.dueAt = date
const hasTokenDueDate = this.tokens.some(({ type }) => type === 'dueDate')
if (!hasTokenDueDate) {
this.tokens.unshift({ type: 'dueDate', value: '' })
}
} else {
this.dueAt = undefined
}
}
/**
* output as todo.txt line
* @param {StringifyOptions} [options]
* @returns {string}
*/
stringify(options) {
const {
sortTask,
orderTask = 0,
showIndex = 0,
hideProjects = false,
hideContexts = false,
hideCreatedAt = false,
colors = getColors({ useColor: false })
} = options || {}
const sorter = sortTask === 1 ? ascSorter : undefined
const colorDone = this.isCompleted ? colors.done : undefined
const colorPri = colors[`pri${this.priority}`] || colors.none
const tokens = []
if (showIndex) {
tokens.push(colorPri((this.id || '').padStart(showIndex, ' '), colorDone))
}
if (this.completedAt) {
this.isCompleted = true
}
if (this.isCompleted) {
tokens.push(colors.done('x'))
}
if (this.priority) {
tokens.push(colorPri(`(${this.priority})`, colorDone))
}
if (this.completedAt) {
tokens.push(colors.done(formatDate(this.completedAt)))
}
if (!hideCreatedAt && this.createdAt) {
tokens.push(colors.date(formatDate(this.createdAt), colorDone))
}
if (!sortTask && !orderTask) {
for (const { type, value, key } of this.tokens) {
switch (type) {
case 'x':
case 'completedAt':
case 'pri':
case 'createdAt':
break
case '+':
if (!hideProjects && this.projects.has(value)) {
tokens.push(colors.project(`+${value}`, colorDone))
}
break
case '@':
if (!hideContexts && this.contexts.has(value)) {
tokens.push(colors.context(`@${value}`, colorDone))
}
break
case 'dueDate':
if (this.dueAt) {
tokens.push(
colors.date(`due:${formatDate(this.dueAt)}`, colorDone)
)
}
break
case 'field': {
const values = this.fields.get(key || '')
if (values?.has(value)) {
tokens.push(colors.none(`${key}:${value}`, colorDone))
}
break
}
default:
tokens.push(colors.none(value, colorDone))
}
}
return tokens.join(' ')
}
// ... no tokens or not raw...
if (orderTask === 1 && this.dueAt) {
tokens.push(colors.date(`due:${formatDate(this.dueAt)}`, colorDone))
}
if (orderTask !== 1 && this.task) {
tokens.push(colors.none(this.task, colorDone))
}
if (!hideProjects) {
setSorter(this.projects, sorter).forEach((p) =>
tokens.push(colors.project(`+${p}`, colorDone))
)
}
if (!hideContexts) {
setSorter(this.contexts, sorter).forEach((c) =>
tokens.push(colors.context(`@${c}`, colorDone))
)
}
if (orderTask === 1 && this.task) {
tokens.push(colors.none(this.task, colorDone))
}
if (orderTask !== 1 && this.dueAt) {
tokens.push(colors.date(`due:${formatDate(this.dueAt)}`, colorDone))
}
for (const key of keySorter(this.fields, sorter)) {
const values = setSorter(this.fields.get(key), sorter)
for (const value of values) {
tokens.push(colors.none(`${key}:${value}`, colorDone))
}
}
return tokens.join(' ')
}
/**
* output as JSON object
* @returns {JTask}
*/
toJSON() {
// eslint-disable-next-line no-unused-vars
const { projects, contexts, fields, tokens, ...o } = this
// @ts-expect-error
o.raw = this.raw
if (this.projects.size) {
// @ts-expect-error
o.projects = setSorter(this.projects)
}
if (this.contexts.size) {
// @ts-expect-error
o.contexts = setSorter(this.contexts)
}
if (this.fields.size) {
for (const key of keySorter(this.fields)) {
// @ts-expect-error
if (!o.fields) {
// @ts-expect-error
o.fields = {}
}
// @ts-expect-error
o.fields[key] = setSorter(this.fields.get(key))
}
}
return o
}
}
const ascSorter = (a, b) => String(a).localeCompare(String(b))
const setSorter = (set, sorter) => (sorter ? [...set].sort(sorter) : [...set])
const keySorter = (map, sorter) =>
sorter ? [...map.keys()].sort(sorter) : [...map.keys()]