UNPKG

todo-txt-cli

Version:

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

477 lines (449 loc) 12.9 kB
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()]