UNPKG

todo.txt

Version:

A todo.txt-format parser and generator

769 lines (676 loc) 23.7 kB
/** * todotxt.js 0.0.1 * @overview A parser/generator for the todo.txt format * @project todotxt * @author Ross Masters <ross@rossmasters.com> * @version 0.0.1 * @license todotxt.js may be freely distributed under the MIT license * @copyright Ross Masters 2013 */ var todotxt = (function () { "use strict"; /** * @class * @classdesc An individual task * @constructor * @param {String} [item=undefined] - The task as a line of text */ function TodoItem(item) { // Core TaskLine regex parsing (completed, priority, created and task) var completed_ptn = /x ([1-2][0-9]{3}\-[0-1][0-9]\-[0-3][0-9])/, priority_ptn = /\(([A-Z])\)/, created_ptn = /([1-2][0-9]{3}\-[0-1][0-9]\-[0-3][0-9])/; this.core_ptn = new RegExp('^(' + completed_ptn.source + '\\s|' + priority_ptn.source + '\\s)?(' + created_ptn.source + '\\s)?(.+)'); // Embedded task metadata parsing this.project_ptn = /\+(\S+[_A-Za-z0-9])(\s|$)/g; this.context_ptn = /@(\S+[_A-Za-z0-9])(\s|$)/g; this.metadata_ptn = /(\S+[_A-Za-z0-9]):(\S+)(\s|$)/g; this.reset(); if (undefined !== item) { return this.parse(item); } } /** * Reset the item's properties * @private * @return {TodoItem} */ TodoItem.prototype.reset = function () { this.id = null; this.text = null; this.priority = null; this.completedAt = false; this.createdAt = new Date(); this.contexts = []; this.projects = []; this.metadata = {}; return this; }; /** * Parse a line into it's parts * @param {String} line * @return {TodoItem} */ TodoItem.prototype.parse = function (line) { var res, projects, contexts, metadata, prj, ctx, md; this.reset(); line = line.trim(); if (line.length === 0) { return this; } res = this.core_ptn.exec(line); this.completedAt = (undefined !== res[2]) ? new Date(res[2]) : this.completedAt; this.priority = (undefined !== res[3]) ? res[3] : this.priority; this.createdAt = (undefined !== res[5]) ? new Date(res[5]) : this.createdAt; this.text = res[6].trim(); function getMatches(regex, input) { var match, matches = [], m, i; while ((match = regex.exec(input)) !== null) { m = []; for (i = 0; i < match.length; i += 1) { if (parseInt(i, 10) === i) { m.push(match[i]); } } matches.push(m); } return matches; } // Capture projects projects = getMatches(this.project_ptn, this.text); for (prj = 0; prj < projects.length; prj += 1) { if (this.projects.indexOf(projects[prj][1]) === -1) { this.projects.push(projects[prj][1]); } } // Capture contexts contexts = getMatches(this.context_ptn, this.text); for (ctx = 0; ctx < contexts.length; ctx += 1) { if (this.contexts.indexOf(contexts[ctx][1]) === -1) { this.contexts.push(contexts[ctx][1]); } } // Capture metadata // Subsequent metadata overwrites the previous value metadata = getMatches(this.metadata_ptn, this.text); for (md = 0; md < metadata.length; md += 1) { this.metadata[metadata[md][1]] = metadata[md][2]; } return this; }; /** * Complete a task * @return {TodoItem} */ TodoItem.prototype.complete = function () { this.completedAt = new Date(); return this; }; /** * Is the task complete? * @return {Boolean} - True if a completion date is set. */ TodoItem.prototype.isCompleted = function () { return this.completedAt instanceof Date; }; /** * Get the task line * @param {Boolean} [with_id=false] Include the task id in output * @return {String} Task as a string */ TodoItem.prototype.toString = function (with_id) { var date_str = function (date) { var month = date.getMonth() + 1; return date.getFullYear() + '-' + (month < 10 ? '0' : '') + month + '-' + date.getDate(); }; return (with_id ? this.id + ': ' : '') + (this.completedAt ? 'x ' + date_str(this.completedAt) + ' ' : '') + (this.priority ? '(' + this.priority + ') ' : '') + (this.createdAt ? date_str(this.createdAt) + ' ' : '') + this.text; }; /** * Append to the .text of the item with a preceding space * @param {String} text - Text to append * @return {TodoItem} */ TodoItem.prototype.append = function (text) { this.text += " " + text; return this; }; /** * Prepend to the .text of the item with a preceding space * @param {String} text - Text to prepend * @return {TodoItem} */ TodoItem.prototype.prepend = function (text) { this.text = text + " " + this.text; return this; }; /** * Deprioritise the item * @return {TodoItem} */ TodoItem.prototype.deprioritise = function () { this.priority = null; return this; }; /** * @class * @classdesc A list of task items * @constructor */ function TodoList() { // Task items this.items = []; // Mappings of task IDs, projects and contexts to array indexes of this.items this.indexes = { 'id': {}, 'project': {}, 'context': {} }; // Filename, or false for in-memory this.filename = false; } /** * Add an item to the list * If the item already has an ID it will be overwritten. * @param {TodoItem|String} item - A task to add to the list * @return {TodoItem|Boolean} - False if the item was empty */ TodoList.prototype.add = function (item) { if (!(item instanceof TodoItem)) { item = new TodoItem(item); } if (null === item.text || item.text.length === 0) { return false; } var id = this.items.push(item); this.items[id - 1].id = id; this.indexes.id[id] = id - 1; this.index(this.items[id - 1]); this.sort(); return this.items[this.indexes.id[id]]; }; /** * Append to an item * @param {Integer} id - Task item id to append to * @param {String} text - Text to append to the task * @return {TodoItem|Boolean} - Task or false if not found */ TodoList.prototype.append = function (id, text) { var item = this.findById(id); if (false !== item) { return item.append(text); } else { return false; } }; /** * Prepend text to a task (with a trailing space) * @param {Integer} id - Task id * @param {String} text - Text to prepend * @return {TodoItem|Boolean} - Task or false if not found */ TodoList.prototype.prepend = function (id, text) { var item = this.findById(id); if (false !== item) { return item.prepend(text); } else { return false; } }; /** * Deduplicate a list * @return {TodoList} */ TodoList.prototype.deduplicate = function () { var i_text, changed = false; do { changed = false; // For each item, check if the lowercase .text matches another for (var i = 0; i < this.items.length; i++) { // Lowercased i_text = this.items[i].text.toLocaleLowerCase(); // For each item, if it matches, remove it for (var j = 0; j < this.items.length; j++) { // Skip if it's the same item if (this.items[i].id === this.items[j].id) { continue; } // Remove if it matches (and restart as the list will be // altered) if (i_text === this.items[j].text.toLocaleLowerCase()) { this.remove(this.items[j].id); changed = true; break; } } if (changed) { break; } } } while(changed); return this; }; /** * Remove an item from the list * @param {Integer} id - Task item id to remove * @return {TodoItem|Boolean} - Task or false if not found */ TodoList.prototype.remove = function (id) { var item = this.findById(id); if (item) { // Remove from items array (and re-index by ID) this.items.splice(this.indexes.id[id], 1); this.reindex(); // De-index this.deindex(item); item.id = null; return item; } else { return false; } }; /** * Complete one or more items * @param {Integer} [...] - Task item id(s) to complete * @return {TodoList} */ TodoList.prototype.complete = function () { var item; for (var i = 0; i < arguments.length; i++) { item = this.findById(arguments[i]); if (false !== item) { item.complete(); } } return this; }; /** * List all incomplete items in the list * @param {String|Array} [term=[]] - Terms to match against * @param {String|Array} [priority=[]] - Priorities the items should have * @param {Boolean} [case_sensitive=false] - If terms are case sensitive * @param {Boolean} [completed=false] - Include completed tasks * @return {Array} - Item list */ TodoList.prototype.list = function (term, priority, case_sensitive, completed) { var terms, priorities, match, p, start, end, c, items, i, matchTerms, t, hasPriority; // Term-search case sensitivity (default insensitive) case_sensitive = (case_sensitive !== undefined) ? !!case_sensitive : false; // Whether completed tasks are included completed = (completed !== undefined) ? !!completed : false; // Build a list of terms to check against terms = []; if (undefined !== term) { if (typeof term === 'string') { terms.push(term); } else if (term instanceof Array) { terms.concat(term); } } // Build a list of matching priorities to check against priorities = []; if (undefined !== priority) { if (priority instanceof Array) { // A list of valid priorities (priority=[A,B,C]) for (p = 0; p < priority.length; p += 1) { if (null !== /^[A-Za-z]$/.exec(priority[p])) { priorities.push(priority[p].toLocaleUpperCase()); } else { throw "Invalid property " + priority[p]; } } } else if (null !== (match = /^([A-Za-z])$/.exec(priority))) { // Check for single priority match (priority=A) priorities.push(match[1]); } else if (null !== (match = /^([A-Z])-([A-Z])$/.exec(priority))) { // A range of valid priorities (priority=A-D) start = match[1].toLocaleUpperCase(); end = match[2].toLocaleUpperCase(); // Flip if we have something like C-A if (start.localeCompare(end) === 1) { start = match[2].toLocaleUpperCase(); end = match[1].toLocaleUpperCase(); } for (c = start.charCodeAt(0); c <= end.charCodeAt(0); c += 1) { priorities.push(String.fromCharCode(i)); } } } // Find matching items items = []; for (i = 0; i < this.items.length; i += 1) { if (!this.items[i].completedAt || completed) { // Require a match against all terms matchTerms = true; for (t = 0; t < terms.length; t += 1) { if (!this.checkTerm(terms[t], this.items[i], case_sensitive)) { matchTerms = false; break; } } // Require at least one priority match hasPriority = true; if (matchTerms && priorities.length > 0) { hasPriority = false; for (p = 0; i < priorities.length; p += 1) { if (this.items[i].priority === p) { hasPriority = true; break; } } } if (hasPriority && matchTerms) { items.push(this.items[i]); } } } this.sort(items); return items; }; /** * List all tasks (including completed tasks) * @param {String|Array} [term=[]] - Terms to match against * @param {String|Array} [priority=[]] - Priorities the items should have * @param {Boolean} [case_sensitive=false] - If terms are case sensitive */ TodoList.prototype.listAll = function (term, priority, case_sensitive) { return this.list(term, priority, case_sensitive, true); }; /** * List contexts of task items * @return {Array} */ TodoList.prototype.contexts = function () { return Object.keys(this.indexes.context); }; /** * List projects of task items * @return {Array} */ TodoList.prototype.projects = function () { return Object.keys(this.indexes.project); }; /** * Deprioritise an item * @param {Integer} id - Task item id to deprioritise * @return {TodoItem|Boolean} - Task or false if not found */ TodoList.prototype.deprioritise = function (id) { var item = this.findById(id); if (false !== item) { return item.deprioritise(); } else { return item; } }; /** * Prioritise a task * @param {Integer} id - Task id * @param {String} priority - Priority to set * @return {TodoItem|Boolean} - Task or false if not found */ TodoList.prototype.prioritise = function (id, priority) { var item = this.findById(id); if (false !== item) { return item.priority = priority; } else { return item; } }; /** * Replace the text of an item * @param {Integer} id - Task id * @param {String} text - New task text * @return {TodoItem|Boolean} - Task or false if not found */ TodoList.prototype.replace = function (id, task) { var item = this.findById(id); if (false !== item) { return item.parse(task); } else { return item; } }; /** * Generate a report on tasks * @todo */ TodoList.prototype.report = function () { }; /** * Ensure an item's id, context and project are indexed * @private * @property {TodoItem} item - Item to index */ TodoList.prototype.index = function (item) { var prj, p, ctx, c; // Check item has an id if (null === item.id) { item.id = this.items.length; } // Check item id is in id->{items[idx]} index if (!this.indexes.id.hasOwnProperty(item.id)) { this.indexes.id[item.id] = this.items.indexOf(item); } // Check item's projects are in project->{items[idx]} index for (p = 0; p < item.projects.length; p += 1) { prj = item.projects[p]; if (!this.indexes.project.hasOwnProperty(prj)) { this.indexes.project[prj] = []; } if (this.indexes.project[prj].indexOf(item.id) === -1) { this.indexes.project[prj].push(item.id); } } // Check item's contexts are in context->{items[idx]} index for (c = 0; c < item.contexts.length; c += 1) { ctx = item.contexts[c]; if (!this.indexes.context.hasOwnProperty(ctx)) { this.indexes.context[ctx] = []; } if (this.indexes.context[ctx].indexOf(item.id) === -1) { this.indexes.context[ctx].push(item.id); } } }; /** * Re-index the list by ID * @return {TodoList} */ TodoList.prototype.reindex = function() { this.indexes.id = {}; for (var i = 0; i < this.items.length; i++) { this.indexes.id[this.items[i].id] = i; } return this; }; /** * De-index an item * @param {TodoItem} item - Item to de-index * @return {TodoList} */ TodoList.prototype.deindex = function(item) { var prj, prj_idx, ctx, ctx_idx; // Remove the id->list_index entry if (this.indexes.id.hasOwnProperty(item.id)) { delete this.indexes.id[item.id]; } // Remove project->id[] indexes for this item for (prj = 0; prj < item.projects.length; prj += 1) { prj_idx = this.indexes.project[item.projects[prj]].indexOf(item.id); if (prj_idx !== -1) { this.indexes.project[item.projects[prj]].splice(prj_idx, 1); // Remove project index when no tasks left that use it if (Object.keys(this.indexes.project[item.projects[prj]]).length === 0) { delete this.indexes.project[item.projects[prj]]; } } } // Remove context->id[] indexes for this item for (ctx = 0; ctx < item.contexts.length; ctx += 1) { ctx_idx = this.indexes.context[item.contexts[ctx]].indexOf(item.id); if (ctx_idx !== -1) { this.indexes.context[item.contexts[ctx]].splice(ctx_idx, 1); // Remove context index when no tasks left that use it if (Object.keys(this.indexes.context[item.contexts[ctx]]).length === 0) { delete this.indexes.context[item.contexts[ctx]]; } } } return this; }; /** * Check if a term definition matches a task * @protected * @param {Array|String} term - A list of keywords to match in task.text * @param {TodoItem} task - Task to match against * @param {Boolean} [case_sensitive=false] - Make a case sensitive check * @return {Boolean} Whether the task is matched * * This is similar to the term matching in todotxt-cli: * - Terms prefixed with - (minus, dash) require tasks to not * contain them. * - Terms prefixed with @ or + match contexts and projects * respectively. These can also be prefixed with - (minus) to * exclude contexts or projects. * - Terms are combined with AND by default. * - Terms that are an array of terms are combined with OR. E.g.: * a term `['python', 'java', '@tests']` matches tasks containing * 'python' or 'java' or with the context '@tests'. */ TodoList.prototype.checkTerm = function (term, task, case_sensitive) { var res, t, not; // Term-search case sensitivity (default insensitive) case_sensitive = (case_sensitive !== undefined) ? !!case_sensitive : false; if (term instanceof Array) { // A list of terms, if one of them returns true, return true overall res = false; for (t = 0; t < term.length; t += 1) { if (this.checkTerm(term[t], task)) { res = true; break; } } } else { term = term.trim(); not = false; if (term.length > 0 && term.substring(0, 1) === '-') { not = true; term = term.substring(1); } if (term.length === 0) { res = true; } else { // If term is not present return not, otherwise !not if (!case_sensitive) { res = (task.text.indexOf(term) === -1) ? not : !not; } else { res = (task.text.toLocaleLowerCase().indexOf(term.toLocaleLowerCase()) === -1) ? not : !not; } } } return res; }; /** * Sort a list of items in-place by priority and then alphabetically * @param {Array} [items=this.items] * @return {Array} */ TodoList.prototype.sort = function (items) { var changed, i, tmp; if (items === undefined) { items = this.items; } changed = false; do { changed = false; for (i = 0; i < items.length - 1; i += 1) { // Compare toString output which naturally puts priority items at the top if (items[i].toString().toLocaleLowerCase().localeCompare(items[i + 1].toString().toLocaleLowerCase()) > 0) { tmp = items[i + 1]; items[i + 1] = items[i]; items[i] = tmp; // Update indexes this.indexes.id[items[i].id] = i; this.indexes.id[items[i + 1].id] = i + 1; changed = true; } } } while (changed); return items; }; /** * Parse a new-line separated list of tasks * @param {String} tasks_src - \n-separated list of tasks * @return {TodoList} */ TodoList.prototype.parse = function (tasks_src) { var tasks = tasks_src.replace("\r", "").split("\n"), task; for (task = 0; task < tasks.length; task += 1) { this.add(tasks[task]); } return this; }; /** * Find an item by id (number added) * @param {Integer} id - Item id * @return {TodoItem|Boolean} False if item not found */ TodoList.prototype.findById = function (id) { if (this.indexes.id.hasOwnProperty(id)) { return this.items[this.indexes.id[id]]; } return false; }; /** * Get the filename the list was parsed from, or 'memory'. * @return {String} */ TodoList.prototype.source = function () { return this.filename || 'memory'; }; return { 'TodoList': TodoList, 'TodoItem': TodoItem }; }(this)); if (typeof module !== "undefined" && module.exports) { module.exports = exports = todotxt; } else if (typeof exports !== "undefined") { exports = todotxt; }