├── .gitignore ├── index.js ├── test ├── doc-example.json ├── heirarchical.coffee └── standard.coffee ├── package.json ├── LICENSE ├── lib └── parser.coffee └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require("coffeescript/register"); 2 | module.exports = require("./lib/parser"); -------------------------------------------------------------------------------- /test/doc-example.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "raw": "x 2014-07-04 (A) 2014-06-19 Document YTD spending on +SocialEvents for @Alex due:2014-08-01", 4 | "text": "Document YTD spending on +SocialEvents for @Alex due:2014-08-01", 5 | "projects": ["SocialEvents"], 6 | "contexts": ["Alex"], 7 | "complete": true, 8 | "dateCreated": "2014-06-19T00:00:00.000Z", 9 | "dateCompleted": "2014-07-04T00:00:00.000Z", 10 | "priority": "A", 11 | "metadata": {"due": "2014-08-01"}, 12 | "subtasks": [], 13 | "indentLevel": 2 14 | } 15 | ] -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "todotxt-parser", 3 | "version": "1.0.2", 4 | "description": "A parser for Gina Trapani's todo.txt format with optional extended features", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha --require coffeescript/register ./test/*.coffee" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/csauve/todotxt-parser.git" 12 | }, 13 | "keywords": [ 14 | "todo.txt", 15 | "parser" 16 | ], 17 | "author": "csauve", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/csauve/todotxt-parser/issues" 21 | }, 22 | "homepage": "https://github.com/csauve/todotxt-parser", 23 | "dependencies": { 24 | "coffeescript": "^2.3.2", 25 | "underscore": "^1.9.1" 26 | }, 27 | "devDependencies": { 28 | "coffeescript": "^2.3.2", 29 | "mocha": "^6.0.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Connor Sauve 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /test/heirarchical.coffee: -------------------------------------------------------------------------------- 1 | assert = require "assert" 2 | parser = require ".." 3 | 4 | describe "heirarchical mode parser", -> 5 | 6 | it "should parse parallel tasks into an array", -> 7 | result = parser.relaxed """ 8 | Task A 9 | Task B 10 | """, hierarchical: true 11 | 12 | assert.equal result[0].text, "Task A" 13 | assert.equal result[1].text, "Task B" 14 | 15 | it "should parse indented tasks as subtasks", -> 16 | result = parser.relaxed """ 17 | Task A 18 | Task B 19 | Task C 20 | Task D 21 | Task E 22 | Task F 23 | Task G 24 | Task H 25 | """, hierarchical: true 26 | 27 | assert.equal result[0].text, "Task A" 28 | assert.equal result[1].text, "Task C" 29 | assert.equal result[2].text, "Task H" 30 | assert.equal result[2].subtasks.length, 0 31 | assert.equal result.length, 3 32 | 33 | assert.equal result[0].subtasks[0].text, "Task B" 34 | assert.equal result[0].subtasks.length, 1 35 | 36 | assert.equal result[1].subtasks[0].text, "Task D" 37 | assert.equal result[1].subtasks[0].subtasks[0].text, "Task E" 38 | assert.equal result[1].subtasks[0].subtasks[1].text, "Task F" 39 | # even though G is by indent a sibling to E, should only consider the relationship to the parent F 40 | assert.equal result[1].subtasks[0].subtasks[1].subtasks[0].text, "Task G" 41 | 42 | it "should detect indentation correctly", -> 43 | # if line starts with x, then count the whitespace after it + 1 (for the x) 44 | # if line starts with a space, then count the number of leading whitespace characters 45 | 46 | # these should be siblings 47 | result = parser.relaxed """ 48 | x Task A 49 | Task B 50 | """, hierarchical: true 51 | assert.equal result[0].indentLevel, 2 52 | assert.equal result[0].subtasks.length, 0 53 | assert.equal result[1].indentLevel, 2 54 | assert.equal result[1].subtasks.length, 0 55 | 56 | result = parser.relaxed """ 57 | x Task A 58 | x Task B 59 | """, hierarchical: true 60 | assert.equal result[0].indentLevel, 2 61 | assert.equal result[0].subtasks[0].text, "Task B" 62 | assert.equal result[0].subtasks[0].indentLevel, 4 63 | 64 | # these are siblings because according to the rules above, both are indent level 2 65 | result = parser.relaxed """ 66 | x Task A 67 | x Task B 68 | """, hierarchical: true 69 | assert.equal result[0].indentLevel, 2 70 | assert.equal result[0].subtasks.length, 0 71 | assert.equal result[1].indentLevel, 2 72 | assert.equal result[1].subtasks.length, 0 73 | 74 | # but if we lead them with whitespace, it's clear we want x's at the start of the task 75 | result = parser.relaxed " x Task A\n x Task B\n Task C", hierarchical: true 76 | assert.equal result[0].subtasks[0].text, "Task B" 77 | assert.equal result[0].subtasks[1].text, "Task C" 78 | 79 | it "should still detect metadata", -> 80 | result = parser.relaxed """ 81 | (A) Task A 82 | (B) @context1 Task B +Project1 +Project2 83 | """, hierarchical: true 84 | assert.equal result[0].priority, "A" 85 | assert.equal result[0].subtasks[0].priority, "B" 86 | assert.equal result[0].subtasks[0].contexts[0], "context1" 87 | assert.equal result[0].subtasks[0].projects[1], "Project2" 88 | 89 | it "should support inherited traits from parents", -> 90 | result = parser.relaxed """ 91 | (A) Task A +BigProject @context1 due:tomorrow t:wednesday 92 | Task B +SubProject @context2 due:today 93 | """, hierarchical: true, inherit: true 94 | 95 | assert.deepEqual result[0].subtasks[0].contexts, ["context1", "context2"] 96 | assert.deepEqual result[0].subtasks[0].projects, ["BigProject", "SubProject"] 97 | # subtask metadata should shadow parents 98 | assert.equal result[0].subtasks[0].metadata["due"], "today" 99 | assert.equal result[0].subtasks[0].metadata["t"], "wednesday" 100 | assert.equal result[0].subtasks[0].priority, "A" 101 | 102 | it "should still correctly parse canonical format", -> 103 | result = parser.parse """ 104 | Task A 105 | x Task B 106 | """, hierarchical: true 107 | # in canonical mode, Task B is not complete because it has no completion date 108 | assert.equal result[0].subtasks[0].text, "x Task B" 109 | assert.equal result[0].subtasks[0].complete, false 110 | 111 | # hierarchical mode implies relaxed whitespace 112 | result = parser.parse """ 113 | Task A 114 | x 2008-01-04 (B) 2008-01-02 Task B @context +Project 115 | """, hierarchical: true 116 | assert.equal result[0].subtasks[0].text, "Task B @context +Project" 117 | assert.equal result[0].subtasks[0].complete, true 118 | assert.deepEqual result[0].subtasks[0].contexts, ["context"] 119 | assert.deepEqual result[0].subtasks[0].projects, ["Project"] 120 | 121 | it "should propagate single-trait parents", -> 122 | result = parser.parse """ 123 | @bank 124 | deposit pay cheque 125 | get some cash to pay back @Cathy 126 | """, hierarchical: true, inherit: true 127 | assert.equal result[0].text, "@bank" 128 | assert.deepEqual result[0].subtasks[1].contexts, ["bank", "Cathy"] -------------------------------------------------------------------------------- /lib/parser.coffee: -------------------------------------------------------------------------------- 1 | _ = require "underscore" 2 | 3 | buildPattern = (opt) -> 4 | # returns interpolation-friendly regex 5 | interp = (expr) -> 6 | regex = expr().toString() 7 | regex[1..(regex.lastIndexOf("/") - 1)] 8 | 9 | DATE = -> opt.dateRegex 10 | START = -> if opt.relaxedWhitespace then /^\s*/ else /^/ 11 | SPACE = -> if opt.relaxedWhitespace then /\s+/ else /\s/ 12 | COMPLETE = -> /// 13 | (x) 14 | (?:#{interp SPACE}(#{interp DATE})) 15 | #{if opt.requireCompletionDate then "" else "?"} 16 | /// 17 | PRIORITY = -> if opt.ignorePriorityCase then /\(([A-Za-z])\)/ else /\(([A-Z])\)/ 18 | 19 | /// 20 | #{interp START} 21 | (?:#{interp COMPLETE}#{interp SPACE})? # completion mark and date 22 | (?:#{interp PRIORITY}#{interp SPACE})? # priority 23 | (?:(#{interp DATE})#{interp SPACE})? # created date 24 | (.*) # task text (may contain +projects, @contexts, meta:data) 25 | $ 26 | /// 27 | 28 | module.exports = 29 | options: 30 | # Gina Trapani's todo.txt-cli format & implementation 31 | CANONICAL: 32 | dateParser: (s) -> new Date(s).toJSON() 33 | dateRegex: /\d{4}-\d{2}-\d{2}/ 34 | relaxedWhitespace: false 35 | requireCompletionDate: true 36 | ignorePriorityCase: false 37 | heirarchical: false 38 | inherit: false 39 | commentRegex: null 40 | projectRegex: /(?:\s|^)\+(\S+)/g 41 | contextRegex: /(?:\s|^)@(\S+)/g 42 | extensions: [] 43 | RELAXED: 44 | dateParser: (s) -> new Date(s).toJSON() 45 | dateRegex: /\d{4}-\d{2}-\d{2}/ 46 | relaxedWhitespace: true 47 | requireCompletionDate: false 48 | ignorePriorityCase: true 49 | heirarchical: false 50 | inherit: false 51 | commentRegex: /^\s*#.*$/ 52 | projectRegex: /(?:\s+|^)\+(\S+)/g 53 | contextRegex: /(?:\s+|^)@(\S+)/g 54 | extensions: [ 55 | (text) -> 56 | metadata = {} 57 | metadataRegex = /(?:\s+|^)(\S+):(\S+)/g 58 | while match = metadataRegex.exec text 59 | metadata[match[1].toLowerCase()] = match[2] 60 | metadata 61 | ] 62 | 63 | parse: (s, options = {}) -> 64 | _.defaults options, module.exports.options.CANONICAL 65 | if options.hierarchical then options.relaxedWhitespace = true 66 | pattern = buildPattern options 67 | root = { subtasks: [], indentLevel: -1, contexts: [], projects: [], metadata: {} } 68 | stack = [root] 69 | 70 | for line in s.split "\n" 71 | taskMatch = line.match pattern 72 | commentMatch = if options.commentRegex then line.match options.commentRegex 73 | if !taskMatch or commentMatch then continue 74 | 75 | text = taskMatch[5].trim() 76 | 77 | indentLevel = if match = line.match /^(\s+).+/ 78 | # if line starts with a space, then count the number of leading whitespace characters 79 | match[1].length 80 | else if match = line.match /^x(\s+).+/ 81 | # if line starts with x, then count the whitespace after it + 1 (for the x) 82 | match[1].length + 1 83 | else 0 84 | 85 | # figure out where we are in the hierarchy 86 | prevSibling = _.last(_.last(stack).subtasks) || _.last(stack) 87 | if indentLevel > prevSibling.indentLevel 88 | stack.push prevSibling 89 | while indentLevel <= _.last(stack).indentLevel 90 | stack.pop() 91 | 92 | parent = _.last(stack) 93 | 94 | # projects 95 | projectsSet = {} 96 | if options.inherit 97 | projectsSet[project] = true for project in parent.projects 98 | while match = options.projectRegex.exec text 99 | projectsSet[match[1]] = true 100 | 101 | # contexts 102 | contextsSet = {} 103 | if options.inherit 104 | contextsSet[context] = true for context in parent.contexts 105 | while match = options.contextRegex.exec text 106 | contextsSet[match[1]] = true 107 | 108 | # metadata from extensions 109 | metadata = {} 110 | if options.inherit 111 | metadata[key] = value for key, value of parent.metadata 112 | for dataParser in options.extensions 113 | data = dataParser text 114 | for key, value of data 115 | metadata[key] = value 116 | 117 | complete = if taskMatch[1] 118 | true 119 | else if options.inherit 120 | parent.complete 121 | else false 122 | 123 | dateCreated = if taskMatch[4] 124 | options.dateParser taskMatch[4] 125 | else if options.inherit 126 | parent.dateCreated 127 | else null 128 | 129 | dateCompleted = if taskMatch[2] 130 | options.dateParser taskMatch[2] 131 | else if options.inherit 132 | parent.dateCompleted 133 | else null 134 | 135 | priority = (taskMatch[3] || metadata.pri)?.toUpperCase() || if options.inherit 136 | parent.priority 137 | else null 138 | 139 | task = 140 | raw: taskMatch[0] 141 | text: text 142 | projects: key for key of projectsSet 143 | contexts: key for key of contextsSet 144 | complete: complete 145 | dateCreated: dateCreated 146 | dateCompleted: dateCompleted 147 | priority: priority 148 | metadata: metadata 149 | subtasks: [] 150 | indentLevel: indentLevel 151 | 152 | _.last(stack).subtasks.push task 153 | 154 | root.subtasks 155 | 156 | # parsing function with relaxed options 157 | relaxed: (s, options = {}) -> 158 | module.exports.parse s, _.defaults options, module.exports.options.RELAXED 159 | -------------------------------------------------------------------------------- /test/standard.coffee: -------------------------------------------------------------------------------- 1 | assert = require "assert" 2 | parser = require ".." 3 | 4 | # test the parser against examples from the official format guide: 5 | # https://github.com/ginatrapani/todo.txt-cli/wiki/The-Todo.txt-Format 6 | describe "standard mode parser", -> 7 | 8 | it "should parse one line as one task", -> 9 | result = parser.parse "Review Tim's pull request" 10 | assert.deepEqual result, [ 11 | raw: "Review Tim's pull request" 12 | text: "Review Tim's pull request" 13 | projects: [] 14 | contexts: [] 15 | complete: false 16 | dateCreated: null 17 | dateCompleted: null 18 | priority: null 19 | metadata: {} 20 | subtasks: [] 21 | indentLevel: 0 22 | ] 23 | 24 | it "should parse one task per line and keep tasks in order", -> 25 | result = parser.parse """ 26 | Task A 27 | Task B 28 | """ 29 | assert.equal result[0].text, "Task A" 30 | assert.equal result[1].text, "Task B" 31 | 32 | result = parser.parse """ 33 | Task B 34 | Task A 35 | """ 36 | assert.equal result[0].text, "Task B" 37 | assert.equal result[1].text, "Task A" 38 | 39 | it "should find priority at the start of the text", -> 40 | result = parser.parse "(A) Call Mom" 41 | assert.equal result[0].priority, "A" 42 | 43 | result = parser.parse "(a) Call Mom", 44 | ignorePriorityCase: true 45 | assert.equal result[0].priority, "A" 46 | 47 | # the relaxed parser includes metadata parsing, and the spec suggests using pri for completed task 48 | result = parser.relaxed "Call Mom pri:A" 49 | assert.equal result[0].priority, "A" 50 | 51 | result = parser.parse "Really gotta call Mom (A) @phone @someday" 52 | assert.equal result[0].priority, null 53 | 54 | result = parser.parse "(b) Get back to the boss" 55 | assert.equal result[0].priority, null 56 | 57 | result = parser.parse "(B)->Submit TPS report" 58 | assert.equal result[0].priority, null 59 | 60 | it "should find creation date immediately after task priority", -> 61 | result = parser.parse "2011-03-02 Document +TodoTxt task format" 62 | assert.equal result[0].dateCreated, "2011-03-02T00:00:00.000Z" 63 | 64 | result = parser.parse "(A) 2011-03-02 Call Mom" 65 | assert.equal result[0].dateCreated, "2011-03-02T00:00:00.000Z" 66 | 67 | result = parser.parse "(A) Call Mom 2011-03-02" 68 | assert.equal result[0].dateCreated, null 69 | 70 | it "should find contexts and projects anywhere in the text", -> 71 | result = parser.parse "@iphone +Family Call Mom +PeaceLoveAndHappiness @phone" 72 | assert.deepEqual result[0].projects, ["Family", "PeaceLoveAndHappiness"] 73 | assert.deepEqual result[0].contexts, ["iphone", "phone"] 74 | 75 | result = parser.relaxed "Email SoAndSo at soandso@example.com and learn how to add 2+2" 76 | assert.deepEqual result[0].contexts, [] 77 | assert.deepEqual result[0].projects, [] 78 | 79 | it "should detect completed tasks", -> 80 | # canonical requires a completion date 81 | result = parser.parse "x completed task" 82 | assert.equal result[0].complete, false 83 | 84 | result = parser.parse "x 2011-03-03 Call Mom" 85 | assert.equal result[0].complete, true 86 | 87 | # relaxed may leave out the completion date 88 | result = parser.relaxed "x completed task" 89 | assert.equal result[0].complete, true 90 | 91 | result = parser.relaxed "xylophone lesson" 92 | assert.equal result[0].complete, false 93 | 94 | result = parser.relaxed "X 2012-01-01 Make resolutions" 95 | assert.equal result[0].complete, false 96 | 97 | result = parser.relaxed "(A) x Find ticket prices" 98 | assert.equal result[0].complete, false 99 | 100 | it "should support extension metadata", -> 101 | # relaxed parser has built-in key:value metadata parser 102 | result = parser.relaxed "(A) t:2006-07-27 Create TPS Report DUE:2006-08-01" 103 | assert.deepEqual result[0].metadata, 104 | t: "2006-07-27" 105 | due: "2006-08-01" 106 | 107 | # custom extensions 108 | result = parser.parse "Stop saying #yolo #swag all the time", 109 | extensions: [ 110 | # a dummy extension to prove later extensions overwrite previous values 111 | (text) -> 112 | hashtags: null 113 | 114 | (text) -> 115 | metadataRegex = /(?:\s+|^)#(\S+)/g 116 | hashtags: (while match = metadataRegex.exec text 117 | match[1]) 118 | ] 119 | assert.deepEqual result[0].metadata.hashtags, ["yolo", "swag"] 120 | 121 | it "should configurably allow relaxed whitespace", -> 122 | result = parser.parse " incomplete task @some-context", 123 | relaxedWhitespace: true 124 | assert.equal result[0].text, "incomplete task @some-context" 125 | assert.deepEqual result[0].contexts, ["some-context"] 126 | 127 | it "should configurably consider completion date optional", -> 128 | result = parser.parse "x complete task", 129 | requireCompletionDate: false 130 | assert.equal result[0].complete, true 131 | 132 | it "should configurably ignore priority case", -> 133 | result = parser.parse "(a) complete task", 134 | ignorePriorityCase: true 135 | assert.equal result[0].priority, "A" 136 | 137 | it "should configurably support comments", -> 138 | result = parser.relaxed """ 139 | Task A 140 | # Task B 141 | # Task C 142 | """ 143 | assert.equal result[0].text, "Task A" 144 | assert.equal result.length, 1 145 | 146 | it "should configurably support custom project and context formats", -> 147 | result = parser.parse "(B) project(Cleanup) Schedule Goodwill pickup project(GarageSale) @phone", 148 | projectRegex: /(?:\s+|^)project\((\S+)\)/g 149 | assert.deepEqual result[0].projects, ["Cleanup", "GarageSale"] 150 | 151 | result = parser.parse "(B) Schedule Goodwill pickup context(phone)", 152 | contextRegex: /(?:\s+|^)context\((\S+)\)/g 153 | assert.deepEqual result[0].contexts, ["phone"] 154 | 155 | it "should parse as documented", -> 156 | example = "x 2014-07-04 (A) 2014-06-19 Document YTD spending on +SocialEvents for @Alex due:2014-08-01" 157 | result = parser.relaxed example 158 | assert.deepEqual result, require "./doc-example.json" 159 | 160 | it "should configurably use a custom date pattern", -> 161 | input = "dAtE(Jan 5) Schedule a meeting with Nancy" 162 | options = 163 | dateRegex: /date\(.+\)/ 164 | dateParser: (s) -> s 165 | 166 | # this should not see the creation date, because the case doesnt match 167 | result = parser.parse input, options 168 | assert.equal result[0].dateCreated, null 169 | 170 | options.dateRegex = /dAtE\(.+\)/i 171 | result = parser.parse input, options 172 | assert.equal result[0].dateCreated, "dAtE(Jan 5)" 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # todotxt-parser 2 | This is a Node.js module for parsing [the todo.txt format](http://todotxt.com) created by [Gina Trapani](http://ginatrapani.org/). A variety of configuration options allow it to parse a strict canonical todo.txt format, or a more relaxed version permitting more liberal whitespace, comments, user-defined metadata extensions, and even indented hierarchical tasks with metadata inheritance. 3 | 4 | ## About the Format 5 | The todo.txt format attempts to maintain all the benefits of portable, human-readable flat files but still provide structured metadata for tools built on the format. For example, your `todo.txt` might look like this: 6 | 7 | ``` 8 | (A) Thank Mom for the meatballs @phone 9 | (B) Schedule Goodwill pickup +GarageSale @phone 10 | Post signs around the neighborhood +GarageSale 11 | @GroceryStore Eskimo pies 12 | Submit expense report for work travel due:2015-01-25 13 | x 2015-01-10 See the new exhibit at the museum 14 | ``` 15 | 16 | Each line in the file is one task, and tasks can have priority (`(A)`), projects (`+GarageSale`), contexts (`@phone`), dates, and other metadata attached to them. Priority, project, and context are 3 main sliceable axes in an effective todo list. See the [todo.txt-cli wiki](https://github.com/ginatrapani/todo.txt-cli/wiki/The-Todo.txt-Format) for full description of the format. 17 | 18 | ## Installation 19 | ```sh 20 | $ npm install todotxt-parser 21 | ``` 22 | 23 | ## API 24 | The API consumes a multilined string and returns an array of task objects in the order they appeared in the input: 25 | ```js 26 | var parser = require("todotxt-parser"); 27 | var tasks = parser.relaxed("x 2014-07-04 (A) 2014-06-19 Document YTD spending on +SocialEvents for @Alex due:2014-08-01"); 28 | ``` 29 | `tasks` looks like this: 30 | ```js 31 | [ 32 | { 33 | // the original untrimmed content of the line 34 | "raw": "x 2014-07-04 (A) 2014-06-19 Document YTD spending on +SocialEvents for @Alex due:2014-08-01", 35 | // the trimmed content of the line following the creation date 36 | "text": "Document YTD spending on +SocialEvents for @Alex due:2014-08-01", 37 | /* projects are found in the `text` field and begin with "+". 38 | * Empty when none present */ 39 | "projects": ["SocialEvents"], 40 | /* contexts are found in the `text` field and begin with "@". 41 | * Empty when none present */ 42 | "contexts": ["Alex"], 43 | // indicates if the task is marked as completed 44 | "complete": true, 45 | // ISO 8601 UTC datetime. Null if not present 46 | "dateCreated": "2014-06-19T00:00:00.000Z", 47 | // ISO 8601 UTC datetime. Null if not present 48 | "dateCompleted": "2014-07-04T00:00:00.000Z", 49 | /* The upper case A-Z priority. If priority was not 50 | * explicitly given, `metadata.pri` will be used if 51 | * it's present. Otherwise null 52 | */ 53 | "priority": "A", 54 | // Stores data parsed by metadata extensions. Defaults to {} 55 | "metadata": {"due": "2014-08-01"}, 56 | /* In hierarchical mode, contains any direct children 57 | * at a higher indentation level 58 | */ 59 | "subtasks": [], 60 | /* Indentation level of the task in character columns. 61 | * See hierarchical mode for more details 62 | */ 63 | "indentLevel": 2 64 | } 65 | ] 66 | ``` 67 | 68 | There are two parsing functions availalbe: `parser.relaxed(input, options)` and `parser.parse(input, options)`. Options can be omitted or partial, and these functions only differ in their default options. Calling `parser.relaxed(input)` is equivalent to calling `parser.parse(input, parser.options.RELAXED)`. The default options for `parser.parse(input)` are equal to `parser.options.CANONICAL`. 69 | 70 | ## Options 71 | *(Examples in CoffeeScript)* 72 | 73 | The exposed default options are as follows: 74 | ```coffee 75 | options: 76 | # Gina Trapani's todo.txt-cli format & implementation 77 | CANONICAL: 78 | dateParser: (s) -> new Date(s).toJSON() 79 | dateRegex: /\d{4}-\d{2}-\d{2}/ 80 | relaxedWhitespace: false 81 | requireCompletionDate: true 82 | ignorePriorityCase: false 83 | heirarchical: false 84 | inherit: false 85 | commentRegex: null 86 | projectRegex: /(?:\s|^)\+(\S+)/g 87 | contextRegex: /(?:\s|^)@(\S+)/g 88 | extensions: [] 89 | RELAXED: 90 | dateParser: (s) -> new Date(s).toJSON() 91 | dateRegex: /\d{4}-\d{2}-\d{2}/ 92 | relaxedWhitespace: true 93 | requireCompletionDate: false 94 | ignorePriorityCase: true 95 | heirarchical: false 96 | inherit: false 97 | commentRegex: /^\s*#.*$/ 98 | projectRegex: /(?:\s+|^)\+(\S+)/g 99 | contextRegex: /(?:\s+|^)@(\S+)/g 100 | extensions: [ 101 | (text) -> 102 | metadata = {} 103 | metadataRegex = /(?:\s+|^)(\S+):(\S+)/g 104 | while match = metadataRegex.exec text 105 | metadata[match[1].toLowerCase()] = match[2] 106 | metadata 107 | ] 108 | ``` 109 | 110 | ### dateParser 111 | A function accepting a string and returning a string, used to convert captured dates for the `dateCreated` and `dateCompleted` fields. It is recommended to return an ISO 8601 UTC datetime for consistency with the default date parser: 112 | ```coffee 113 | (s) -> new Date(s).toJSON() 114 | ``` 115 | 116 | ### dateRegex 117 | A `RegExp` used to match the creation and completion dates. It should not contain any capture groups, and any modifiers (like case insensitivity) will be ignored. Matches will be parsed by the `dateParser` function. This option defaults to capturing "YYYY-MM-DD" format: 118 | ```coffee 119 | /\d{4}-\d{2}-\d{2}/ 120 | ``` 121 | 122 | ### relaxedWhitespace 123 | The todo.txt specification does not allow for more than 1 space between the completion mark, completion date, priority, creation date, and text. This ensures priorities and tasks line up so lines can be sorted consistently. When `relaxedWhitespace` is set to `true`, these restrictions are lifted. 124 | ```coffee 125 | # none of these longer whitespace gaps would have been valid 126 | parser.parse "x 2013-11-11 (B) 2013-10-11 Clean up", 127 | relaxedWhitespace: true 128 | # with `relaxedWhitespace`, this is allowed now 129 | parser.parse " Task B", 130 | relaxedWhitespace: true 131 | ``` 132 | 133 | ### requireCompletionDate 134 | A task is marked completed by adding a lower case "x" marker to the start of the line, followed by a single space and then a completion date. Changing 'requireCompletionDate' to false makes the date optional, allowing tasks like this: 135 | ```coffee 136 | parser.parse "x Walk the dog", 137 | requireCompletionDate: false 138 | ``` 139 | Note: It is possible for a tasks creation date to become its completion date with this option disabled: 140 | ```coffee 141 | # this date will become the creation date 142 | parser.parse "2014-12-02 Task A", 143 | requireCompletionDate: false 144 | 145 | # but now it is the completion date 146 | parser.parse "x 2014-12-02 Task A", 147 | requireCompletionDate: false 148 | 149 | # a priority clears the ambiguity; it's now the creation date 150 | parser.parse "x (A) 2014-12-02 Task A", 151 | requireCompletionDate: false 152 | ``` 153 | 154 | ### ignorePriorityCase 155 | When set to `true`, both `A-Z` and `a-z` will be allowed for priority. The priority is still always converted to upper case after capture. 156 | 157 | ### hierarchical 158 | Standard `todo.txt` has no notion of subtasks. Indentation is not allowed because the result is no longer sortable in a meaningful way. If you want to group a set of tasks under one project, each task needs to be annotated with the same `+Project` tag. This can clutter large projects, and it's difficult to see at a glance which tasks are associated by project. If the ability to sort lines alphabetically is not important to you, and you would rather be able to logically group tasks under other tasks, then there is **hierarchical mode**: 159 | 160 | ```coffee 161 | tasks = parser.relaxed """ 162 | Task A 163 | Task B 164 | Task C 165 | Task D 166 | Task E 167 | Task F 168 | """, hierarchical: true 169 | ``` 170 | Instead of all tasks being stored in a single array, like standard mode with `relaxedWhitespace: true` would return, the `subtasks` field of each task is now used to store child tasks: 171 | 172 | *(Fields other than `text`, `indentLevel`, and `subtasks` omitted for brevity)* 173 | ```coffee 174 | # parse still returns an array, but it only contains the root level tasks 175 | [ 176 | { text: "Task A", indentLevel: 0, subtasks: [ 177 | { text: "Task B", indentLevel: 2, subtasks: [] } 178 | { text: "Task C", indentLevel: 2, subtasks: [ 179 | # a task is a leaf when `subtasks` is empty 180 | { text: "Task D", indentLevel: 4, subtasks: [] } 181 | { text: "Task E", indentLevel: 4, subtasks: [] } 182 | ]} 183 | ]} 184 | # tasks A and F are siblings 185 | { text: "Task F", indentLevel: 0, subtasks: [] } 186 | ] 187 | ``` 188 | 189 | Hierarchical mode implies `relaxedWhitespace: true`. A task is considered a subtask when its indentation level is greater than its parent's. A new parent is chosen when the indentation level is greater than the previous sibling's indentation level. For example, what is the output of this? 190 | 191 | ```coffee 192 | tasks = parser.relaxed """ 193 | Task A 194 | Task B 195 | Task C 196 | Task D 197 | Task E 198 | Task F 199 | """, hierarchical: true 200 | ``` 201 | 202 | Tasks A and B will be root level siblings even though they are not indented the same amount. Task B has three subtasks: C, D, and F. Task D has a single subtask, E. Even though tasks E and C are indented the same amount, it's their position relative to the previous task that matters. The best practice is to use consistent indentation. 203 | 204 | How is `indentLevel` determined? There are two rules: 205 | 206 | 1. If the line **immediately** begins with the completion mark "x", then `indentLevel` counts it **and** contiguous whitespace characters following it 207 | 2. Otherwise, `indentLevel` is the number of leading whitespace characters 208 | 209 | This means you can either place the completion mark in the first column, or after the indent: 210 | ``` 211 | x Task B 212 | Task C 213 | x Task D 214 | x Task E 215 | Task F 216 | Task G 217 | ``` 218 | is equivalent to: 219 | ``` 220 | x Task B 221 | Task C 222 | x Task D 223 | x Task E 224 | Task F 225 | Task G 226 | ``` 227 | It's important to note that tasks B and C are siblings. If the intent was to have C be a subtask of B, then the first format should have been used (add at least 1 extra column of leading whitespace). 228 | 229 | ### inherit 230 | The `inherit` option is only applicable to hierarchical mode, and is disabled by default. When enabled, subtasks will inherit the metadata of their ancestors. This includes projects, contexts, completeness, creation and completion dates, priority, and extension metadata. Subtasks can shadow ancestral metadata by explicitly defining it themselves. 231 | 232 | ```coffee 233 | tasks = parser.relaxed """ 234 | (A) 2014-06-19 Task A +Project1 @context1 due:2014-09-13 t:2014-05-01 235 | Task B +Project2 due:2014-08-15 236 | """, hierarchical: true, inherit: true 237 | ``` 238 | Task B will have inhereted task A's metadata: 239 | ```coffee 240 | raw: " Task B +Project2 due:2014-08-15" 241 | text: "Task B +Project2 due:2014-08-15" 242 | # `projects` and `contexts` are considered sets, so you won't 243 | # get duplicates if they're also found in an ancestor 244 | projects: ["Project 1", "Project 2"] 245 | contexts: ["context1"] 246 | complete: false 247 | datecreated: "2014-06-19T00:00:00.000Z" 248 | dateCompleted: null 249 | priority: "A" 250 | # note that `due` is shadowing the parent's value 251 | metadata: {due: "2014-08-15", t: "2014-05-01"} 252 | subtasks: [] 253 | indentLevel: 2 254 | ``` 255 | 256 | ### commentRegex 257 | This RegExp tests if the line is a comment, and should therefore be ignored. Comments are not part of the todo.txt specification, so this is `null` by default. 258 | 259 | ### projectRegex, contextRegex 260 | These two RegExp are used to match projects and contexts only inside the task's `text` field, which is anything following the creation date. The defaults match `+Project` and `@context`. 261 | ```coffee 262 | projectRegex: /(?:\s|^)\+(\S+)/g 263 | contextRegex: /(?:\s|^)@(\S+)/g 264 | ``` 265 | When supplying your own expressions, makes sure to have a capture group for the context/project itself, and to enable global matching with the `g` modifier. 266 | 267 | ### extensions 268 | Extensions are functions that are passed the `text` field of the task and return an object of key-value metadata. The results of all extensions are merged into a task's `metadata` field. The order of functions in `extensions` matters: later functions can overwrite values for a key. No extensions are used by default, but relaxed mode will find any "key:value" pairs with this function: 269 | ```coffee 270 | extensions: [ 271 | (text) -> 272 | metadata = {} 273 | metadataRegex = /(?:\s+|^)(\S+):(\S+)/g 274 | while match = metadataRegex.exec text 275 | metadata[match[1].toLowerCase()] = match[2] 276 | metadata 277 | ] 278 | ``` 279 | 280 | ## Future work 281 | * Add a formatter that turns a list or hierarchy of tasks back into a string. 282 | 283 | ## Testing 284 | Use node package manager to install dependencies and run the tests: 285 | ```sh 286 | $ npm install 287 | $ npm test 288 | ``` 289 | 290 | ## License 291 | See the LICENSE file (MIT). 292 | --------------------------------------------------------------------------------