├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .node-version ├── .npmrc ├── .prettierrc.js ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-interactive-tools.cjs └── releases │ └── yarn-3.6.3.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── dist ├── manifest.json ├── styles.css └── versions.json ├── docs ├── PluginGuide.md ├── RECURRING.md └── screenshot.png ├── jest.config.js ├── manifest.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── scripts ├── esbuild.config.mjs ├── release.mjs └── version-bump.mjs ├── src ├── lib │ ├── classNames.ts │ ├── parser.js │ ├── todo.peggy │ └── todo.ts ├── main.ts ├── styles.css ├── ui │ ├── create-todo-dialog.tsx │ ├── delete-todo-dialog.tsx │ ├── edit-todo-dialog.tsx │ ├── icon │ │ ├── magnifying-glass.tsx │ │ ├── pencil.tsx │ │ ├── plus.tsx │ │ ├── trash.tsx │ │ └── x-mark.tsx │ ├── todoslist.tsx │ ├── todosview.tsx │ └── todoview.tsx └── view.tsx ├── tests ├── description.test.ts ├── peg.test.ts └── todo.test.ts ├── tsconfig.json └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | tab_width = 2 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-todotxt-plugin 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Use Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: "20.x" 22 | 23 | - uses: pnpm/action-setup@v4 24 | # make sure we have packageManager entry in package.json 25 | 26 | - name: Build 27 | id: build 28 | run: | 29 | pnpm install 30 | pnpm run build 31 | mkdir ${{ env.PLUGIN_NAME }} 32 | cp dist/main.js dist/manifest.json dist/styles.css ${{ env.PLUGIN_NAME }} 33 | cp dist/main.js dist/styles.css ./ 34 | zip -r ${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip ${{ env.PLUGIN_NAME }} 35 | ls 36 | 37 | - name: Create release notes 38 | uses: johnyherangi/create-release-notes@main 39 | id: create-release-notes 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | 43 | - name: Create Release 44 | id: create-release 45 | uses: softprops/action-gh-release@v1 46 | with: 47 | body: ${{ steps.create-release-notes.outputs.release-notes }} 48 | files: | 49 | main.js 50 | styles.css 51 | manifest.json 52 | ${{ env.PLUGIN_NAME }}-${{ github.ref_name }}.zip 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # dependencies 9 | node_modules 10 | .pnp.* 11 | .yarn/* 12 | !.yarn/patches 13 | !.yarn/plugins 14 | !.yarn/releases 15 | !.yarn/sdks 16 | !.yarn/versions 17 | 18 | # Don't include the compiled main.js file in the repo. 19 | # They should be uploaded to GitHub releases instead. 20 | main.js 21 | 22 | # Exclude sourcemaps 23 | *.map 24 | 25 | # obsidian 26 | data.json 27 | 28 | # Exclude macOS Finder (System Explorer) View States 29 | .DS_Store 30 | 31 | # Temporary file 32 | tmp/ 33 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.8.1 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | bracketSpacing: true, 5 | }; 6 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | 7 | yarnPath: .yarn/releases/yarn-3.6.3.cjs 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mark Grimes 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian TodoTxt Plugin 2 | 3 | Manage [\*.todotxt](https://github.com/todotxt/todo.txt) files in Obsidian. 4 | 5 | ![Sample TodoTxt file in Obsidian](docs/screenshot.png) 6 | 7 | Install the plugin and put your todo file in your Obsidian vault with the 8 | `.todotxt` extension (ie, `TODO.todotxt`). 9 | 10 | Creating a `.todotxt` file from within Obsidian can be a bit tricky. When you 11 | create a new note Obsidian will automatically append the `.md` extension, so 12 | `TODO.todotxt` becomes `TODO.todotxt.md`. To fix this, you can right-click on 13 | the file in Obsidian sidebar, select Reveal in Finder/File Explorer, and then 14 | change the file extension to `.todotxt`. If you are successful, the file will 15 | be listed in the Obsidian sidebar followed by a `TODOTXT` badge. 16 | 17 | ## Additional Features 18 | 19 | The TodoTxt Plugin supports some extensions to the basic todo.txt spec: 20 | 21 | ### Due Dates 22 | 23 | Due dates can be specified by including `due:yyyy-mm-dd` in the text of the 24 | task item. The plugin will highlight the due date and shift to orange as the 25 | date nears then red when the due date is missed. The date must be specified in 26 | the `yyyy-mm-dd` format (including padding the month or day with a `0` if 27 | needed) and no whitespace may be included. 28 | 29 | ### Preserving Priorities on Complete 30 | 31 | EXPERIMENATL: This feature is experimental and needs to be enabled in the settings. 32 | 33 | As described in the todo.txt [spec](https://github.com/todotxt/todo.txt), 34 | priorities are typically discarded when a task is marked as complete. This 35 | extension to the spec will preserve the priority in a `pri:X` tag. It will also 36 | restore the priority if the task is later marked as uncompleted. 37 | 38 | ### Recurring Tasks 39 | 40 | EXPERIMENATL: This feature is experimental and needs to be enabled in the settings. 41 | This is not part of the todo.txt [spec](https://github.com/todotxt/todo.txt). 42 | 43 | Recurring tasks can be specified by including the `rec:` tag. When such a task 44 | is marked as complete a new task will created with a `due:` tag based on the 45 | value in the `rec:` tag. 46 | 47 | See further documentation of [recurring tasks](docs/RECURRING.md). 48 | 49 | ### Threshhold Dates 50 | 51 | Threshold dates are indicated by the `t:YYYY-MM-DD` tag. Tasks with a specified 52 | threshold are not considered ready to be undertaken until the threshold date. 53 | The Todotxt Plugin will display tasks with threshold dates in the future with a 54 | subtlely muted text. 55 | 56 | ## How to Install by Hand 57 | 58 | 1. Clone this repo. 59 | 1. `pnpm install` to install dependencies 60 | 1. `pnpm run dev` to start compilation in watch mode. 61 | 62 | ## Manually Installing the Plugin 63 | 64 | Copy `main.js`, `styles.css`, `manifest.json` to your vault 65 | `VaultFolder/.obsidian/plugins/todotxt-plugin/`. 66 | 67 | cp dist/* VaultFolder/.obsidian/plugins/todotxt-plugin/ 68 | 69 | ## Keyboard Shortcuts 70 | 71 | Use `tab` and `shift-tab` to navigate through your todos. 72 | 73 | - `ctrl-n` to create a New todo task 74 | - `ctrl-/` goto filter input 75 | - `e` or `enter` to Edit the current todo task 76 | - `d` to Delete the current todo task 77 | - `space` toggle done 78 | 79 | ## Future Development 80 | 81 | - [x] Delete a Todo 82 | - [x] Edit a Todo 83 | - [x] Keyboard shortcut to create new Todo 84 | - [x] Keyboard navigation through TODOs 85 | - [x] Priority colors are a bit bright 86 | - [ ] Better handling for Todo.parse() errors 87 | - [ ] Global keyboard shortcut to create new Todo 88 | - [ ] Command palette command to create new Todo 89 | - [ ] Config menu set the default .todotxt file 90 | - [ ] Command palette to create a new .todotxt file 91 | 92 | ## Development 93 | 94 | Helpful commands to run while developing: 95 | 96 | ```shell 97 | pnpm run dev # compile typescript to ./dist via esbuild 98 | pnpm run css # compile css to ./dist via postcss 99 | pnpm run cp # copy files from ./dist to Obsidian plugins dir 100 | pnpm run parser:watch # compile .peggy grammar to parser.js 101 | pnpm run test:watch # run tests in watch mode 102 | ``` 103 | 104 | We are using the moment package because Obsidian already requires it. Otherwise 105 | would use something lighter weight (like date-fns) or built-in. 106 | 107 | ## Thanks 108 | 109 | * Thanks to the authors of [todotxt](https://github.com/todotxt). 110 | * Thanks to the authors of [SwiftoDo](https://swiftodoapp.com/) for documenting 111 | the due and recurring extensions to the spec. 112 | 113 | ## Support 114 | 115 | I've been asked if there are ways to support this plugin. I created it to 116 | scratch my own itch and am just happy that others find it useful. Giving it a 117 | star on github would be appreciated. If you are feeling extra generous, you can 118 | buy me a coffee ☕. 119 | 120 | Buy Me A Coffee 121 | -------------------------------------------------------------------------------- /dist/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "todotxt", 3 | "name": "TodoTxt", 4 | "version": "2.1.7", 5 | "minAppVersion": "0.15.0", 6 | "description": "Manage Todo.txt files.", 7 | "author": "Mark Grimes", 8 | "authorUrl": "https://github.com/mvgrimes", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /dist/styles.css: -------------------------------------------------------------------------------- 1 | #todotxt { 2 | --todotxt-color-amber-300: rgb(252 211 77); 3 | --todotxt-color-amber-600: rgb(217 119 6); 4 | --todotxt-color-blue-200: rgb(191 219 254); 5 | --todotxt-color-blue-300: rgb(147 197 253); 6 | --todotxt-color-blue-600: #2563EB; 7 | --todotxt-color-gray-200: rgb(229 231 235); 8 | --todotxt-color-gray-400: rgb(156 163 175); 9 | --todotxt-color-gray-600: rgb(75 85 99); 10 | --todotxt-color-gray-700: rgb(55 65 81); 11 | --todotxt-color-gray-900: rgb(17 24 39); 12 | --todotxt-color-gray-900-9: rgb(17 24 39 / 0.90); 13 | --todotxt-color-indigo-200: rgb(199 210 254); 14 | --todotxt-color-indigo-900: rgb(49 46 129); 15 | --todotxt-color-orange-300: rgb(253 186 116); 16 | --todotxt-color-orange-600: #EA580C; 17 | --todotxt-color-red-300: rgb(252 165 165); 18 | --todotxt-color-red-600: #DC2626; 19 | --todotxt-color-red-700: rgb(185 28 28); 20 | --todotxt-color-sky-300: #7DD3FC; 21 | --todotxt-color-sky-600: rgb(2 132 199); 22 | --todotxt-color-slate-700: rgb(51 65 85); 23 | } 24 | 25 | #todotxt .todo-container { 26 | width: 100%; 27 | } 28 | 29 | #todotxt .todo-controls { 30 | display: flex; 31 | align-items: center; 32 | gap: 0.75rem; 33 | } 34 | 35 | #todotxt .todo-list-container { 36 | display: grid; 37 | grid-template-columns: repeat(1, minmax(0, 1fr)); 38 | gap: 0.75rem; 39 | } 40 | 41 | #todotxt .todo-list-header { 42 | border-bottom: 2px solid var(--checkbox-color); 43 | } 44 | 45 | #todotxt .todo-list { 46 | display: flex; 47 | flex-direction: column; 48 | gap: 0.75rem; 49 | } 50 | 51 | #todotxt .todo-filter { 52 | position: relative; 53 | } 54 | 55 | #todotxt .todo-filter input { 56 | display: block; 57 | width: 5em; 58 | } 59 | 60 | #todotxt .todo-filter input:focus { 61 | width: 10em; 62 | } 63 | 64 | #todotxt .todo-filter input::-moz-placeholder { 65 | color: var(--text-muted); 66 | } 67 | 68 | #todotxt .todo-filter input::placeholder { 69 | color: var(--text-muted); 70 | } 71 | 72 | #todotxt .todo-filter .icon-container { 73 | position: absolute; 74 | top: 0; 75 | bottom: 0; 76 | right: 0.5rem; 77 | } 78 | 79 | #todotxt .todo-filter .icon { 80 | width: 1rem; 81 | height: 1rem; 82 | color: var(--text-muted); 83 | } 84 | 85 | #todotxt .todo-filter button { 86 | margin: 0; 87 | padding: 0; 88 | background: none; 89 | border: none; 90 | box-shadow: none; 91 | } 92 | 93 | #todotxt .todo input[type="checkbox"] { 94 | margin-top: 0.125rem; 95 | } 96 | 97 | #todotxt .todo { 98 | 99 | border: 3px solid transparent; 100 | border-radius: 4px; 101 | 102 | display: flex; 103 | } 104 | 105 | /* .todo:has(input:focus) { */ 106 | 107 | #todotxt .todo:focus-within { 108 | border: 3px solid var(--text-accent); 109 | } 110 | 111 | #todotxt .todo-label { 112 | display: flex; 113 | align-items: flex-start; 114 | } 115 | 116 | #todotxt .todo-priority { 117 | border-radius: 0.5rem; 118 | padding: 0.125rem 0.375rem; 119 | margin-right: 0.25rem; 120 | font: 0.75rem; 121 | line-height: 1rem; 122 | } 123 | 124 | #todotxt .todo-priority-a { 125 | color: var(--todotxt-color-red-600); 126 | background-color: var(--todotxt-color-red-300); 127 | } 128 | 129 | #todotxt .todo-priority-b { 130 | color: var(--todotxt-color-orange-600); 131 | background-color: var(--todotxt-color-orange-300); 132 | } 133 | 134 | #todotxt .todo-priority-c { 135 | color: var(--todotxt-color-sky-600); 136 | background-color: var(--todotxt-color-sky-300); 137 | } 138 | 139 | #todotxt .todo-priority-d { 140 | color: var(--todotxt-color-amber-600); 141 | background-color: var(--todotxt-color-gray-200); 142 | } 143 | 144 | #todotxt .todo-priority-e { 145 | border: 1px solid var(--todotxt-color-gray-200); 146 | } 147 | 148 | #todotxt .todo-priority-f { 149 | border: 1px solid var(--todotxt-color-gray-200); 150 | } 151 | 152 | #todotxt .todo-priority-g { 153 | border: 1px solid var(--todotxt-color-gray-200); 154 | } 155 | 156 | #todotxt .todo-description { 157 | display: flex; 158 | align-items: flex-start; 159 | color: var(--text-normal); 160 | } 161 | 162 | #todotxt .todo-description .todo-due { 163 | color: var(--todotxt-color-sky-600); 164 | } 165 | 166 | #todotxt .todo-description .todo-due-soon { 167 | color: var(--todotxt-color-amber-600); 168 | } 169 | 170 | #todotxt .todo-description .todo-due-past { 171 | color: var(--todotxt-color-red-600); 172 | } 173 | 174 | #todotxt .todo-description .todo-prethreshold { 175 | color: var(--text-muted); 176 | } 177 | 178 | #todotxt .todo-description .todo-completed { 179 | text-decoration-line: line-through; 180 | color: var(--text-muted); 181 | } 182 | 183 | #todotxt .todo-tag { 184 | font-size: 0.75rem; 185 | line-height: 1rem; 186 | margin-right: 0.5rem; 187 | color: var(--text-muted); 188 | display: inline-block; 189 | } 190 | 191 | #todotxt .todo-project { 192 | font-size: 0.75rem; 193 | line-height: 1rem; 194 | margin-right: 0.5rem; 195 | padding: 0.125rem 0.25rem; 196 | border-radius: 9999px; 197 | color: var(--todotxt-color-gray-700); 198 | background-color: var(--todotxt-color-blue-200); 199 | white-space: nowrap; 200 | display: inline-block; 201 | } 202 | 203 | #todotxt .todo-ctx { 204 | font-size: 0.75rem; 205 | line-height: 1rem; 206 | margin-right: 0.5rem; 207 | padding: 0.125rem 0.25rem; 208 | border-radius: 9999px; 209 | color: var(--todotxt-color-gray-700); 210 | background-color: var(--todotxt-color-indigo-200); 211 | white-space: nowrap; 212 | display: inline-block; 213 | } 214 | 215 | #todotxt .todo-actions { 216 | visibility: hidden; 217 | display: flex; 218 | gap: 0.125rem; 219 | color: var(--text-faint); 220 | 221 | margin-left: 0.25rem; 222 | } 223 | 224 | #todotxt .todo-actions button { 225 | margin: 0; 226 | padding: 1px; 227 | } 228 | 229 | #todotxt .todo-actions button svg { 230 | display: inline; 231 | width: 1rem; 232 | height: 1rem; 233 | color: inherit 234 | } 235 | 236 | #todotxt .todo-label:hover .todo-actions { 237 | visibility: visible; 238 | } 239 | 240 | #todotxt .todo-dialog-bg { 241 | position: absolute; 242 | top: 0px; 243 | right: 0px; 244 | bottom: 0px; 245 | left: 0px; 246 | background-color: var(--background-primary); 247 | display: flex; 248 | justify-content: center; 249 | align-items: center; 250 | } 251 | 252 | #todotxt .todo-dialog { 253 | width: 66%; 254 | border: 0.25rem solid var(--background-secondary-alt); 255 | border-radius: 0.5rem; 256 | background-color: var(--background-secondary); 257 | z-index: 20; 258 | padding: 0.5rem; 259 | margin: 1.5rem; 260 | } 261 | 262 | #todotxt .todo-dialog-header { 263 | margin-top: 0.125rem; 264 | margin-bottom: 0.125rem; 265 | } 266 | 267 | #todotxt .todo-dialog-input { 268 | margin-top: 0.125rem; 269 | margin-bottom: 0.125rem; 270 | width: 100%; 271 | } 272 | 273 | #todotxt .todo-dialog-actions { 274 | margin-top: 0.125rem; 275 | margin-bottom: 0.125rem; 276 | display: flex; 277 | justify-content: flex-end; 278 | gap: 0.25rem; 279 | } 280 | 281 | .todotxt-settings .todo-experimental-heading { 282 | margin-top: 4rem; 283 | margin-bottom: 0.25rem; 284 | } 285 | 286 | #todotxt { 287 | /* Particularly on mobile, we need a bit of padding at the bottom */ 288 | margin-bottom: 2rem; 289 | } 290 | 291 | /* sm */ 292 | 293 | @media (min-width: 640px) { 294 | } 295 | 296 | /* md */ 297 | 298 | @media (min-width: 768px) { 299 | } 300 | 301 | /* lg */ 302 | 303 | @media (min-width: 1024px) { 304 | #todotxt .todo-list-container { 305 | grid-template-columns: repeat(2, minmax(0, 1fr)) 306 | } 307 | .todo-container { 308 | display: block; 309 | } 310 | } 311 | 312 | /* xl */ 313 | 314 | @media (min-width: 1280px) { 315 | #todotxt .todo-list-container { 316 | grid-template-columns: repeat(3, minmax(0, 1fr)) 317 | } 318 | .todo-container { 319 | display: flex; 320 | align-items: center; 321 | justify-content: space-between; 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /dist/versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0", 3 | "1.0.1": "0.15.0", 4 | "1.0.2": "0.15.0", 5 | "1.0.3": "0.15.0", 6 | "1.0.4": "0.15.0", 7 | "1.0.5": "0.15.0", 8 | "1.1.0": "0.15.0", 9 | "1.2.0": "0.15.0", 10 | "1.2.1": "0.15.0", 11 | "1.2.2": "0.15.0", 12 | "1.2.3": "0.15.0", 13 | "1.3.0": "0.15.0", 14 | "1.3.1": "0.15.0", 15 | "1.3.2": "0.15.0", 16 | "1.3.3": "0.15.0", 17 | "1.4.0": "0.15.0", 18 | "1.4.3": "0.15.0", 19 | "1.4.4": "0.15.0", 20 | "1.5.0": "0.15.0", 21 | "1.6.0": "0.15.0", 22 | "1.7.0": "0.15.0", 23 | "2.0.0": "0.15.0", 24 | "2.0.2": "0.15.0", 25 | "2.0.3": "0.15.0", 26 | "2.0.4": "0.15.0", 27 | "2.0.5": "0.15.0", 28 | "2.1.0": "0.15.0", 29 | "2.1.1": "0.15.0", 30 | "2.1.2": "0.15.0", 31 | "2.1.5": "0.15.0", 32 | "2.1.6": "0.15.0", 33 | "2.1.7": "0.15.0" 34 | } -------------------------------------------------------------------------------- /docs/PluginGuide.md: -------------------------------------------------------------------------------- 1 | # Obsidian Sample Plugin 2 | 3 | This is a sample plugin for Obsidian (https://obsidian.md). 4 | 5 | This project uses Typescript to provide type checking and documentation. 6 | The repo depends on the latest plugin API (obsidian.d.ts) in Typescript Definition format, which contains TSDoc comments describing what it does. 7 | 8 | **Note:** The Obsidian API is still in early alpha and is subject to change at any time! 9 | 10 | This sample plugin demonstrates some of the basic functionality the plugin API can do. 11 | - Changes the default font color to red using `styles.css`. 12 | - Adds a ribbon icon, which shows a Notice when clicked. 13 | - Adds a command "Open Sample Modal" which opens a Modal. 14 | - Adds a plugin setting tab to the settings page. 15 | - Registers a global click event and output 'click' to the console. 16 | - Registers a global interval which logs 'setInterval' to the console. 17 | 18 | ## First time developing plugins? 19 | 20 | Quick starting guide for new plugin devs: 21 | 22 | - Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with. 23 | - Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it). 24 | - Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder. 25 | - Install NodeJS, then run `npm i` in the command line under your repo folder. 26 | - Run `npm run dev` to compile your plugin from `main.ts` to `main.js`. 27 | - Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`. 28 | - Reload Obsidian to load the new version of your plugin. 29 | - Enable plugin in settings window. 30 | - For updates to the Obsidian API run `npm update` in the command line under your repo folder. 31 | 32 | ## Releasing new releases 33 | 34 | - Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release. 35 | - Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible. 36 | - Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases 37 | - Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release. 38 | - Publish the release. 39 | 40 | > You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`. 41 | > The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json` 42 | 43 | ## Adding your plugin to the community plugin list 44 | 45 | - Check https://github.com/obsidianmd/obsidian-releases/blob/master/plugin-review.md 46 | - Publish an initial version. 47 | - Make sure you have a `README.md` file in the root of your repo. 48 | - Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin. 49 | 50 | ## How to use 51 | 52 | - Clone this repo. 53 | - `npm i` or `yarn` to install dependencies 54 | - `npm run dev` to start compilation in watch mode. 55 | 56 | ## Manually installing the plugin 57 | 58 | - Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. 59 | 60 | ## Improve code quality with eslint (optional) 61 | 62 | - [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code. 63 | - To use eslint with this project, make sure to install eslint from terminal: 64 | - `npm install -g eslint` 65 | - To use eslint to analyze this project use this command: 66 | - `eslint main.ts` 67 | - eslint will then create a report with suggestions for code improvement by file and line number. 68 | - If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: 69 | - `eslint ./src` 70 | 71 | ## Funding URL 72 | 73 | You can include funding URLs where people who use your plugin can financially support it. 74 | 75 | The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: 76 | 77 | ```json 78 | { 79 | "fundingUrl": "https://buymeacoffee.com" 80 | } 81 | ``` 82 | 83 | If you have multiple URLs, you can also do: 84 | 85 | ```json 86 | { 87 | "fundingUrl": { 88 | "Buy Me a Coffee": "https://buymeacoffee.com", 89 | "GitHub Sponsor": "https://github.com/sponsors", 90 | "Patreon": "https://www.patreon.com/" 91 | } 92 | } 93 | ``` 94 | 95 | ## API Documentation 96 | 97 | See https://github.com/obsidianmd/obsidian-api 98 | 99 | -------------------------------------------------------------------------------- /docs/RECURRING.md: -------------------------------------------------------------------------------- 1 | # Recurring Tasks 2 | 3 | The [TodoTxt Plugin](/README.md) supports extensions to the todo.txt 4 | [spec](https://github.com/todotxt/todo.txt) to handle recurring tasks. 5 | 6 | A task can be specified as recurring by including the `rec:` tag, and 7 | optionally, the `t:` (threshold) tag. When a recurring task is marked as 8 | completed, a new task is also created with a `due:` tag based on the `rec:` and 9 | `t:` tags. 10 | 11 | ### The `rec:` Recurring Tag 12 | 13 | A `rec:` tag indicates that the task is recurring. The tags value indicates 14 | when the next task should be due. For example, a `rec:1w` indicates that the 15 | tasks recurs one week after the task is completed. 16 | 17 | The rec pattern is defined as: 18 | 19 | 1. The `rec:` tag prefix (proceeded by a space, as are all tags) 20 | 2. An optional `+` to indicate that "strict" recurrance 21 | 3. A number 22 | 4. The duration (a single letter indicating days, weeks, months or years). 23 | 24 | No spaces are permitted in the rec pattern. Some examples: 25 | 26 | - `rec:10d` - Recurs in ten days 27 | - `rec:1y` - Recurs in one year 28 | - `rec:+5w` - Recurs five weeks after 29 | 30 | Duration can be one of: 31 | 32 | | Abbr | Duration | 33 | | ---- | -------- | 34 | | d | days | 35 | | w | weeks (7 days) | 36 | | m | months | 37 | | y | years | 38 | 39 | (At this time, `b` for "business days" is not supported. It will be treated as days.) 40 | 41 | 42 | ### Thresholds 43 | 44 | If the task has a threshold specified, the new task will also have a threshold. Its date will 45 | be based on the number of days between the threshold date and the due date. 46 | 47 | 48 | ## Thanks 49 | 50 | * Thanks to @pcause for requesting this feature and pointing to example 51 | implementations. 52 | * Thanks to the authors of [SwiftoDo](https://swiftodoapp.com/) for documenting 53 | the due and recurring extensions to the spec. 54 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvgrimes/obsidian-todotxt-plugin/e133931bdb29a2adb823b03cce9f2be9631c507b/docs/screenshot.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "todotxt", 3 | "name": "TodoTxt", 4 | "version": "2.1.7", 5 | "minAppVersion": "0.15.0", 6 | "description": "Manage Todo.txt files.", 7 | "author": "Mark Grimes", 8 | "authorUrl": "https://github.com/mvgrimes", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-sample-plugin", 3 | "version": "2.1.7", 4 | "description": "This is a sample plugin for Obsidian (https://obsidian.md)", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "dev": "node ./scripts/esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node ./scripts/esbuild.config.mjs production && cp manifest.json versions.json dist/ && postcss -o ./dist/styles.css ./src/styles.css", 9 | "css": "postcss -o ./dist/styles.css --watch ./src/styles.css", 10 | "cp": "bash -c 'cp dist/* $HOME/Notes/.obsidian/plugins/todotxt-plugin/'", 11 | "cp:watch": "nodemon --watch ./dist --ext js,json,css --exec pnpm run cp", 12 | "test": "jest", 13 | "test:watch": "jest --watch --watchPathIgnorePatterns '^scripts/' --watchPathIgnorePatterns '^dist/'", 14 | "version": "node ./scripts/version-bump.mjs", 15 | "release": "pnpm run build && zx ./scripts/release.mjs", 16 | "deploy": "pnpm run release", 17 | "parser": "peggy -o src/lib/parser.js --format commonjs src/lib/todo.peggy", 18 | "parser:watch": "nodemon --watch ./src/lib/todo.peggy --ext peggy --exec pnpm run parser", 19 | "pretty": "prettier --write 'src/**/*.{js,ts,jsx,tsx}'" 20 | }, 21 | "keywords": [], 22 | "author": "", 23 | "license": "MIT", 24 | "devDependencies": { 25 | "@jest/globals": "^29.6.4", 26 | "@types/luxon": "^3.3.2", 27 | "@types/node": "^18.12.0", 28 | "@types/react": "^18.0.26", 29 | "@types/react-dom": "^18.0.9", 30 | "@typescript-eslint/eslint-plugin": "^6.0.0", 31 | "@typescript-eslint/parser": "^6.0.0", 32 | "autoprefixer": "^10.4.13", 33 | "builtin-modules": "3.3.0", 34 | "esbuild": "0.14.47", 35 | "expect": "^29.6.4", 36 | "jest": "^29.6.4", 37 | "nodemon": "^2.0.20", 38 | "obsidian": "latest", 39 | "peggy": "^4.0.2", 40 | "postcss": "^8.4.20", 41 | "postcss-cli": "^10.1.0", 42 | "postcss-import": "^15.1.0", 43 | "postcss-nested": "^6.0.0", 44 | "ts-jest": "^29.1.1", 45 | "tslib": "^2.6.0", 46 | "typescript": "^5.5.0", 47 | "zx": "^7.2.2" 48 | }, 49 | "dependencies": { 50 | "luxon": "^3.4.2", 51 | "react": "^18.2.0", 52 | "react-dom": "^18.2.0", 53 | "use-long-press": "^3.2.0" 54 | }, 55 | "packageManager": "pnpm@9.6.0" 56 | } 57 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'postcss-nested': {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /scripts/esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules' 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === 'production'); 13 | 14 | esbuild.build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['src/main.ts'], 19 | bundle: true, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/collab', 25 | '@codemirror/commands', 26 | '@codemirror/language', 27 | '@codemirror/lint', 28 | '@codemirror/search', 29 | '@codemirror/state', 30 | '@codemirror/view', 31 | '@lezer/common', 32 | '@lezer/highlight', 33 | '@lezer/lr', 34 | ...builtins], 35 | format: 'cjs', 36 | watch: !prod, 37 | target: 'es2018', 38 | logLevel: "info", 39 | sourcemap: prod ? false : 'inline', 40 | treeShaking: true, 41 | outfile: 'dist/main.js', 42 | }).catch(() => process.exit(1)); 43 | -------------------------------------------------------------------------------- /scripts/release.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | 3 | const version = process.env.npm_package_version; 4 | if ( ! version ){ 5 | console.error('No version specified in process.env.npm_package_vesion. This should be run via `npm run release`.'); 6 | process.exit(1); 7 | } 8 | 9 | await $`git diff --exit-code`; 10 | await $`git tag -a ${version} -m ${version}`; 11 | await $`git push`; 12 | await $`git push --tags`; 13 | -------------------------------------------------------------------------------- /scripts/version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | if ( ! targetVersion ){ 5 | console.error('No version specified in process.env.npm_package_vesion. This should be run via `npm run version`.'); 6 | process.exit(1); 7 | } 8 | 9 | // read minAppVersion from manifest.json and bump version to target version 10 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 11 | const { minAppVersion } = manifest; 12 | manifest.version = targetVersion; 13 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 14 | 15 | // update versions.json with target version and minAppVersion from manifest.json 16 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 17 | versions[targetVersion] = minAppVersion; 18 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 19 | -------------------------------------------------------------------------------- /src/lib/classNames.ts: -------------------------------------------------------------------------------- 1 | export default function classNames(...classes: string[]) { 2 | return classes.filter(Boolean).join(' '); 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/parser.js: -------------------------------------------------------------------------------- 1 | // @generated by Peggy 4.0.3. 2 | // 3 | // https://peggyjs.org/ 4 | 5 | "use strict"; 6 | 7 | 8 | function peg$subclass(child, parent) { 9 | function C() { this.constructor = child; } 10 | C.prototype = parent.prototype; 11 | child.prototype = new C(); 12 | } 13 | 14 | function peg$SyntaxError(message, expected, found, location) { 15 | var self = Error.call(this, message); 16 | // istanbul ignore next Check is a necessary evil to support older environments 17 | if (Object.setPrototypeOf) { 18 | Object.setPrototypeOf(self, peg$SyntaxError.prototype); 19 | } 20 | self.expected = expected; 21 | self.found = found; 22 | self.location = location; 23 | self.name = "SyntaxError"; 24 | return self; 25 | } 26 | 27 | peg$subclass(peg$SyntaxError, Error); 28 | 29 | function peg$padEnd(str, targetLength, padString) { 30 | padString = padString || " "; 31 | if (str.length > targetLength) { return str; } 32 | targetLength -= str.length; 33 | padString += padString.repeat(targetLength); 34 | return str + padString.slice(0, targetLength); 35 | } 36 | 37 | peg$SyntaxError.prototype.format = function(sources) { 38 | var str = "Error: " + this.message; 39 | if (this.location) { 40 | var src = null; 41 | var k; 42 | for (k = 0; k < sources.length; k++) { 43 | if (sources[k].source === this.location.source) { 44 | src = sources[k].text.split(/\r\n|\n|\r/g); 45 | break; 46 | } 47 | } 48 | var s = this.location.start; 49 | var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) 50 | ? this.location.source.offset(s) 51 | : s; 52 | var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; 53 | if (src) { 54 | var e = this.location.end; 55 | var filler = peg$padEnd("", offset_s.line.toString().length, ' '); 56 | var line = src[s.line - 1]; 57 | var last = s.line === e.line ? e.column : line.length + 1; 58 | var hatLen = (last - s.column) || 1; 59 | str += "\n --> " + loc + "\n" 60 | + filler + " |\n" 61 | + offset_s.line + " | " + line + "\n" 62 | + filler + " | " + peg$padEnd("", s.column - 1, ' ') 63 | + peg$padEnd("", hatLen, "^"); 64 | } else { 65 | str += "\n at " + loc; 66 | } 67 | } 68 | return str; 69 | }; 70 | 71 | peg$SyntaxError.buildMessage = function(expected, found) { 72 | var DESCRIBE_EXPECTATION_FNS = { 73 | literal: function(expectation) { 74 | return "\"" + literalEscape(expectation.text) + "\""; 75 | }, 76 | 77 | class: function(expectation) { 78 | var escapedParts = expectation.parts.map(function(part) { 79 | return Array.isArray(part) 80 | ? classEscape(part[0]) + "-" + classEscape(part[1]) 81 | : classEscape(part); 82 | }); 83 | 84 | return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; 85 | }, 86 | 87 | any: function() { 88 | return "any character"; 89 | }, 90 | 91 | end: function() { 92 | return "end of input"; 93 | }, 94 | 95 | other: function(expectation) { 96 | return expectation.description; 97 | } 98 | }; 99 | 100 | function hex(ch) { 101 | return ch.charCodeAt(0).toString(16).toUpperCase(); 102 | } 103 | 104 | function literalEscape(s) { 105 | return s 106 | .replace(/\\/g, "\\\\") 107 | .replace(/"/g, "\\\"") 108 | .replace(/\0/g, "\\0") 109 | .replace(/\t/g, "\\t") 110 | .replace(/\n/g, "\\n") 111 | .replace(/\r/g, "\\r") 112 | .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) 113 | .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); 114 | } 115 | 116 | function classEscape(s) { 117 | return s 118 | .replace(/\\/g, "\\\\") 119 | .replace(/\]/g, "\\]") 120 | .replace(/\^/g, "\\^") 121 | .replace(/-/g, "\\-") 122 | .replace(/\0/g, "\\0") 123 | .replace(/\t/g, "\\t") 124 | .replace(/\n/g, "\\n") 125 | .replace(/\r/g, "\\r") 126 | .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) 127 | .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); 128 | } 129 | 130 | function describeExpectation(expectation) { 131 | return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); 132 | } 133 | 134 | function describeExpected(expected) { 135 | var descriptions = expected.map(describeExpectation); 136 | var i, j; 137 | 138 | descriptions.sort(); 139 | 140 | if (descriptions.length > 0) { 141 | for (i = 1, j = 1; i < descriptions.length; i++) { 142 | if (descriptions[i - 1] !== descriptions[i]) { 143 | descriptions[j] = descriptions[i]; 144 | j++; 145 | } 146 | } 147 | descriptions.length = j; 148 | } 149 | 150 | switch (descriptions.length) { 151 | case 1: 152 | return descriptions[0]; 153 | 154 | case 2: 155 | return descriptions[0] + " or " + descriptions[1]; 156 | 157 | default: 158 | return descriptions.slice(0, -1).join(", ") 159 | + ", or " 160 | + descriptions[descriptions.length - 1]; 161 | } 162 | } 163 | 164 | function describeFound(found) { 165 | return found ? "\"" + literalEscape(found) + "\"" : "end of input"; 166 | } 167 | 168 | return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; 169 | }; 170 | 171 | function peg$parse(input, options) { 172 | options = options !== undefined ? options : {}; 173 | 174 | var peg$FAILED = {}; 175 | var peg$source = options.grammarSource; 176 | 177 | var peg$startRuleFunctions = { todo: peg$parsetodo }; 178 | var peg$startRuleFunction = peg$parsetodo; 179 | 180 | var peg$c0 = "x"; 181 | var peg$c1 = "("; 182 | var peg$c2 = ")"; 183 | var peg$c3 = "-"; 184 | var peg$c4 = ":"; 185 | var peg$c5 = "@"; 186 | var peg$c6 = "+"; 187 | var peg$c7 = "[["; 188 | var peg$c8 = "]]"; 189 | var peg$c9 = "["; 190 | var peg$c10 = "]"; 191 | 192 | var peg$r0 = /^[A-Z]/; 193 | var peg$r1 = /^[0-9]/; 194 | var peg$r2 = /^[^\]]/; 195 | var peg$r3 = /^[^)]/; 196 | var peg$r4 = /^[^ \t[\]:]/; 197 | var peg$r5 = /^[^ \t]/; 198 | var peg$r6 = /^[ \t]/; 199 | 200 | var peg$e0 = peg$literalExpectation("x", false); 201 | var peg$e1 = peg$literalExpectation("(", false); 202 | var peg$e2 = peg$classExpectation([["A", "Z"]], false, false); 203 | var peg$e3 = peg$literalExpectation(")", false); 204 | var peg$e4 = peg$classExpectation([["0", "9"]], false, false); 205 | var peg$e5 = peg$literalExpectation("-", false); 206 | var peg$e6 = peg$literalExpectation(":", false); 207 | var peg$e7 = peg$literalExpectation("@", false); 208 | var peg$e8 = peg$literalExpectation("+", false); 209 | var peg$e9 = peg$literalExpectation("[[", false); 210 | var peg$e10 = peg$literalExpectation("]]", false); 211 | var peg$e11 = peg$literalExpectation("[", false); 212 | var peg$e12 = peg$literalExpectation("]", false); 213 | var peg$e13 = peg$classExpectation(["]"], true, false); 214 | var peg$e14 = peg$classExpectation([")"], true, false); 215 | var peg$e15 = peg$classExpectation([" ", "\t", "[", "]", ":"], true, false); 216 | var peg$e16 = peg$classExpectation([" ", "\t"], true, false); 217 | var peg$e17 = peg$classExpectation([" ", "\t"], false, false); 218 | var peg$e18 = peg$anyExpectation(); 219 | 220 | var peg$f0 = function(completed, priority, firstDate, secondDate, description) { return { completed: completed, priority: priority, firstDate: firstDate, secondDate: secondDate, description: description } }; 221 | var peg$f1 = function(key, value) { return { tag: key, value: value } }; 222 | var peg$f2 = function(ctx) { return { context: ctx } }; 223 | var peg$f3 = function(prj) { return { project: prj } }; 224 | var peg$f4 = function(title) { return { link: title } }; 225 | var peg$f5 = function(title, url) { return { title: title, url: url }}; 226 | var peg$currPos = options.peg$currPos | 0; 227 | var peg$savedPos = peg$currPos; 228 | var peg$posDetailsCache = [{ line: 1, column: 1 }]; 229 | var peg$maxFailPos = peg$currPos; 230 | var peg$maxFailExpected = options.peg$maxFailExpected || []; 231 | var peg$silentFails = options.peg$silentFails | 0; 232 | 233 | var peg$result; 234 | 235 | if (options.startRule) { 236 | if (!(options.startRule in peg$startRuleFunctions)) { 237 | throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); 238 | } 239 | 240 | peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; 241 | } 242 | 243 | function text() { 244 | return input.substring(peg$savedPos, peg$currPos); 245 | } 246 | 247 | function offset() { 248 | return peg$savedPos; 249 | } 250 | 251 | function range() { 252 | return { 253 | source: peg$source, 254 | start: peg$savedPos, 255 | end: peg$currPos 256 | }; 257 | } 258 | 259 | function location() { 260 | return peg$computeLocation(peg$savedPos, peg$currPos); 261 | } 262 | 263 | function expected(description, location) { 264 | location = location !== undefined 265 | ? location 266 | : peg$computeLocation(peg$savedPos, peg$currPos); 267 | 268 | throw peg$buildStructuredError( 269 | [peg$otherExpectation(description)], 270 | input.substring(peg$savedPos, peg$currPos), 271 | location 272 | ); 273 | } 274 | 275 | function error(message, location) { 276 | location = location !== undefined 277 | ? location 278 | : peg$computeLocation(peg$savedPos, peg$currPos); 279 | 280 | throw peg$buildSimpleError(message, location); 281 | } 282 | 283 | function peg$literalExpectation(text, ignoreCase) { 284 | return { type: "literal", text: text, ignoreCase: ignoreCase }; 285 | } 286 | 287 | function peg$classExpectation(parts, inverted, ignoreCase) { 288 | return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; 289 | } 290 | 291 | function peg$anyExpectation() { 292 | return { type: "any" }; 293 | } 294 | 295 | function peg$endExpectation() { 296 | return { type: "end" }; 297 | } 298 | 299 | function peg$otherExpectation(description) { 300 | return { type: "other", description: description }; 301 | } 302 | 303 | function peg$computePosDetails(pos) { 304 | var details = peg$posDetailsCache[pos]; 305 | var p; 306 | 307 | if (details) { 308 | return details; 309 | } else { 310 | if (pos >= peg$posDetailsCache.length) { 311 | p = peg$posDetailsCache.length - 1; 312 | } else { 313 | p = pos; 314 | while (!peg$posDetailsCache[--p]) {} 315 | } 316 | 317 | details = peg$posDetailsCache[p]; 318 | details = { 319 | line: details.line, 320 | column: details.column 321 | }; 322 | 323 | while (p < pos) { 324 | if (input.charCodeAt(p) === 10) { 325 | details.line++; 326 | details.column = 1; 327 | } else { 328 | details.column++; 329 | } 330 | 331 | p++; 332 | } 333 | 334 | peg$posDetailsCache[pos] = details; 335 | 336 | return details; 337 | } 338 | } 339 | 340 | function peg$computeLocation(startPos, endPos, offset) { 341 | var startPosDetails = peg$computePosDetails(startPos); 342 | var endPosDetails = peg$computePosDetails(endPos); 343 | 344 | var res = { 345 | source: peg$source, 346 | start: { 347 | offset: startPos, 348 | line: startPosDetails.line, 349 | column: startPosDetails.column 350 | }, 351 | end: { 352 | offset: endPos, 353 | line: endPosDetails.line, 354 | column: endPosDetails.column 355 | } 356 | }; 357 | if (offset && peg$source && (typeof peg$source.offset === "function")) { 358 | res.start = peg$source.offset(res.start); 359 | res.end = peg$source.offset(res.end); 360 | } 361 | return res; 362 | } 363 | 364 | function peg$fail(expected) { 365 | if (peg$currPos < peg$maxFailPos) { return; } 366 | 367 | if (peg$currPos > peg$maxFailPos) { 368 | peg$maxFailPos = peg$currPos; 369 | peg$maxFailExpected = []; 370 | } 371 | 372 | peg$maxFailExpected.push(expected); 373 | } 374 | 375 | function peg$buildSimpleError(message, location) { 376 | return new peg$SyntaxError(message, null, null, location); 377 | } 378 | 379 | function peg$buildStructuredError(expected, found, location) { 380 | return new peg$SyntaxError( 381 | peg$SyntaxError.buildMessage(expected, found), 382 | expected, 383 | found, 384 | location 385 | ); 386 | } 387 | 388 | function peg$parsetodo() { 389 | var s0, s1, s2, s3, s4, s5, s6, s7, s8; 390 | 391 | s0 = peg$currPos; 392 | s1 = peg$parsecompleted(); 393 | if (s1 === peg$FAILED) { 394 | s1 = null; 395 | } 396 | s2 = peg$parsepriority(); 397 | if (s2 === peg$FAILED) { 398 | s2 = null; 399 | } 400 | s3 = peg$parsedate(); 401 | if (s3 === peg$FAILED) { 402 | s3 = null; 403 | } 404 | s4 = peg$parsedate(); 405 | if (s4 === peg$FAILED) { 406 | s4 = null; 407 | } 408 | s5 = peg$parsedescription(); 409 | s6 = peg$parsews(); 410 | if (s6 === peg$FAILED) { 411 | s6 = null; 412 | } 413 | s7 = peg$currPos; 414 | peg$silentFails++; 415 | s8 = peg$parseEOI(); 416 | peg$silentFails--; 417 | if (s8 !== peg$FAILED) { 418 | peg$currPos = s7; 419 | s7 = undefined; 420 | } else { 421 | s7 = peg$FAILED; 422 | } 423 | if (s7 !== peg$FAILED) { 424 | peg$savedPos = s0; 425 | s0 = peg$f0(s1, s2, s3, s4, s5); 426 | } else { 427 | peg$currPos = s0; 428 | s0 = peg$FAILED; 429 | } 430 | 431 | return s0; 432 | } 433 | 434 | function peg$parsecompleted() { 435 | var s0, s1, s2; 436 | 437 | s0 = peg$currPos; 438 | if (input.charCodeAt(peg$currPos) === 120) { 439 | s1 = peg$c0; 440 | peg$currPos++; 441 | } else { 442 | s1 = peg$FAILED; 443 | if (peg$silentFails === 0) { peg$fail(peg$e0); } 444 | } 445 | if (s1 !== peg$FAILED) { 446 | s2 = peg$parsews(); 447 | if (s2 !== peg$FAILED) { 448 | s0 = s1; 449 | } else { 450 | peg$currPos = s0; 451 | s0 = peg$FAILED; 452 | } 453 | } else { 454 | peg$currPos = s0; 455 | s0 = peg$FAILED; 456 | } 457 | 458 | return s0; 459 | } 460 | 461 | function peg$parsepriority() { 462 | var s0, s1, s2, s3, s4; 463 | 464 | s0 = peg$currPos; 465 | if (input.charCodeAt(peg$currPos) === 40) { 466 | s1 = peg$c1; 467 | peg$currPos++; 468 | } else { 469 | s1 = peg$FAILED; 470 | if (peg$silentFails === 0) { peg$fail(peg$e1); } 471 | } 472 | if (s1 !== peg$FAILED) { 473 | s2 = input.charAt(peg$currPos); 474 | if (peg$r0.test(s2)) { 475 | peg$currPos++; 476 | } else { 477 | s2 = peg$FAILED; 478 | if (peg$silentFails === 0) { peg$fail(peg$e2); } 479 | } 480 | if (s2 !== peg$FAILED) { 481 | if (input.charCodeAt(peg$currPos) === 41) { 482 | s3 = peg$c2; 483 | peg$currPos++; 484 | } else { 485 | s3 = peg$FAILED; 486 | if (peg$silentFails === 0) { peg$fail(peg$e3); } 487 | } 488 | if (s3 !== peg$FAILED) { 489 | s4 = peg$parsews(); 490 | if (s4 !== peg$FAILED) { 491 | s0 = s2; 492 | } else { 493 | peg$currPos = s0; 494 | s0 = peg$FAILED; 495 | } 496 | } else { 497 | peg$currPos = s0; 498 | s0 = peg$FAILED; 499 | } 500 | } else { 501 | peg$currPos = s0; 502 | s0 = peg$FAILED; 503 | } 504 | } else { 505 | peg$currPos = s0; 506 | s0 = peg$FAILED; 507 | } 508 | 509 | return s0; 510 | } 511 | 512 | function peg$parsedate() { 513 | var s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11, s12; 514 | 515 | s0 = peg$currPos; 516 | s1 = peg$currPos; 517 | s2 = peg$currPos; 518 | s3 = input.charAt(peg$currPos); 519 | if (peg$r1.test(s3)) { 520 | peg$currPos++; 521 | } else { 522 | s3 = peg$FAILED; 523 | if (peg$silentFails === 0) { peg$fail(peg$e4); } 524 | } 525 | if (s3 !== peg$FAILED) { 526 | s4 = input.charAt(peg$currPos); 527 | if (peg$r1.test(s4)) { 528 | peg$currPos++; 529 | } else { 530 | s4 = peg$FAILED; 531 | if (peg$silentFails === 0) { peg$fail(peg$e4); } 532 | } 533 | if (s4 !== peg$FAILED) { 534 | s5 = input.charAt(peg$currPos); 535 | if (peg$r1.test(s5)) { 536 | peg$currPos++; 537 | } else { 538 | s5 = peg$FAILED; 539 | if (peg$silentFails === 0) { peg$fail(peg$e4); } 540 | } 541 | if (s5 !== peg$FAILED) { 542 | s6 = input.charAt(peg$currPos); 543 | if (peg$r1.test(s6)) { 544 | peg$currPos++; 545 | } else { 546 | s6 = peg$FAILED; 547 | if (peg$silentFails === 0) { peg$fail(peg$e4); } 548 | } 549 | if (s6 !== peg$FAILED) { 550 | if (input.charCodeAt(peg$currPos) === 45) { 551 | s7 = peg$c3; 552 | peg$currPos++; 553 | } else { 554 | s7 = peg$FAILED; 555 | if (peg$silentFails === 0) { peg$fail(peg$e5); } 556 | } 557 | if (s7 !== peg$FAILED) { 558 | s8 = input.charAt(peg$currPos); 559 | if (peg$r1.test(s8)) { 560 | peg$currPos++; 561 | } else { 562 | s8 = peg$FAILED; 563 | if (peg$silentFails === 0) { peg$fail(peg$e4); } 564 | } 565 | if (s8 !== peg$FAILED) { 566 | s9 = input.charAt(peg$currPos); 567 | if (peg$r1.test(s9)) { 568 | peg$currPos++; 569 | } else { 570 | s9 = peg$FAILED; 571 | if (peg$silentFails === 0) { peg$fail(peg$e4); } 572 | } 573 | if (s9 !== peg$FAILED) { 574 | if (input.charCodeAt(peg$currPos) === 45) { 575 | s10 = peg$c3; 576 | peg$currPos++; 577 | } else { 578 | s10 = peg$FAILED; 579 | if (peg$silentFails === 0) { peg$fail(peg$e5); } 580 | } 581 | if (s10 !== peg$FAILED) { 582 | s11 = input.charAt(peg$currPos); 583 | if (peg$r1.test(s11)) { 584 | peg$currPos++; 585 | } else { 586 | s11 = peg$FAILED; 587 | if (peg$silentFails === 0) { peg$fail(peg$e4); } 588 | } 589 | if (s11 !== peg$FAILED) { 590 | s12 = input.charAt(peg$currPos); 591 | if (peg$r1.test(s12)) { 592 | peg$currPos++; 593 | } else { 594 | s12 = peg$FAILED; 595 | if (peg$silentFails === 0) { peg$fail(peg$e4); } 596 | } 597 | if (s12 !== peg$FAILED) { 598 | s3 = [s3, s4, s5, s6, s7, s8, s9, s10, s11, s12]; 599 | s2 = s3; 600 | } else { 601 | peg$currPos = s2; 602 | s2 = peg$FAILED; 603 | } 604 | } else { 605 | peg$currPos = s2; 606 | s2 = peg$FAILED; 607 | } 608 | } else { 609 | peg$currPos = s2; 610 | s2 = peg$FAILED; 611 | } 612 | } else { 613 | peg$currPos = s2; 614 | s2 = peg$FAILED; 615 | } 616 | } else { 617 | peg$currPos = s2; 618 | s2 = peg$FAILED; 619 | } 620 | } else { 621 | peg$currPos = s2; 622 | s2 = peg$FAILED; 623 | } 624 | } else { 625 | peg$currPos = s2; 626 | s2 = peg$FAILED; 627 | } 628 | } else { 629 | peg$currPos = s2; 630 | s2 = peg$FAILED; 631 | } 632 | } else { 633 | peg$currPos = s2; 634 | s2 = peg$FAILED; 635 | } 636 | } else { 637 | peg$currPos = s2; 638 | s2 = peg$FAILED; 639 | } 640 | if (s2 !== peg$FAILED) { 641 | s1 = input.substring(s1, peg$currPos); 642 | } else { 643 | s1 = s2; 644 | } 645 | if (s1 !== peg$FAILED) { 646 | s2 = peg$parsews(); 647 | if (s2 !== peg$FAILED) { 648 | s0 = s1; 649 | } else { 650 | peg$currPos = s0; 651 | s0 = peg$FAILED; 652 | } 653 | } else { 654 | peg$currPos = s0; 655 | s0 = peg$FAILED; 656 | } 657 | 658 | return s0; 659 | } 660 | 661 | function peg$parsedescription() { 662 | var s0, s1, s2; 663 | 664 | s0 = []; 665 | s1 = peg$parsepart(); 666 | while (s1 !== peg$FAILED) { 667 | s0.push(s1); 668 | s1 = peg$currPos; 669 | s2 = peg$parsews(); 670 | if (s2 !== peg$FAILED) { 671 | s2 = peg$parsepart(); 672 | if (s2 === peg$FAILED) { 673 | peg$currPos = s1; 674 | s1 = peg$FAILED; 675 | } else { 676 | s1 = s2; 677 | } 678 | } else { 679 | s1 = s2; 680 | } 681 | } 682 | 683 | return s0; 684 | } 685 | 686 | function peg$parsepart() { 687 | var s0; 688 | 689 | s0 = peg$parsetag(); 690 | if (s0 === peg$FAILED) { 691 | s0 = peg$parsecontext(); 692 | if (s0 === peg$FAILED) { 693 | s0 = peg$parseproject(); 694 | if (s0 === peg$FAILED) { 695 | s0 = peg$parselink(); 696 | if (s0 === peg$FAILED) { 697 | s0 = peg$parsexlink(); 698 | if (s0 === peg$FAILED) { 699 | s0 = peg$parseword(); 700 | } 701 | } 702 | } 703 | } 704 | } 705 | 706 | return s0; 707 | } 708 | 709 | function peg$parseword() { 710 | var s0, s1; 711 | 712 | s0 = peg$currPos; 713 | s1 = peg$parsechars(); 714 | if (s1 !== peg$FAILED) { 715 | s0 = input.substring(s0, peg$currPos); 716 | } else { 717 | s0 = s1; 718 | } 719 | 720 | return s0; 721 | } 722 | 723 | function peg$parsetag() { 724 | var s0, s1, s2, s3, s4; 725 | 726 | s0 = peg$currPos; 727 | s1 = peg$currPos; 728 | s2 = peg$parsetagName(); 729 | if (s2 !== peg$FAILED) { 730 | s1 = input.substring(s1, peg$currPos); 731 | } else { 732 | s1 = s2; 733 | } 734 | if (s1 !== peg$FAILED) { 735 | if (input.charCodeAt(peg$currPos) === 58) { 736 | s2 = peg$c4; 737 | peg$currPos++; 738 | } else { 739 | s2 = peg$FAILED; 740 | if (peg$silentFails === 0) { peg$fail(peg$e6); } 741 | } 742 | if (s2 !== peg$FAILED) { 743 | s3 = peg$currPos; 744 | s4 = peg$parsechars(); 745 | if (s4 !== peg$FAILED) { 746 | s3 = input.substring(s3, peg$currPos); 747 | } else { 748 | s3 = s4; 749 | } 750 | if (s3 !== peg$FAILED) { 751 | peg$savedPos = s0; 752 | s0 = peg$f1(s1, s3); 753 | } else { 754 | peg$currPos = s0; 755 | s0 = peg$FAILED; 756 | } 757 | } else { 758 | peg$currPos = s0; 759 | s0 = peg$FAILED; 760 | } 761 | } else { 762 | peg$currPos = s0; 763 | s0 = peg$FAILED; 764 | } 765 | 766 | return s0; 767 | } 768 | 769 | function peg$parsecontext() { 770 | var s0, s1, s2, s3; 771 | 772 | s0 = peg$currPos; 773 | if (input.charCodeAt(peg$currPos) === 64) { 774 | s1 = peg$c5; 775 | peg$currPos++; 776 | } else { 777 | s1 = peg$FAILED; 778 | if (peg$silentFails === 0) { peg$fail(peg$e7); } 779 | } 780 | if (s1 !== peg$FAILED) { 781 | s2 = peg$currPos; 782 | s3 = peg$parsechars(); 783 | if (s3 !== peg$FAILED) { 784 | s2 = input.substring(s2, peg$currPos); 785 | } else { 786 | s2 = s3; 787 | } 788 | if (s2 !== peg$FAILED) { 789 | peg$savedPos = s0; 790 | s0 = peg$f2(s2); 791 | } else { 792 | peg$currPos = s0; 793 | s0 = peg$FAILED; 794 | } 795 | } else { 796 | peg$currPos = s0; 797 | s0 = peg$FAILED; 798 | } 799 | 800 | return s0; 801 | } 802 | 803 | function peg$parseproject() { 804 | var s0, s1, s2, s3; 805 | 806 | s0 = peg$currPos; 807 | if (input.charCodeAt(peg$currPos) === 43) { 808 | s1 = peg$c6; 809 | peg$currPos++; 810 | } else { 811 | s1 = peg$FAILED; 812 | if (peg$silentFails === 0) { peg$fail(peg$e8); } 813 | } 814 | if (s1 !== peg$FAILED) { 815 | s2 = peg$currPos; 816 | s3 = peg$parsechars(); 817 | if (s3 !== peg$FAILED) { 818 | s2 = input.substring(s2, peg$currPos); 819 | } else { 820 | s2 = s3; 821 | } 822 | if (s2 !== peg$FAILED) { 823 | peg$savedPos = s0; 824 | s0 = peg$f3(s2); 825 | } else { 826 | peg$currPos = s0; 827 | s0 = peg$FAILED; 828 | } 829 | } else { 830 | peg$currPos = s0; 831 | s0 = peg$FAILED; 832 | } 833 | 834 | return s0; 835 | } 836 | 837 | function peg$parselink() { 838 | var s0, s1, s2, s3, s4, s5; 839 | 840 | s0 = peg$currPos; 841 | if (input.substr(peg$currPos, 2) === peg$c7) { 842 | s1 = peg$c7; 843 | peg$currPos += 2; 844 | } else { 845 | s1 = peg$FAILED; 846 | if (peg$silentFails === 0) { peg$fail(peg$e9); } 847 | } 848 | if (s1 !== peg$FAILED) { 849 | s2 = peg$parsetitle(); 850 | if (s2 !== peg$FAILED) { 851 | if (input.substr(peg$currPos, 2) === peg$c8) { 852 | s3 = peg$c8; 853 | peg$currPos += 2; 854 | } else { 855 | s3 = peg$FAILED; 856 | if (peg$silentFails === 0) { peg$fail(peg$e10); } 857 | } 858 | if (s3 !== peg$FAILED) { 859 | s4 = peg$currPos; 860 | peg$silentFails++; 861 | s5 = peg$parsews(); 862 | if (s5 === peg$FAILED) { 863 | s5 = peg$parseEOI(); 864 | } 865 | peg$silentFails--; 866 | if (s5 !== peg$FAILED) { 867 | peg$currPos = s4; 868 | s4 = undefined; 869 | } else { 870 | s4 = peg$FAILED; 871 | } 872 | if (s4 !== peg$FAILED) { 873 | peg$savedPos = s0; 874 | s0 = peg$f4(s2); 875 | } else { 876 | peg$currPos = s0; 877 | s0 = peg$FAILED; 878 | } 879 | } else { 880 | peg$currPos = s0; 881 | s0 = peg$FAILED; 882 | } 883 | } else { 884 | peg$currPos = s0; 885 | s0 = peg$FAILED; 886 | } 887 | } else { 888 | peg$currPos = s0; 889 | s0 = peg$FAILED; 890 | } 891 | 892 | return s0; 893 | } 894 | 895 | function peg$parsexlink() { 896 | var s0, s1, s2, s3, s4, s5, s6, s7, s8; 897 | 898 | s0 = peg$currPos; 899 | if (input.charCodeAt(peg$currPos) === 91) { 900 | s1 = peg$c9; 901 | peg$currPos++; 902 | } else { 903 | s1 = peg$FAILED; 904 | if (peg$silentFails === 0) { peg$fail(peg$e11); } 905 | } 906 | if (s1 !== peg$FAILED) { 907 | s2 = peg$parsetitle(); 908 | if (s2 !== peg$FAILED) { 909 | if (input.charCodeAt(peg$currPos) === 93) { 910 | s3 = peg$c10; 911 | peg$currPos++; 912 | } else { 913 | s3 = peg$FAILED; 914 | if (peg$silentFails === 0) { peg$fail(peg$e12); } 915 | } 916 | if (s3 !== peg$FAILED) { 917 | if (input.charCodeAt(peg$currPos) === 40) { 918 | s4 = peg$c1; 919 | peg$currPos++; 920 | } else { 921 | s4 = peg$FAILED; 922 | if (peg$silentFails === 0) { peg$fail(peg$e1); } 923 | } 924 | if (s4 !== peg$FAILED) { 925 | s5 = peg$parseurl(); 926 | if (s5 !== peg$FAILED) { 927 | if (input.charCodeAt(peg$currPos) === 41) { 928 | s6 = peg$c2; 929 | peg$currPos++; 930 | } else { 931 | s6 = peg$FAILED; 932 | if (peg$silentFails === 0) { peg$fail(peg$e3); } 933 | } 934 | if (s6 !== peg$FAILED) { 935 | s7 = peg$currPos; 936 | peg$silentFails++; 937 | s8 = peg$parsews(); 938 | if (s8 === peg$FAILED) { 939 | s8 = peg$parseEOI(); 940 | } 941 | peg$silentFails--; 942 | if (s8 !== peg$FAILED) { 943 | peg$currPos = s7; 944 | s7 = undefined; 945 | } else { 946 | s7 = peg$FAILED; 947 | } 948 | if (s7 !== peg$FAILED) { 949 | peg$savedPos = s0; 950 | s0 = peg$f5(s2, s5); 951 | } else { 952 | peg$currPos = s0; 953 | s0 = peg$FAILED; 954 | } 955 | } else { 956 | peg$currPos = s0; 957 | s0 = peg$FAILED; 958 | } 959 | } else { 960 | peg$currPos = s0; 961 | s0 = peg$FAILED; 962 | } 963 | } else { 964 | peg$currPos = s0; 965 | s0 = peg$FAILED; 966 | } 967 | } else { 968 | peg$currPos = s0; 969 | s0 = peg$FAILED; 970 | } 971 | } else { 972 | peg$currPos = s0; 973 | s0 = peg$FAILED; 974 | } 975 | } else { 976 | peg$currPos = s0; 977 | s0 = peg$FAILED; 978 | } 979 | 980 | return s0; 981 | } 982 | 983 | function peg$parsetitle() { 984 | var s0, s1, s2; 985 | 986 | s0 = peg$currPos; 987 | s1 = []; 988 | s2 = input.charAt(peg$currPos); 989 | if (peg$r2.test(s2)) { 990 | peg$currPos++; 991 | } else { 992 | s2 = peg$FAILED; 993 | if (peg$silentFails === 0) { peg$fail(peg$e13); } 994 | } 995 | if (s2 !== peg$FAILED) { 996 | while (s2 !== peg$FAILED) { 997 | s1.push(s2); 998 | s2 = input.charAt(peg$currPos); 999 | if (peg$r2.test(s2)) { 1000 | peg$currPos++; 1001 | } else { 1002 | s2 = peg$FAILED; 1003 | if (peg$silentFails === 0) { peg$fail(peg$e13); } 1004 | } 1005 | } 1006 | } else { 1007 | s1 = peg$FAILED; 1008 | } 1009 | if (s1 !== peg$FAILED) { 1010 | s0 = input.substring(s0, peg$currPos); 1011 | } else { 1012 | s0 = s1; 1013 | } 1014 | 1015 | return s0; 1016 | } 1017 | 1018 | function peg$parseurl() { 1019 | var s0, s1, s2; 1020 | 1021 | s0 = peg$currPos; 1022 | s1 = []; 1023 | s2 = input.charAt(peg$currPos); 1024 | if (peg$r3.test(s2)) { 1025 | peg$currPos++; 1026 | } else { 1027 | s2 = peg$FAILED; 1028 | if (peg$silentFails === 0) { peg$fail(peg$e14); } 1029 | } 1030 | if (s2 !== peg$FAILED) { 1031 | while (s2 !== peg$FAILED) { 1032 | s1.push(s2); 1033 | s2 = input.charAt(peg$currPos); 1034 | if (peg$r3.test(s2)) { 1035 | peg$currPos++; 1036 | } else { 1037 | s2 = peg$FAILED; 1038 | if (peg$silentFails === 0) { peg$fail(peg$e14); } 1039 | } 1040 | } 1041 | } else { 1042 | s1 = peg$FAILED; 1043 | } 1044 | if (s1 !== peg$FAILED) { 1045 | s0 = input.substring(s0, peg$currPos); 1046 | } else { 1047 | s0 = s1; 1048 | } 1049 | 1050 | return s0; 1051 | } 1052 | 1053 | function peg$parsetagName() { 1054 | var s0, s1; 1055 | 1056 | s0 = []; 1057 | s1 = input.charAt(peg$currPos); 1058 | if (peg$r4.test(s1)) { 1059 | peg$currPos++; 1060 | } else { 1061 | s1 = peg$FAILED; 1062 | if (peg$silentFails === 0) { peg$fail(peg$e15); } 1063 | } 1064 | if (s1 !== peg$FAILED) { 1065 | while (s1 !== peg$FAILED) { 1066 | s0.push(s1); 1067 | s1 = input.charAt(peg$currPos); 1068 | if (peg$r4.test(s1)) { 1069 | peg$currPos++; 1070 | } else { 1071 | s1 = peg$FAILED; 1072 | if (peg$silentFails === 0) { peg$fail(peg$e15); } 1073 | } 1074 | } 1075 | } else { 1076 | s0 = peg$FAILED; 1077 | } 1078 | 1079 | return s0; 1080 | } 1081 | 1082 | function peg$parsechars() { 1083 | var s0, s1; 1084 | 1085 | s0 = []; 1086 | s1 = input.charAt(peg$currPos); 1087 | if (peg$r5.test(s1)) { 1088 | peg$currPos++; 1089 | } else { 1090 | s1 = peg$FAILED; 1091 | if (peg$silentFails === 0) { peg$fail(peg$e16); } 1092 | } 1093 | if (s1 !== peg$FAILED) { 1094 | while (s1 !== peg$FAILED) { 1095 | s0.push(s1); 1096 | s1 = input.charAt(peg$currPos); 1097 | if (peg$r5.test(s1)) { 1098 | peg$currPos++; 1099 | } else { 1100 | s1 = peg$FAILED; 1101 | if (peg$silentFails === 0) { peg$fail(peg$e16); } 1102 | } 1103 | } 1104 | } else { 1105 | s0 = peg$FAILED; 1106 | } 1107 | 1108 | return s0; 1109 | } 1110 | 1111 | function peg$parsews() { 1112 | var s0, s1; 1113 | 1114 | s0 = []; 1115 | s1 = input.charAt(peg$currPos); 1116 | if (peg$r6.test(s1)) { 1117 | peg$currPos++; 1118 | } else { 1119 | s1 = peg$FAILED; 1120 | if (peg$silentFails === 0) { peg$fail(peg$e17); } 1121 | } 1122 | if (s1 !== peg$FAILED) { 1123 | while (s1 !== peg$FAILED) { 1124 | s0.push(s1); 1125 | s1 = input.charAt(peg$currPos); 1126 | if (peg$r6.test(s1)) { 1127 | peg$currPos++; 1128 | } else { 1129 | s1 = peg$FAILED; 1130 | if (peg$silentFails === 0) { peg$fail(peg$e17); } 1131 | } 1132 | } 1133 | } else { 1134 | s0 = peg$FAILED; 1135 | } 1136 | 1137 | return s0; 1138 | } 1139 | 1140 | function peg$parseEOI() { 1141 | var s0, s1; 1142 | 1143 | s0 = peg$currPos; 1144 | peg$silentFails++; 1145 | if (input.length > peg$currPos) { 1146 | s1 = input.charAt(peg$currPos); 1147 | peg$currPos++; 1148 | } else { 1149 | s1 = peg$FAILED; 1150 | if (peg$silentFails === 0) { peg$fail(peg$e18); } 1151 | } 1152 | peg$silentFails--; 1153 | if (s1 === peg$FAILED) { 1154 | s0 = undefined; 1155 | } else { 1156 | peg$currPos = s0; 1157 | s0 = peg$FAILED; 1158 | } 1159 | 1160 | return s0; 1161 | } 1162 | 1163 | peg$result = peg$startRuleFunction(); 1164 | 1165 | if (options.peg$library) { 1166 | return /** @type {any} */ ({ 1167 | peg$result, 1168 | peg$currPos, 1169 | peg$FAILED, 1170 | peg$maxFailExpected, 1171 | peg$maxFailPos 1172 | }); 1173 | } 1174 | if (peg$result !== peg$FAILED && peg$currPos === input.length) { 1175 | return peg$result; 1176 | } else { 1177 | if (peg$result !== peg$FAILED && peg$currPos < input.length) { 1178 | peg$fail(peg$endExpectation()); 1179 | } 1180 | 1181 | throw peg$buildStructuredError( 1182 | peg$maxFailExpected, 1183 | peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, 1184 | peg$maxFailPos < input.length 1185 | ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) 1186 | : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) 1187 | ); 1188 | } 1189 | } 1190 | 1191 | module.exports = { 1192 | StartRules: ["todo"], 1193 | SyntaxError: peg$SyntaxError, 1194 | parse: peg$parse 1195 | }; 1196 | -------------------------------------------------------------------------------- /src/lib/todo.peggy: -------------------------------------------------------------------------------- 1 | todo = 2 | completed:completed? 3 | priority:priority? 4 | firstDate:date? 5 | secondDate:date? 6 | description:description 7 | trailing_ws? 8 | &EOI 9 | { return { completed: completed, priority: priority, firstDate: firstDate, secondDate: secondDate, description: description } } 10 | 11 | completed = @"x" ws 12 | priority = "(" @[A-Z] ")" ws 13 | date = @$( [0-9][0-9][0-9][0-9] "-" [0-9][0-9] "-" [0-9][0-9] ) ws 14 | 15 | description = part |0.., ws| 16 | trailing_ws = ws 17 | part = tag / context / project / link / xlink / word 18 | word = $chars 19 | tag = key:$tagName ":" value:$chars { return { tag: key, value: value } } 20 | context = "@" ctx:$chars { return { context: ctx } } 21 | project = "+" prj:$chars { return { project: prj } } 22 | link = "[[" title:title "]]" &(ws / EOI) { return { link: title } } 23 | xlink = "[" title:title "]" "(" url:url ")" &(ws / EOI) { return { title: title, url: url }} 24 | title = $[^\]]+ 25 | url = $[^\)]+ 26 | 27 | 28 | tagName = [^ \t\[\]:]+ 29 | chars = [^ \t]+ 30 | ws = [ \t]+ 31 | EOI = !. 32 | -------------------------------------------------------------------------------- /src/lib/todo.ts: -------------------------------------------------------------------------------- 1 | import { DateTime, Duration } from 'luxon'; 2 | import parser from './parser'; 3 | 4 | // The pattern for a todotxt entry 5 | // See: https://github.com/todotxt/todo.txt 6 | // Examples: 7 | // x 2020-11-19 2020-11-16 Pay Amex Cash Card Bill (Due Dec 11th) t:2020-11-21 +Home @Bills 8 | // (B) 2020-11-17 Update Mac systems +Home 9 | 10 | export class TodoTag { 11 | constructor( 12 | public tag: string, 13 | public value: string, 14 | ) {} 15 | clone() { 16 | return new TodoTag(this.tag, this.value); 17 | } 18 | toString() { 19 | return `${this.tag}:${this.value}`; 20 | } 21 | toHtml() { 22 | return this.toString(); 23 | } 24 | } 25 | 26 | export class TodoCtx { 27 | constructor(public ctx: string) {} 28 | clone() { 29 | return new TodoCtx(this.ctx); 30 | } 31 | toString() { 32 | return `@${this.ctx}`; 33 | } 34 | toHtml() { 35 | return this.toString(); 36 | } 37 | } 38 | 39 | export class TodoProject { 40 | constructor(public project: string) {} 41 | clone() { 42 | return new TodoProject(this.project); 43 | } 44 | toString() { 45 | return `+${this.project}`; 46 | } 47 | toHtml() { 48 | return this.toString(); 49 | } 50 | } 51 | 52 | export class TodoExternalLink { 53 | constructor( 54 | public title: string, 55 | public url: string, 56 | ) {} 57 | clone() { 58 | return new TodoExternalLink(this.title, this.url); 59 | } 60 | toString() { 61 | return `[${this.title}](${this.url})`; 62 | } 63 | toHtml() { 64 | return `${this.title}`; 65 | } 66 | } 67 | 68 | export class TodoInternalLink { 69 | constructor(public title: string) {} 70 | clone() { 71 | return new TodoInternalLink(this.title); 72 | } 73 | toString() { 74 | return `[[${this.title}]]`; 75 | } 76 | toHtml() { 77 | return `${this.title}`; 78 | } 79 | } 80 | 81 | export class TodoWord { 82 | constructor(public word: string) {} 83 | clone() { 84 | return new TodoWord(this.word); 85 | } 86 | toString() { 87 | return `${this.word}`; 88 | } 89 | toHtml() { 90 | return this.toString(); 91 | } 92 | } 93 | 94 | export type TodoDescription = ( 95 | | TodoWord 96 | | TodoProject 97 | | TodoCtx 98 | | TodoTag 99 | | TodoInternalLink 100 | | TodoExternalLink 101 | )[]; 102 | 103 | type TodoArgs = { 104 | id: number; 105 | completed: boolean; 106 | completedDate?: string; 107 | priority: string; 108 | createDate?: string; 109 | description: TodoDescription; 110 | }; 111 | 112 | export class Todo { 113 | id: number; 114 | completed: boolean; 115 | completedDate?: string; 116 | priority: string; 117 | createDate?: string; 118 | description: TodoDescription; 119 | 120 | constructor(args: TodoArgs) { 121 | this.id = args.id; 122 | this.completed = args.completed; 123 | this.completedDate = args.completedDate; 124 | this.priority = args.priority; 125 | this.createDate = args.createDate; 126 | this.description = args.description; 127 | } 128 | 129 | static parse(line: string, id: number): Todo { 130 | const data = parser.parse(line); 131 | // console.error(`[TodoTxt] setViewData: cannot match todo`, line); 132 | 133 | const desc = data.description.map((item: any) => { 134 | if (typeof item === 'string') { 135 | return new TodoWord(item); 136 | } 137 | if ('project' in item) { 138 | return new TodoProject(item.project); 139 | } 140 | if ('context' in item) { 141 | return new TodoCtx(item.context); 142 | } 143 | if ('tag' in item) { 144 | return new TodoTag(item.tag, item.value); 145 | } 146 | if ('link' in item) { 147 | return new TodoInternalLink(item.link); 148 | } 149 | if ('url' in item) { 150 | return new TodoExternalLink(item.title, item.url); 151 | } 152 | throw new Error( 153 | `[TodoTxt] todo.parse: unknown item type: ${JSON.stringify(item)})`, 154 | ); 155 | }); 156 | 157 | return new Todo({ 158 | id, 159 | completed: !!data.completed, 160 | priority: data.priority ?? '', 161 | createDate: data.secondDate ?? data.firstDate, 162 | completedDate: data.secondDate ? data.firstDate : undefined, 163 | description: desc, 164 | }); 165 | } 166 | 167 | complete(preservePriority: boolean) { 168 | this.completed = true; 169 | this.completedDate = new Date().toISOString().substring(0, 10); 170 | 171 | if (preservePriority) { 172 | // If there is a priority, create a pri: tag to store it 173 | if (this.priority?.length > 0) { 174 | this.setTag('pri', this.priority); 175 | } 176 | } 177 | } 178 | 179 | uncomplete(preservePriority: boolean) { 180 | this.completed = false; 181 | this.completedDate = undefined; 182 | 183 | if (preservePriority) { 184 | // If there is a pri:X tag, use that to set the priority then remove all the pri: tags. 185 | const priorityTag = this.getTag('pri'); 186 | if (priorityTag && priorityTag.value.length === 1) { 187 | this.priority = priorityTag.value.toUpperCase(); 188 | this.description = this.description.filter( 189 | (item) => !(item instanceof TodoTag && item.tag === 'pri'), 190 | ); 191 | } 192 | } 193 | } 194 | 195 | preThreshold() { 196 | const thresholdTag = this.getTag('t'); 197 | if (!thresholdTag) return false; 198 | 199 | const today = (this.completedDate = new Date() 200 | .toISOString() 201 | .substring(0, 10)); 202 | return thresholdTag.value > today; 203 | } 204 | 205 | recurring(id: number) { 206 | const rec = this.getTag('rec'); 207 | if (!rec) return false; 208 | 209 | const recurrance = rec.value.match(/^(\+?)(\d+)([dbwmy]+)$/); 210 | if (!recurrance) return false; // TODO: notify of a failed recurrance 211 | 212 | const [_, strict, n, datePart] = recurrance; 213 | const duration = getDuration(+n, datePart); 214 | if (!duration) return false; // TODO: notify of a failed recurrance 215 | 216 | const newTodo = this.clone(id); 217 | newTodo.setNextDueDate(!!strict, duration); 218 | if (newTodo.setNextThresholdDate(this)) { 219 | // If it is recurring and has a threshold, remove the priority, it will be handled by `todo promote` 220 | // newTodo.setTag('pri', this.priority); 221 | newTodo.priority = ''; 222 | } 223 | return newTodo; 224 | } 225 | 226 | getDueDate() { 227 | return this.getTagAsDate('due'); 228 | } 229 | getThresholdDate() { 230 | return this.getTagAsDate('t'); 231 | } 232 | 233 | getTagAsDate(key: string) { 234 | const tag = this.getTag(key); 235 | if (!tag) return; 236 | return DateTime.fromFormat(tag.value, 'yyyy-MM-dd'); 237 | } 238 | 239 | // moment.js should only be used here (needed for the duration) 240 | setNextDueDate(strict: boolean, duration: Duration) { 241 | let start = DateTime.now(); 242 | 243 | if (strict) { 244 | const currentDueDate = this.getDueDate(); 245 | if (currentDueDate?.isValid) start = currentDueDate as DateTime; 246 | // TODO: notify about failed due date parsing? 247 | } 248 | 249 | if (!duration) return; 250 | 251 | const due = start.plus(duration).toISODate(); 252 | if (!due) return; // TODO: notify failed addition? 253 | this.setTag('due', due); 254 | 255 | return true; 256 | } 257 | 258 | setNextThresholdDate(from: Todo) { 259 | const thresholdDate = from.getThresholdDate(); 260 | if (!thresholdDate) return; 261 | 262 | const dueDate = from.getDueDate() || DateTime.now(); 263 | 264 | const duration = dueDate.diff(thresholdDate); 265 | const newDueDate = this.getDueDate(); 266 | if (!newDueDate) return; // Should have set the due date already 267 | 268 | const newThreshold = newDueDate.minus(duration).toISODate(); 269 | if (!newThreshold) return; // TODO: notify about failure? 270 | this.setTag('t', newThreshold); 271 | 272 | return true; 273 | } 274 | 275 | // Set an existing tag to the given value. Create the tag if it doesn't exist. 276 | setTag(key: string, value: string) { 277 | const existingTag = this.getTag(key); 278 | if (existingTag) { 279 | existingTag.value = value; 280 | return; 281 | } 282 | 283 | this.description.push(new TodoTag(key, value)); 284 | } 285 | 286 | getTag(key: string) { 287 | return this.getTags().find((tag) => tag.tag === key); 288 | } 289 | 290 | getTags() { 291 | return this.description.filter( 292 | (item) => item instanceof TodoTag, 293 | ) as TodoTag[]; 294 | } 295 | getProjects() { 296 | return this.description.filter( 297 | (item) => item instanceof TodoProject, 298 | ) as TodoProject[]; 299 | } 300 | getContexts() { 301 | return this.description.filter( 302 | (item) => item instanceof TodoCtx, 303 | ) as TodoCtx[]; 304 | } 305 | getInternalLinks() { 306 | return this.description.filter( 307 | (item) => item instanceof TodoInternalLink, 308 | ) as TodoInternalLink[]; 309 | } 310 | getExternalLinks() { 311 | return this.description.filter( 312 | (item) => item instanceof TodoExternalLink, 313 | ) as TodoExternalLink[]; 314 | } 315 | 316 | // Returns a new Todo with: 317 | // - todays createDate 318 | // - uncompleted 319 | // - any pri tags stripped 320 | clone(id: number) { 321 | const description = this.description 322 | .filter((item) => !(item instanceof TodoTag && item.tag === 'pri')) 323 | .map((item) => item.clone()); 324 | 325 | return new Todo({ 326 | id, 327 | completed: false, 328 | priority: this.priority, 329 | createDate: new Date().toISOString().substring(0, 10), 330 | description, 331 | }); 332 | } 333 | 334 | toString() { 335 | return [ 336 | this.completed ? 'x' : null, 337 | this.priority && !this.completed ? `(${this.priority})` : null, 338 | this.completedDate, 339 | this.createDate, 340 | this.description.map((item) => item.toString()).join(' '), 341 | ] 342 | .filter((item) => item) 343 | .join(' '); 344 | } 345 | 346 | sort(t: Todo) { 347 | return sortTodo(this, t); 348 | } 349 | } 350 | 351 | export function sortTodo(a: Todo, b: Todo) { 352 | if (a.completed < b.completed) return -1; 353 | if (a.completed > b.completed) return 1; 354 | if ((a.priority || 'X') < (b.priority || 'X')) return -1; 355 | if ((a.priority || 'X') > (b.priority || 'X')) return 1; 356 | return a.description.toString().localeCompare(b.description.toString()); 357 | } 358 | 359 | function getDuration(n: number, datePart: string) { 360 | return datePart === 'd' 361 | ? Duration.fromObject({ days: n }) 362 | : datePart === 'b' 363 | ? Duration.fromObject({ days: n }) 364 | : datePart === 'w' 365 | ? Duration.fromObject({ weeks: n }) 366 | : datePart === 'm' 367 | ? Duration.fromObject({ months: n }) 368 | : datePart === 'y' 369 | ? Duration.fromObject({ years: n }) 370 | : null; 371 | } 372 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | // Notice, 4 | Plugin, 5 | PluginSettingTab, 6 | Setting, 7 | WorkspaceLeaf, 8 | } from 'obsidian'; 9 | import { TodotxtView, VIEW_TYPE_TODOTXT } from './view'; 10 | 11 | interface TodotxtPluginSettings { 12 | defaultPriorityFilter: string; 13 | defaultOrganizeBy: 'project' | 'context'; 14 | defaultTodotxt: string; 15 | defaultGroupBy: string; 16 | additionalExts: string[]; 17 | recurringTasks: boolean; 18 | preservePriority: boolean; 19 | } 20 | 21 | const DEFAULT_SETTINGS: TodotxtPluginSettings = { 22 | defaultPriorityFilter: 'B', 23 | defaultOrganizeBy: 'project', 24 | defaultTodotxt: 'default', 25 | defaultGroupBy: 'Default', 26 | additionalExts: [], 27 | recurringTasks: false, 28 | preservePriority: true, 29 | }; 30 | 31 | export default class TodotxtPlugin extends Plugin { 32 | settings: TodotxtPluginSettings; 33 | 34 | async onload() { 35 | await this.loadSettings(); 36 | 37 | this.registerView( 38 | VIEW_TYPE_TODOTXT, 39 | (leaf: WorkspaceLeaf) => new TodotxtView(leaf, this), 40 | ); 41 | this.registerExtensions( 42 | ['todotxt', ...this.settings.additionalExts], 43 | VIEW_TYPE_TODOTXT, 44 | ); 45 | 46 | // This adds a settings tab so the user can configure various aspects of the plugin 47 | this.addSettingTab(new TodoSettingTab(this.app, this)); 48 | 49 | // Add a command for the command palette 50 | // this.addCommand({ 51 | // id: 'todotxt-add-todo', 52 | // name: 'Add todo item to TODOTXT file', 53 | // callback: () => { 54 | // new TodoModal(this.app, (result) => { 55 | // new Notice(`Adding '${result}' to ${this.settings.defaultTodotxt}`); 56 | // }).open(); 57 | // }, 58 | // }); 59 | 60 | // This creates an icon in the left ribbon 61 | // Could be used to jump to the default todo list 62 | // const ribbonIconEl = this.addRibbonIcon( 63 | // 'dice', 64 | // 'Todo Plugin', 65 | // (evt: MouseEvent) => { 66 | // // Called when the user clicks the icon. 67 | // new Notice('This is a notice!'); 68 | // }, 69 | // ); 70 | // // Perform additional things with the ribbon 71 | // ribbonIconEl.addClass('my-plugin-ribbon-class'); 72 | 73 | // This adds a status bar item to the bottom of the app. Does not work on mobile apps. 74 | // TODO: add a count of todos, fileFormat 75 | // const statusBarItemEl = this.addStatusBarItem(); 76 | // statusBarItemEl.setText('Todotxt'); 77 | 78 | // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) 79 | // Using this function will automatically remove the event listener when this plugin is disabled. 80 | // this.registerDomEvent(document, "click", (evt: MouseEvent) => { 81 | // console.log("click", evt); 82 | // }); 83 | 84 | // When registering intervals, this function will automatically clear the interval when the plugin is disabled. 85 | // this.registerInterval( 86 | // window.setInterval(() => console.log("setInterval"), 5 * 60 * 1000) 87 | // ); 88 | 89 | console.log( 90 | `Todo.txt: version ${this.manifest.version} (requires Obsidian ${this.manifest.minAppVersion})`, 91 | ); 92 | } 93 | 94 | onunload() {} 95 | 96 | async loadSettings() { 97 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 98 | } 99 | 100 | async saveSettings() { 101 | await this.saveData(this.settings); 102 | } 103 | } 104 | 105 | // class TodoModal extends Modal { 106 | // constructor(app: App) { 107 | // super(app); 108 | // } 109 | // 110 | // onOpen() { 111 | // const { contentEl } = this; 112 | // contentEl.setText("Woah!"); 113 | // } 114 | // 115 | // onClose() { 116 | // const { contentEl } = this; 117 | // contentEl.empty(); 118 | // } 119 | // } 120 | 121 | class TodoSettingTab extends PluginSettingTab { 122 | plugin: TodotxtPlugin; 123 | 124 | constructor(app: App, plugin: TodotxtPlugin) { 125 | super(app, plugin); 126 | this.plugin = plugin; 127 | } 128 | 129 | display(): void { 130 | const { containerEl } = this; 131 | 132 | containerEl.empty(); 133 | const settingsDiv = containerEl.createDiv({ cls: 'todotxt-settings' }); 134 | settingsDiv.createEl('h2', { text: 'Settings for TodoTxt plugin.' }); 135 | 136 | new Setting(settingsDiv) 137 | .setName('Default priority filter') 138 | .setDesc( 139 | 'By default, only Todos with this priority or high will be displayed.', 140 | ) 141 | .addDropdown((dropdown) => 142 | dropdown 143 | .addOptions({ A: 'A', B: 'B', C: 'C', D: 'D', Z: 'All' }) 144 | .setValue(this.plugin.settings.defaultPriorityFilter) 145 | .onChange(async (value) => { 146 | this.plugin.settings.defaultPriorityFilter = value; 147 | await this.plugin.saveSettings(); 148 | }), 149 | ); 150 | 151 | new Setting(settingsDiv) 152 | .setName('Default Todo grouping') 153 | .setDesc('By default, only Todos will be organized in lists by these.') 154 | .addDropdown((dropdown) => 155 | dropdown 156 | .addOptions({ project: 'Project', context: 'Context' }) 157 | .setValue(this.plugin.settings.defaultOrganizeBy) 158 | .onChange(async (value) => { 159 | this.plugin.settings.defaultOrganizeBy = 160 | value === 'project' ? 'project' : 'context'; 161 | await this.plugin.saveSettings(); 162 | }), 163 | ); 164 | 165 | new Setting(settingsDiv) 166 | .setName('Name for the default project/context') 167 | .setDesc( 168 | 'If no project/context is specified for a Todo, it will be listed under this list. ' + 169 | 'The todotxt file will need to be reloaded in order to see the changes.', 170 | ) 171 | .addText((text) => 172 | text 173 | .setValue(this.plugin.settings.defaultGroupBy) 174 | .onChange(async (value) => { 175 | this.plugin.settings.defaultGroupBy = 176 | value.replace(/[ \t]/g, '') || 'Default'; 177 | await this.plugin.saveSettings(); 178 | }), 179 | ); 180 | 181 | settingsDiv.createEl('h4', { 182 | text: 'Experimental', 183 | cls: 'todo-experimental-heading', 184 | }); 185 | const expDiv = settingsDiv.createEl('details'); 186 | expDiv.createEl('summary', { 187 | text: 188 | 'Warning: These features are considered experimental and may change or be removed from future versions.' + 189 | '(Click to expand.)', 190 | }); 191 | 192 | new Setting(expDiv) 193 | .setName('Preserve tast priorities when completed') 194 | .setDesc( 195 | `According to the todotxt spec, priorities are typically discarded when a task is completed. ` + 196 | `This feature will save the priority (as a "pri:X" tag). If the task is "uncompleted" the priority will be restored.`, 197 | ) 198 | .addToggle((toggle) => 199 | toggle 200 | .setValue(this.plugin.settings.preservePriority) 201 | .onChange(async (value) => { 202 | this.plugin.settings.preservePriority = value; 203 | await this.plugin.saveSettings(); 204 | }), 205 | ); 206 | 207 | new Setting(expDiv) 208 | .setName('Support for recurring tasks') 209 | .setDesc( 210 | `When completing tasks with the rec: tag, create a new task based. This is not part of the todotxt spec.`, 211 | ) 212 | .addToggle((toggle) => 213 | toggle 214 | .setValue(this.plugin.settings.recurringTasks) 215 | .onChange(async (value) => { 216 | this.plugin.settings.recurringTasks = value; 217 | await this.plugin.saveSettings(); 218 | }), 219 | ); 220 | 221 | new Setting(expDiv) 222 | .setName('Additional TodoTxt extension') 223 | .setDesc( 224 | 'Additional filename extensions (separate multiple extensions by commas) to treat as TodoTxt files.' + 225 | '\nRequires restart of Obsidian.', 226 | ) 227 | .addText((text) => 228 | text 229 | .setValue(this.plugin.settings.additionalExts.join(',')) 230 | .onChange(async (value) => { 231 | this.plugin.settings.additionalExts = value?.split(/\s*,\s*/) || []; 232 | await this.plugin.saveSettings(); 233 | }), 234 | ); 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | #todotxt { 2 | --todotxt-color-amber-300: rgb(252 211 77); 3 | --todotxt-color-amber-600: rgb(217 119 6); 4 | --todotxt-color-blue-200: rgb(191 219 254); 5 | --todotxt-color-blue-300: rgb(147 197 253); 6 | --todotxt-color-blue-600: #2563EB; 7 | --todotxt-color-gray-200: rgb(229 231 235); 8 | --todotxt-color-gray-400: rgb(156 163 175); 9 | --todotxt-color-gray-600: rgb(75 85 99); 10 | --todotxt-color-gray-700: rgb(55 65 81); 11 | --todotxt-color-gray-900: rgb(17 24 39); 12 | --todotxt-color-gray-900-9: rgb(17 24 39 / 0.90); 13 | --todotxt-color-indigo-200: rgb(199 210 254); 14 | --todotxt-color-indigo-900: rgb(49 46 129); 15 | --todotxt-color-orange-300: rgb(253 186 116); 16 | --todotxt-color-orange-600: #EA580C; 17 | --todotxt-color-red-300: rgb(252 165 165); 18 | --todotxt-color-red-600: #DC2626; 19 | --todotxt-color-red-700: rgb(185 28 28); 20 | --todotxt-color-sky-300: #7DD3FC; 21 | --todotxt-color-sky-600: rgb(2 132 199); 22 | --todotxt-color-slate-700: rgb(51 65 85); 23 | 24 | .todo-container { 25 | width: 100%; 26 | } 27 | 28 | .todo-controls { 29 | display: flex; 30 | align-items: center; 31 | gap: 0.75rem; 32 | } 33 | 34 | .todo-list-container { 35 | display: grid; 36 | grid-template-columns: repeat(1, minmax(0, 1fr)); 37 | gap: 0.75rem; 38 | } 39 | 40 | .todo-list-header { 41 | border-bottom: 2px solid var(--checkbox-color); 42 | } 43 | 44 | .todo-list { 45 | display: flex; 46 | flex-direction: column; 47 | gap: 0.75rem; 48 | } 49 | 50 | .todo-filter { 51 | position: relative; 52 | 53 | input { 54 | display: block; 55 | width: 5em; 56 | } 57 | input:focus { 58 | width: 10em; 59 | } 60 | input::placeholder { 61 | color: var(--text-muted); 62 | } 63 | .icon-container { 64 | position: absolute; 65 | top: 0; 66 | bottom: 0; 67 | right: 0.5rem; 68 | } 69 | .icon { 70 | width: 1rem; 71 | height: 1rem; 72 | color: var(--text-muted); 73 | } 74 | button { 75 | margin: 0; 76 | padding: 0; 77 | background: none; 78 | border: none; 79 | box-shadow: none; 80 | } 81 | } 82 | 83 | .todo { 84 | input[type="checkbox"] { 85 | margin-top: 0.125rem; 86 | } 87 | 88 | border: 3px solid transparent; 89 | border-radius: 4px; 90 | 91 | display: flex; 92 | } 93 | 94 | /* .todo:has(input:focus) { */ 95 | .todo:focus-within { 96 | border: 3px solid var(--text-accent); 97 | } 98 | 99 | .todo-label { 100 | display: flex; 101 | align-items: flex-start; 102 | } 103 | 104 | .todo-priority { 105 | border-radius: 0.5rem; 106 | padding: 0.125rem 0.375rem; 107 | margin-right: 0.25rem; 108 | font: 0.75rem; 109 | line-height: 1rem; 110 | } 111 | 112 | .todo-priority-a { 113 | color: var(--todotxt-color-red-600); 114 | background-color: var(--todotxt-color-red-300); 115 | } 116 | .todo-priority-b { 117 | color: var(--todotxt-color-orange-600); 118 | background-color: var(--todotxt-color-orange-300); 119 | } 120 | .todo-priority-c { 121 | color: var(--todotxt-color-sky-600); 122 | background-color: var(--todotxt-color-sky-300); 123 | } 124 | .todo-priority-d { 125 | color: var(--todotxt-color-amber-600); 126 | background-color: var(--todotxt-color-gray-200); 127 | } 128 | .todo-priority-e { 129 | border: 1px solid var(--todotxt-color-gray-200); 130 | } 131 | .todo-priority-f { 132 | border: 1px solid var(--todotxt-color-gray-200); 133 | } 134 | .todo-priority-g { 135 | border: 1px solid var(--todotxt-color-gray-200); 136 | } 137 | 138 | .todo-description { 139 | display: flex; 140 | align-items: flex-start; 141 | color: var(--text-normal); 142 | 143 | .todo-due { 144 | color: var(--todotxt-color-sky-600); 145 | } 146 | .todo-due-soon { 147 | color: var(--todotxt-color-amber-600); 148 | } 149 | .todo-due-past { 150 | color: var(--todotxt-color-red-600); 151 | } 152 | 153 | .todo-prethreshold { 154 | color: var(--text-muted); 155 | } 156 | 157 | .todo-completed { 158 | text-decoration-line: line-through; 159 | color: var(--text-muted); 160 | } 161 | } 162 | 163 | .todo-tag { 164 | font-size: 0.75rem; 165 | line-height: 1rem; 166 | margin-right: 0.5rem; 167 | color: var(--text-muted); 168 | display: inline-block; 169 | } 170 | .todo-project { 171 | font-size: 0.75rem; 172 | line-height: 1rem; 173 | margin-right: 0.5rem; 174 | padding: 0.125rem 0.25rem; 175 | border-radius: 9999px; 176 | color: var(--todotxt-color-gray-700); 177 | background-color: var(--todotxt-color-blue-200); 178 | white-space: nowrap; 179 | display: inline-block; 180 | } 181 | .todo-ctx { 182 | font-size: 0.75rem; 183 | line-height: 1rem; 184 | margin-right: 0.5rem; 185 | padding: 0.125rem 0.25rem; 186 | border-radius: 9999px; 187 | color: var(--todotxt-color-gray-700); 188 | background-color: var(--todotxt-color-indigo-200); 189 | white-space: nowrap; 190 | display: inline-block; 191 | } 192 | 193 | .todo-actions { 194 | visibility: hidden; 195 | display: flex; 196 | gap: 0.125rem; 197 | color: var(--text-faint); 198 | 199 | margin-left: 0.25rem; 200 | 201 | button { 202 | margin: 0; 203 | padding: 1px; 204 | 205 | svg { 206 | display: inline; 207 | width: 1rem; 208 | height: 1rem; 209 | color: inherit 210 | } 211 | } 212 | } 213 | 214 | .todo-label:hover { 215 | .todo-actions { 216 | visibility: visible; 217 | } 218 | } 219 | 220 | .todo-dialog-bg { 221 | position: absolute; 222 | top: 0px; 223 | right: 0px; 224 | bottom: 0px; 225 | left: 0px; 226 | background-color: var(--background-primary); 227 | display: flex; 228 | justify-content: center; 229 | align-items: center; 230 | } 231 | .todo-dialog { 232 | width: 66%; 233 | border: 0.25rem solid var(--background-secondary-alt); 234 | border-radius: 0.5rem; 235 | background-color: var(--background-secondary); 236 | z-index: 20; 237 | padding: 0.5rem; 238 | margin: 1.5rem; 239 | } 240 | .todo-dialog-header { 241 | margin-top: 0.125rem; 242 | margin-bottom: 0.125rem; 243 | } 244 | .todo-dialog-input { 245 | margin-top: 0.125rem; 246 | margin-bottom: 0.125rem; 247 | width: 100%; 248 | } 249 | .todo-dialog-actions { 250 | margin-top: 0.125rem; 251 | margin-bottom: 0.125rem; 252 | display: flex; 253 | justify-content: flex-end; 254 | gap: 0.25rem; 255 | } 256 | } 257 | 258 | .todotxt-settings .todo-experimental-heading { 259 | margin-top: 4rem; 260 | margin-bottom: 0.25rem; 261 | } 262 | 263 | #todotxt { 264 | /* Particularly on mobile, we need a bit of padding at the bottom */ 265 | margin-bottom: 2rem; 266 | } 267 | 268 | /* sm */ 269 | @media (min-width: 640px) { 270 | } 271 | 272 | /* md */ 273 | @media (min-width: 768px) { 274 | } 275 | 276 | /* lg */ 277 | @media (min-width: 1024px) { 278 | #todotxt { 279 | .todo-list-container { 280 | grid-template-columns: repeat(2, minmax(0, 1fr)) 281 | } 282 | } 283 | .todo-container { 284 | display: block; 285 | } 286 | } 287 | 288 | /* xl */ 289 | @media (min-width: 1280px) { 290 | #todotxt { 291 | .todo-list-container { 292 | grid-template-columns: repeat(3, minmax(0, 1fr)) 293 | } 294 | } 295 | .todo-container { 296 | display: flex; 297 | align-items: center; 298 | justify-content: space-between; 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/ui/create-todo-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useState, KeyboardEvent, MouseEvent, ChangeEvent } from 'react'; 3 | 4 | interface CreateTodoProps { 5 | onAdd: (t: string | null) => void; 6 | } 7 | 8 | export const CreateTodoDialog = (props: CreateTodoProps) => { 9 | const [value, setValue] = useState(''); 10 | 11 | const handleChange = (e: ChangeEvent) => { 12 | setValue(e.currentTarget.value); 13 | }; 14 | 15 | const handleKeyPress = (e: KeyboardEvent) => { 16 | if (e.key === 'Enter') { 17 | props.onAdd(value); 18 | setValue(''); 19 | e.preventDefault(); 20 | } else if (e.key === 'Escape') { 21 | props.onAdd(null); 22 | setValue(''); 23 | e.preventDefault(); 24 | } 25 | }; 26 | 27 | const handleAdd = (e: MouseEvent) => { 28 | props.onAdd(value); 29 | setValue(''); 30 | e.preventDefault(); 31 | }; 32 | 33 | const handleCancel = (e: MouseEvent) => { 34 | props.onAdd(null); 35 | setValue(''); 36 | e.preventDefault(); 37 | }; 38 | 39 | return ( 40 |
e.stopPropagation()}> 41 |
42 |
43 |

Enter a New ToDo:

44 | 52 |
53 | 54 | 55 |
56 |
57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/ui/delete-todo-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { KeyboardEvent, MouseEvent } from 'react'; 3 | 4 | interface DeleteTodoProps { 5 | onDelete: (t: boolean) => void; 6 | } 7 | 8 | export const DeleteTodoDialog = (props: DeleteTodoProps) => { 9 | const handleKeyPress = (e: KeyboardEvent) => { 10 | if (e.key === 'Escape') { 11 | props.onDelete(false); 12 | } else if (e.key === 'y') { 13 | props.onDelete(true); 14 | } else if (e.key === 'n') { 15 | props.onDelete(false); 16 | } 17 | }; 18 | 19 | const handleConfirm = (e: MouseEvent) => { 20 | props.onDelete(true); 21 | e.preventDefault(); 22 | }; 23 | 24 | const handleCancel = (e: MouseEvent) => { 25 | props.onDelete(false); 26 | e.preventDefault(); 27 | }; 28 | 29 | return ( 30 |
e.stopPropagation()}> 31 |
32 |
33 |

Confirm Delete:

34 |

Are you sure you want to delete this todo?

35 |
36 | 39 | 40 |
41 |
42 |
43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/ui/edit-todo-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | useState, 4 | type ChangeEvent, 5 | type KeyboardEvent, 6 | type MouseEvent, 7 | } from 'react'; 8 | 9 | interface EditTodoProps { 10 | onEdit: (todoText: string | null) => void; 11 | todoText: string; 12 | } 13 | 14 | export const EditTodoDialog = (props: EditTodoProps) => { 15 | const [value, setValue] = useState(props.todoText); 16 | 17 | const handleChange = (e: ChangeEvent) => { 18 | setValue(e.currentTarget.value); 19 | }; 20 | 21 | const handleKeyPress = (e: KeyboardEvent) => { 22 | if (e.key === 'Enter') { 23 | props.onEdit(value !== '' ? value : null); 24 | setValue(''); 25 | e.preventDefault(); 26 | } else if (e.key === 'Escape') { 27 | props.onEdit(null); 28 | setValue(''); 29 | e.preventDefault(); 30 | } 31 | }; 32 | const handleConfirm = (e: MouseEvent) => { 33 | props.onEdit(value !== '' ? value : null); 34 | setValue(''); 35 | e.preventDefault(); 36 | }; 37 | 38 | const handleCancel = (e: MouseEvent) => { 39 | props.onEdit(null); 40 | setValue(''); 41 | e.preventDefault(); 42 | }; 43 | 44 | return ( 45 |
e.stopPropagation()}> 46 |
47 |
48 |

Edit Todo:

49 | 57 |
58 | 59 | 60 |
61 |
62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/ui/icon/magnifying-glass.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface MagnifyingGlassIconProps { 4 | className?: string; 5 | } 6 | 7 | export function MagnifyingGlassIcon({ className }: MagnifyingGlassIconProps) { 8 | return ( 9 | 17 | 22 | 23 | ); 24 | } 25 | 26 | export default React.memo(MagnifyingGlassIcon); 27 | -------------------------------------------------------------------------------- /src/ui/icon/pencil.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface PencilIconProps { 4 | className?: string; 5 | } 6 | 7 | export function PencilIcon({ className }: PencilIconProps) { 8 | return ( 9 | 17 | 22 | 23 | ); 24 | } 25 | 26 | export default React.memo(PencilIcon); 27 | -------------------------------------------------------------------------------- /src/ui/icon/plus.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface PlusIconProps { 4 | className?: string; 5 | } 6 | 7 | export function PlusIcon({ className }: PlusIconProps) { 8 | return ( 9 | 17 | 22 | 23 | ); 24 | } 25 | 26 | export default React.memo(PlusIcon); 27 | -------------------------------------------------------------------------------- /src/ui/icon/trash.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface TrashIconProps { 4 | className?: string; 5 | } 6 | 7 | export function TrashIcon({ className }: TrashIconProps) { 8 | return ( 9 | 17 | 22 | 23 | ); 24 | } 25 | 26 | export default React.memo(TrashIcon); 27 | -------------------------------------------------------------------------------- /src/ui/icon/x-mark.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface TrashIconProps { 4 | className?: string; 5 | } 6 | 7 | export function TrashIcon({ className }: TrashIconProps) { 8 | return ( 9 | 17 | 22 | 23 | ); 24 | } 25 | 26 | export default React.memo(TrashIcon); 27 | -------------------------------------------------------------------------------- /src/ui/todoslist.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { sortTodo, type Todo } from '../lib/todo'; 3 | import { TodoView } from './todoview'; 4 | 5 | type TodosListProps = { 6 | tag: string; 7 | todos: Todo[]; 8 | onCompleteToggle: (t: Todo) => void; 9 | onDeleteClicked: (t: Todo) => void; 10 | onEditClicked: (t: Todo) => void; 11 | onNavigate: (url: string, newTab: boolean) => void; 12 | }; 13 | 14 | export const TodosList = (props: TodosListProps) => { 15 | const sorted = [...props.todos].sort(sortTodo); 16 | 17 | const handleKeyPress = ( 18 | e: React.KeyboardEvent, 19 | t: Todo, 20 | ) => { 21 | if (e.key === 'e' || e.key === 'Enter') { 22 | props.onEditClicked(t); 23 | } else if (e.key === 'd') { 24 | props.onDeleteClicked(t); 25 | } 26 | }; 27 | 28 | return ( 29 |
30 | {sorted?.map((todo) => ( 31 | 41 | ))} 42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/ui/todosview.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useState, type FormEvent, type KeyboardEvent } from 'react'; 3 | import { Todo } from '../lib/todo'; 4 | import { TodosList } from './todoslist'; 5 | import PlusIcon from './icon/plus'; 6 | import XMarkIcon from './icon/x-mark'; 7 | import MagnifyingGlassIcon from './icon/magnifying-glass'; 8 | import { EditTodoDialog } from './edit-todo-dialog'; 9 | import { DeleteTodoDialog } from './delete-todo-dialog'; 10 | import { CreateTodoDialog } from './create-todo-dialog'; 11 | 12 | type TodosViewProps = { 13 | todos: Todo[]; 14 | onChange: (t: Todo[]) => void; 15 | defaultPriorityFilter: string; 16 | defaultOrganizeBy: 'project' | 'context'; 17 | defaultGroupBy: string; 18 | preservePriority: boolean; 19 | recurringTasks: boolean; 20 | onNavigate: (url: string, newTab: boolean) => void; 21 | }; 22 | type OrganizeBy = 'project' | 'context'; 23 | 24 | export const TodosView = (props: TodosViewProps) => { 25 | const [filter, setFilter] = useState('' as string); 26 | const filterInputRef = React.useRef(null); 27 | const [minPriority, setMinPriority] = useState(props.defaultPriorityFilter); 28 | const [confirmCreate, setConfirmCreate] = useState(false); 29 | const [confirmDelete, setConfirmDelete] = useState(null); 30 | const [confirmEdit, setConfirmEdit] = useState(null); 31 | const [organizeBy, setOrganizeBy] = useState( 32 | props.defaultOrganizeBy, 33 | ); 34 | 35 | // Get all the projects/ctx, plus add a Default tag for untagged todos 36 | const todoTags = 37 | organizeBy === 'project' 38 | ? [ 39 | props.defaultGroupBy, 40 | ...uniq( 41 | props.todos 42 | .flatMap((todo) => todo.getProjects().map((i) => i.project)) 43 | .sort(cmp), 44 | ), 45 | ] 46 | : [ 47 | props.defaultGroupBy, 48 | ...uniq( 49 | props.todos 50 | .flatMap((todo) => todo.getContexts().map((i) => i.ctx)) 51 | .sort(cmp), 52 | ), 53 | ]; 54 | 55 | // Create a list of each tag... 56 | const todoLists = Object.fromEntries( 57 | todoTags.map((tag) => [tag, [] as Todo[]]), 58 | ); 59 | 60 | const displayedTodos = 61 | filter === '' 62 | ? props.todos 63 | : props.todos.filter((todo) => 64 | todo.description 65 | .toString() 66 | .toLowerCase() 67 | .includes(filter.toLowerCase()), 68 | ); 69 | 70 | // ... and populate them 71 | displayedTodos.forEach((todo) => { 72 | if ((todo.priority || 'Z') > minPriority) return; 73 | 74 | if (organizeBy === 'project') { 75 | if (todo.getProjects().length > 0) { 76 | uniq(todo.getProjects().map((i) => i.project)).forEach((prj) => { 77 | todoLists[prj] ||= []; 78 | todoLists[prj].push(todo); 79 | }); 80 | } else { 81 | todoLists[props.defaultGroupBy] ||= []; 82 | todoLists[props.defaultGroupBy].push(todo); 83 | } 84 | } else { 85 | if (todo.getContexts().length > 0) { 86 | uniq(todo.getContexts().map((i) => i.ctx)).forEach((ctx) => { 87 | todoLists[ctx] ||= []; 88 | todoLists[ctx].push(todo); 89 | }); 90 | } else { 91 | todoLists[props.defaultGroupBy] ||= []; 92 | todoLists[props.defaultGroupBy].push(todo); 93 | } 94 | } 95 | }); 96 | 97 | // List filters: 98 | const handleChangePriorityFilter = (e: FormEvent) => { 99 | setMinPriority(e.currentTarget.value); 100 | }; 101 | 102 | // Organize by: 103 | const handleOrganizeBy = (e: FormEvent) => { 104 | setOrganizeBy(e.currentTarget.value === 'project' ? 'project' : 'context'); 105 | }; 106 | 107 | // Todo CrUD: 108 | const handleCompleteToggle = (t: Todo) => { 109 | const newTodos = [...props.todos]; 110 | const todo = newTodos.find((todo) => todo.id === t.id) as Todo; 111 | if (!todo) return; 112 | 113 | if (!todo.completed) { 114 | todo.complete(props.preservePriority); 115 | if (props.recurringTasks) { 116 | const nextRecurring = todo.recurring(newTodos.length); 117 | if (nextRecurring) newTodos.push(nextRecurring); 118 | } 119 | } else { 120 | todo.uncomplete(props.preservePriority); 121 | } 122 | 123 | if (props.onChange) props.onChange(newTodos); 124 | }; 125 | const handleDelete = (t: boolean) => { 126 | if (t) { 127 | const newTodos = props.todos.filter( 128 | (todo) => todo.id !== confirmDelete?.id, 129 | ); 130 | if (props.todos.length !== newTodos.length && props.onChange) 131 | props.onChange(newTodos); 132 | } 133 | setConfirmDelete(null); 134 | }; 135 | const handleEdit = (todoText: string | null) => { 136 | if (confirmEdit && todoText !== null && todoText !== '') { 137 | const newTodo = Todo.parse(todoText, confirmEdit.id); 138 | const newTodos = props.todos.map((todo) => 139 | todo.id === confirmEdit.id ? newTodo : todo, 140 | ); 141 | if (props.onChange) props.onChange(newTodos); 142 | } 143 | setConfirmEdit(null); 144 | }; 145 | const handleAdd = (todoText: string | null) => { 146 | if (todoText !== null && todoText !== '') { 147 | // Parse the todo 148 | const todo = Todo.parse(todoText, props.todos.length); 149 | todo.createDate = new Date().toISOString().substring(0, 10); 150 | // Add the new todo to the todoList 151 | const newTodos = [...props.todos, todo]; 152 | if (props.onChange) props.onChange(newTodos); 153 | } 154 | setConfirmCreate(false); 155 | }; 156 | 157 | // Keyboard shortcuts/navigation 158 | const handleKeyPress = (e: KeyboardEvent) => { 159 | if (e.key === 'n' && e.ctrlKey) { 160 | handleShowCreate(); 161 | } else if (e.key === '/' && e.ctrlKey) { 162 | filterInputRef.current?.focus(); 163 | } 164 | }; 165 | 166 | // Filter the todo list 167 | const handleFilter = (e: FormEvent) => { 168 | setFilter(e.currentTarget.value || ''); 169 | }; 170 | const handleClear = () => setFilter(''); 171 | 172 | // Display the dialog 173 | const handleShowCreate = () => setConfirmCreate(true); 174 | const handleShowEdit = (t: Todo | null) => setConfirmEdit(t); 175 | const handleShowDelete = (t: Todo | null) => setConfirmDelete(t); 176 | 177 | return ( 178 |
179 |
180 |

ToDo Lists

181 |
182 | {/**/} 183 | 194 | {/**/} 195 | 203 |
204 | 211 | 218 |
219 | 220 |
221 |
222 |
223 | {todoTags.map((tag) => ( 224 |
225 |

{tag}

226 | 234 |
235 | ))} 236 |
237 | {confirmCreate && } 238 | {confirmDelete && } 239 | {confirmEdit && ( 240 | 244 | )} 245 |
246 | ); 247 | }; 248 | 249 | function uniq(array: T[]) { 250 | return [...new Set(array)]; 251 | } 252 | 253 | function cmp(a: string, b: string) { 254 | return a.localeCompare(b); 255 | } 256 | -------------------------------------------------------------------------------- /src/ui/todoview.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import PencilIcon from './icon/pencil'; 3 | import TrashIcon from './icon/trash'; 4 | import { 5 | TodoWord, 6 | Todo, 7 | TodoCtx, 8 | TodoDescription, 9 | TodoExternalLink, 10 | TodoInternalLink, 11 | TodoProject, 12 | TodoTag, 13 | } from '../lib/todo'; 14 | import { useLongPress } from 'use-long-press'; 15 | import cn from '../lib/classNames'; 16 | 17 | type TodoViewProps = { 18 | tag: string; 19 | todo: Todo; 20 | onCompleteToggle: (t: Todo) => void; 21 | onDeleteClicked: (t: Todo) => void; 22 | onEditClicked: (t: Todo) => void; 23 | onKeyPressed: (e: React.KeyboardEvent, t: Todo) => void; 24 | onNavigate: (url: string, newTab: boolean) => void; 25 | }; 26 | export const TodoView = (props: TodoViewProps) => { 27 | const { todo } = props; 28 | const longPressCB = React.useCallback( 29 | () => props.onEditClicked(todo), 30 | [props], 31 | ); 32 | 33 | const longPressProps = useLongPress(longPressCB, { 34 | threshold: 500, 35 | cancelOnMovement: true, 36 | }); 37 | 38 | return ( 39 |
40 | props.onKeyPressed(e, todo)} 45 | onChange={() => props.onCompleteToggle(todo)} 46 | /> 47 | {/* We can't use
89 | ); 90 | }; 91 | 92 | const TodoTagView = ({ tag }: { tag: TodoTag }) => { 93 | const TODAY = new Date().toISOString(); 94 | const SOON = new Date(Date.now() + 24 * 60 * 60 * 1000 * 3).toISOString(); // Three days 95 | const classes = ['todo-tag']; 96 | 97 | // Highlight late or pending tasks 98 | if (tag.tag === 'due') { 99 | const due = tag.value; 100 | if (due > SOON) { 101 | classes.push('todo-due'); 102 | } else if (due > TODAY) { 103 | classes.push('todo-due-soon'); 104 | } else { 105 | classes.push('todo-due-past'); 106 | } 107 | } 108 | 109 | return ( 110 | 111 | {tag.tag}:{tag.value} 112 | 113 | ); 114 | }; 115 | 116 | const TodoProjectView = ({ project }: { project: TodoProject }) => { 117 | return {project.project}; 118 | }; 119 | 120 | const TodoContextView = ({ ctx }: { ctx: TodoCtx }) => { 121 | return {ctx.ctx}; 122 | }; 123 | 124 | const TodoInternalLinkView = ({ 125 | link, 126 | onNavigate, 127 | }: { 128 | link: TodoInternalLink; 129 | onNavigate: (url: string, newTab: boolean) => void; 130 | }) => { 131 | return ( 132 | <> 133 | { 135 | e.preventDefault(); 136 | onNavigate(link.title, true); 137 | }} 138 | > 139 | {link.title} 140 | {' '} 141 | 142 | ); 143 | }; 144 | 145 | const TodoExternalLinkView = ({ 146 | link, 147 | onNavigate, 148 | }: { 149 | link: TodoExternalLink; 150 | onNavigate: (url: string, newTab: boolean) => void; 151 | }) => { 152 | return ( 153 | <> 154 | {link.title}{' '} 155 | 156 | ); 157 | }; 158 | 159 | // {todo.projects 160 | // .filter((tag) => tag !== props.tag) 161 | // .map((tag, i) => ( 162 | // 163 | // {tag} 164 | // 165 | // ))} 166 | // {todo.ctx 167 | // .filter((ctx) => ctx !== props.tag) 168 | // .map((ctx, i) => ( 169 | // 170 | // {ctx} 171 | // 172 | // ))} 173 | 174 | const TodoDescriptionView = ({ 175 | description, 176 | onNavigate, 177 | group, 178 | }: { 179 | description: TodoDescription; 180 | onNavigate: (url: string, newTab: boolean) => void; 181 | group: string; 182 | }) => { 183 | return ( 184 | 185 | {description.map((item, i) => { 186 | if (item instanceof TodoWord) return {item.word} ; 187 | if (item instanceof TodoTag) return ; 188 | if (item instanceof TodoProject && item.project !== group) 189 | return ; 190 | if (item instanceof TodoCtx && item.ctx !== group) 191 | return ; 192 | if (item instanceof TodoInternalLink) 193 | return ( 194 | 195 | ); 196 | if (item instanceof TodoExternalLink) 197 | return ( 198 | 199 | ); 200 | return null; 201 | })} 202 | 203 | ); 204 | }; 205 | -------------------------------------------------------------------------------- /src/view.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { createRoot, Root } from 'react-dom/client'; 3 | import { TextFileView, WorkspaceLeaf } from 'obsidian'; 4 | import { TodosView } from './ui/todosview'; 5 | import { Todo } from './lib/todo'; 6 | import TodotxtPlugin from './main'; 7 | 8 | export const VIEW_TYPE_TODOTXT = 'todotxt-view'; 9 | 10 | export class TodotxtView extends TextFileView { 11 | todoData: Todo[]; 12 | fileFormat: 'unix' | 'dos' = 'unix'; 13 | root: Root; 14 | plugin: TodotxtPlugin; 15 | 16 | constructor(leaf: WorkspaceLeaf, plugin: TodotxtPlugin) { 17 | super(leaf); 18 | this.plugin = plugin; 19 | } 20 | 21 | // Convert from Todo[] to string before writing to disk 22 | getViewData() { 23 | const lineSep = this.fileFormat === 'dos' ? '\r\n' : '\n'; 24 | return this.todoData.map((t) => t.toString()).join(lineSep) + lineSep; 25 | } 26 | 27 | // Convert string from disk to Todo[] 28 | setViewData(data: string, clear: boolean) { 29 | this.fileFormat = data.match(/\r\n/) ? 'dos' : 'unix'; 30 | 31 | this.todoData = data 32 | .split(/\r?\n/) 33 | .filter((line) => line) 34 | .map(Todo.parse); 35 | 36 | this.refresh(); 37 | } 38 | 39 | clear() { 40 | this.todoData = []; 41 | } 42 | 43 | async onOpen() { 44 | this.root = createRoot(this.containerEl.children[1]); 45 | } 46 | 47 | async onClose() { 48 | this.root.unmount(); 49 | } 50 | 51 | getViewType() { 52 | return VIEW_TYPE_TODOTXT; 53 | } 54 | 55 | update(todos: Todo[]) { 56 | // console.log(`[TodoTxt] update`, { todos }); 57 | this.todoData = todos; 58 | this.refresh(); 59 | this.requestSave(); 60 | } 61 | 62 | handleNavigate(url: string, newTab: boolean) { 63 | const callingFilePath = ''; // TODO: this must be available in `this` 64 | this.plugin.app.workspace.openLinkText(url, callingFilePath, newTab); 65 | } 66 | 67 | refresh() { 68 | const settings = this.plugin?.settings; 69 | 70 | this.root.render( 71 | , 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/description.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, beforeAll, it, expect } from '@jest/globals'; 2 | import parser from '../src/lib/parser'; 3 | 4 | function p(description: string) { 5 | return parser.parse(description).description; 6 | } 7 | 8 | describe('todo description', () => { 9 | beforeAll(() => { 10 | jest.useFakeTimers().setSystemTime(new Date('2023-09-01')); 11 | }); 12 | 13 | it('simple description', () => { 14 | expect(p('Test parser')).toEqual(['Test', 'parser']); 15 | }); 16 | 17 | it('description with tag', () => { 18 | expect(p('Test rec:+1w tag')).toEqual([ 19 | 'Test', 20 | { tag: 'rec', value: '+1w' }, 21 | 'tag', 22 | ]); 23 | expect(p('Test rec:+1:@-w tag')).toEqual([ 24 | 'Test', 25 | { tag: 'rec', value: '+1:@-w' }, 26 | 'tag', 27 | ]); 28 | }); 29 | 30 | it('description with context', () => { 31 | expect(p('Test @home context')).toEqual([ 32 | 'Test', 33 | { context: 'home' }, 34 | 'context', 35 | ]); 36 | expect(p('Test @@+-home@ context')).toEqual([ 37 | 'Test', 38 | { context: '@+-home@' }, 39 | 'context', 40 | ]); 41 | }); 42 | 43 | it('description with project', () => { 44 | expect(p('Test +list project')).toEqual([ 45 | 'Test', 46 | { project: 'list' }, 47 | 'project', 48 | ]); 49 | expect(p('Test +@+-list@ project')).toEqual([ 50 | 'Test', 51 | { project: '@+-list@' }, 52 | 'project', 53 | ]); 54 | }); 55 | 56 | it('description with internal link', () => { 57 | expect(p('Test [[Other Page/Link]] link')).toEqual([ 58 | 'Test', 59 | { link: 'Other Page/Link' }, 60 | 'link', 61 | ]); 62 | 63 | expect(p('Test [[Other Page/Link]]')).toEqual([ 64 | 'Test', 65 | { link: 'Other Page/Link' }, 66 | ]); 67 | 68 | expect(p('Test [[Other Page/Link]].')).toEqual([ 69 | 'Test', 70 | '[[Other', 71 | 'Page/Link]].', 72 | ]); 73 | 74 | expect(p('Test [[Other Page/Link] broken')).toEqual([ 75 | 'Test', 76 | '[[Other', 77 | 'Page/Link]', 78 | 'broken', 79 | ]); 80 | }); 81 | 82 | it('description with extneral link', () => { 83 | expect(p('Test [External Link](https://elsewere.url) link')).toEqual([ 84 | 'Test', 85 | { title: 'External Link', url: 'https://elsewere.url' }, 86 | 'link', 87 | ]); 88 | 89 | expect(p('Test [External Link](https://elsewere.url)')).toEqual([ 90 | 'Test', 91 | { title: 'External Link', url: 'https://elsewere.url' }, 92 | ]); 93 | 94 | expect(p('Test [External Link](https://elsewere.url). link')).toEqual([ 95 | 'Test', 96 | '[External', 97 | 'Link](https://elsewere.url).', 98 | 'link', 99 | ]); 100 | 101 | expect(p('Test [External Link](https://elsewere.url).')).toEqual([ 102 | 'Test', 103 | '[External', 104 | 'Link](https://elsewere.url).', 105 | ]); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /tests/peg.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, beforeAll, it, expect } from '@jest/globals'; 2 | import parser from '../src/lib/parser'; 3 | 4 | function p(text: string) { 5 | return parser.parse(text); 6 | } 7 | 8 | describe('todo', () => { 9 | beforeAll(() => { 10 | jest.useFakeTimers().setSystemTime(new Date('2023-09-01')); 11 | }); 12 | 13 | it('just description', () => { 14 | expect(p('Test parser')).toEqual({ 15 | completed: null, 16 | priority: null, 17 | firstDate: null, 18 | secondDate: null, 19 | description: ['Test', 'parser'], 20 | }); 21 | }); 22 | 23 | it('with priority', () => { 24 | expect(p('(A) Test parser')).toEqual({ 25 | completed: null, 26 | priority: 'A', 27 | firstDate: null, 28 | secondDate: null, 29 | description: ['Test', 'parser'], 30 | }); 31 | }); 32 | 33 | it('with priority and date', () => { 34 | expect(p('(A) 2023-11-01 Test parser')).toEqual({ 35 | completed: null, 36 | priority: 'A', 37 | firstDate: '2023-11-01', 38 | secondDate: null, 39 | description: ['Test', 'parser'], 40 | }); 41 | }); 42 | 43 | it('with completed', () => { 44 | expect(p('x Test parser')).toEqual({ 45 | completed: 'x', 46 | priority: null, 47 | firstDate: null, 48 | secondDate: null, 49 | description: ['Test', 'parser'], 50 | }); 51 | }); 52 | 53 | it('with completed and date', () => { 54 | expect(p('x 2023-12-01 Test parser')).toEqual({ 55 | completed: 'x', 56 | priority: null, 57 | firstDate: '2023-12-01', 58 | secondDate: null, 59 | description: ['Test', 'parser'], 60 | }); 61 | }); 62 | 63 | it('with completed and two dates', () => { 64 | expect(p('x 2023-12-01 2023-11-01 Test parser')).toEqual({ 65 | completed: 'x', 66 | priority: null, 67 | firstDate: '2023-12-01', 68 | secondDate: '2023-11-01', 69 | description: ['Test', 'parser'], 70 | }); 71 | }); 72 | 73 | it('with trialing whitespace', () => { 74 | expect(p('(A) test ')).toEqual({ 75 | completed: null, 76 | priority: 'A', 77 | firstDate: null, 78 | secondDate: null, 79 | description: ['test'], 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /tests/todo.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, beforeAll, test, expect } from '@jest/globals'; 2 | import { Todo } from '../src/lib/todo'; 3 | 4 | describe('todo', () => { 5 | beforeAll(() => { 6 | jest.useFakeTimers().setSystemTime(new Date('2023-09-01')); 7 | }); 8 | 9 | test('strict rec with threshold', () => { 10 | const todo = Todo.parse('Task rec:+1w due:2023-09-05 t:2023-09-03', 1); 11 | 12 | const nextRecurring = todo.recurring(0); 13 | expect(nextRecurring).toBeTruthy(); 14 | if (!nextRecurring) return; 15 | 16 | expect(nextRecurring.getTag('due')?.value).toBe('2023-09-12'); 17 | expect(nextRecurring.getTag('t')?.value).toBe('2023-09-10'); 18 | }); 19 | 20 | test('strict rec w/o due date', () => { 21 | const todo = Todo.parse('Task rec:+1w t:2023-08-30', 1); 22 | 23 | const nextRecurring = todo.recurring(0); 24 | expect(nextRecurring).toBeTruthy(); 25 | if (!nextRecurring) return; 26 | 27 | expect(nextRecurring.getTag('due')?.value).toBe('2023-09-07'); 28 | expect(nextRecurring.getTag('t')?.value).toBe('2023-09-05'); 29 | }); 30 | 31 | test('non-strict rec w/ due date', () => { 32 | const todo = Todo.parse('Task rec:1w due:2023-09-05 t:2023-09-03', 1); 33 | 34 | const nextRecurring = todo.recurring(0); 35 | expect(nextRecurring).toBeTruthy(); 36 | if (!nextRecurring) return; 37 | 38 | expect(nextRecurring.getTag('due')?.value).toBe('2023-09-07'); 39 | expect(nextRecurring.getTag('t')?.value).toBe('2023-09-05'); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "outDir": "dist", 9 | "rootDir": "src", 10 | "allowJs": true, 11 | "noImplicitAny": true, 12 | "jsx": "react", 13 | "moduleResolution": "node", 14 | "esModuleInterop": true, 15 | "importHelpers": true, 16 | "isolatedModules": true, 17 | "strictNullChecks": true, 18 | "lib": [ 19 | "DOM", 20 | "ES5", 21 | "ES6", 22 | "ES7" 23 | ] 24 | }, 25 | "include": [ 26 | "**/*.ts" 27 | ], 28 | "exclude": [ 29 | "./tests/*.ts" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0", 3 | "1.0.1": "0.15.0", 4 | "1.0.2": "0.15.0", 5 | "1.0.3": "0.15.0", 6 | "1.0.4": "0.15.0", 7 | "1.0.5": "0.15.0", 8 | "1.1.0": "0.15.0", 9 | "1.2.0": "0.15.0", 10 | "1.2.1": "0.15.0", 11 | "1.2.2": "0.15.0", 12 | "1.2.3": "0.15.0", 13 | "1.3.0": "0.15.0", 14 | "1.3.1": "0.15.0", 15 | "1.3.2": "0.15.0", 16 | "1.3.3": "0.15.0", 17 | "1.4.0": "0.15.0", 18 | "1.4.3": "0.15.0", 19 | "1.4.4": "0.15.0", 20 | "1.5.0": "0.15.0", 21 | "1.6.0": "0.15.0", 22 | "1.7.0": "0.15.0", 23 | "2.0.0": "0.15.0", 24 | "2.0.2": "0.15.0", 25 | "2.0.3": "0.15.0", 26 | "2.0.4": "0.15.0", 27 | "2.0.5": "0.15.0", 28 | "2.1.0": "0.15.0", 29 | "2.1.1": "0.15.0", 30 | "2.1.2": "0.15.0", 31 | "2.1.5": "0.15.0", 32 | "2.1.6": "0.15.0", 33 | "2.1.7": "0.15.0" 34 | } --------------------------------------------------------------------------------