├── src ├── utils │ ├── index.ts │ ├── files.ts │ ├── groups.ts │ ├── helpers.ts │ └── tasks.ts ├── constants.ts ├── plugins │ ├── comment.ts │ ├── highlight.ts │ ├── tag.ts │ ├── link.ts │ └── plugin-helper.ts ├── svelte │ ├── clickOutside.directive.ts │ ├── CheckCircle.svelte │ ├── ChecklistItem.svelte │ ├── App.svelte │ ├── Icon.svelte │ ├── ChecklistGroup.svelte │ └── Header.svelte ├── worker_helpers.ts ├── _types.ts ├── main.ts ├── view.ts └── settings.ts ├── images ├── screenshot-sub-tag.png ├── screenshot-settings.png ├── screenshot-two-files.png └── screenshot-show-completed.png ├── svelte-custom.d.ts ├── .prettierrc.json ├── .gitignore ├── manifest.json ├── tsconfig.json ├── version-bump.mjs ├── docs ├── CONTRIBUTING.md └── SETUP.md ├── versions.json ├── LICENSE ├── package.json ├── esbuild.config.mjs ├── styles.css └── README.md /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './files' 2 | export * from './groups' 3 | export * from './tasks' 4 | -------------------------------------------------------------------------------- /images/screenshot-sub-tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delashum/obsidian-checklist-plugin/HEAD/images/screenshot-sub-tag.png -------------------------------------------------------------------------------- /images/screenshot-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delashum/obsidian-checklist-plugin/HEAD/images/screenshot-settings.png -------------------------------------------------------------------------------- /images/screenshot-two-files.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delashum/obsidian-checklist-plugin/HEAD/images/screenshot-two-files.png -------------------------------------------------------------------------------- /images/screenshot-show-completed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delashum/obsidian-checklist-plugin/HEAD/images/screenshot-show-completed.png -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const TODO_VIEW_TYPE = 'todo' 2 | 3 | export const LOCAL_SORT_OPT = { 4 | numeric: true, 5 | ignorePunctuation: true, 6 | } 7 | -------------------------------------------------------------------------------- /svelte-custom.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace svelte.JSX { 2 | interface HTMLAttributes { 3 | onclick_outside?: (ev: MouseEvent) => void 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "bracketSpacing": false, 6 | "bracketSameLine": true 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | 8 | # build 9 | dist 10 | main.js 11 | *.js.map 12 | 13 | 14 | # other 15 | .DS_Store 16 | 17 | yarn.lock 18 | data.json -------------------------------------------------------------------------------- /src/plugins/comment.ts: -------------------------------------------------------------------------------- 1 | import {regexPlugin} from './plugin-helper' 2 | 3 | export const commentPlugin = regexPlugin( 4 | /\%\%([^\%]+)\%\%/, 5 | (match: string[], utils: any) => { 6 | return `` 7 | }, 8 | ) 9 | -------------------------------------------------------------------------------- /src/plugins/highlight.ts: -------------------------------------------------------------------------------- 1 | import {regexPlugin} from './plugin-helper' 2 | 3 | export const highlightPlugin = regexPlugin( 4 | /\=\=([^\=]+)\=\=/, 5 | (match: string[], utils: any) => { 6 | return `${utils.escape(match[1])}` 7 | }, 8 | ) 9 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-checklist-plugin", 3 | "name": "Checklist", 4 | "version": "2.2.14", 5 | "minAppVersion": "0.14.5", 6 | "description": "Combines checklists across pages into users sidebar", 7 | "author": "delashum", 8 | "isDesktopOnly": false 9 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "moduleResolution": "node", 6 | "importHelpers": true, 7 | "types": ["svelte", "node"], 8 | "lib": ["ESNext", "DOM"] 9 | }, 10 | "include": ["src/**/*", "svelte-custom.d.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /src/plugins/tag.ts: -------------------------------------------------------------------------------- 1 | import {regexPlugin} from './plugin-helper' 2 | 3 | export const tagPlugin = regexPlugin(/\#\S+/, (match, utils) => { 4 | const content = match[0] 5 | return `${utils.escape( 8 | content, 9 | )}` 10 | }) 11 | -------------------------------------------------------------------------------- /src/svelte/clickOutside.directive.ts: -------------------------------------------------------------------------------- 1 | export function clickOutside(node) { 2 | const handleClick = (event: MouseEvent) => { 3 | if (node && !node.contains(event.target) && !event.defaultPrevented) { 4 | node.dispatchEvent(new CustomEvent('click_outside', node)) 5 | } 6 | } 7 | 8 | document.addEventListener('mousedown', handleClick, true) 9 | 10 | return { 11 | destroy() { 12 | document.removeEventListener('mousedown', handleClick, true) 13 | }, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import {readFileSync, writeFileSync} from 'fs' 2 | 3 | const targetVersion = process.env.npm_package_version 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync('manifest.json', 'utf8')) 7 | const {minAppVersion} = manifest 8 | manifest.version = targetVersion 9 | writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t')) 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync('versions.json', 'utf8')) 13 | versions[targetVersion] = minAppVersion 14 | writeFileSync('versions.json', JSON.stringify(versions, null, '\t')) 15 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Issues 2 | 3 | 1. Create issue for review in [delashum/obsidian-checklist-plugin/issues](https://github.com/delashum/obsidian-checklist-plugin/issues) 4 | 2. If issue is approved for development, follow process below. 5 | 6 | ## Development 7 | 8 | See SETUP.md for guide on getting your development environment setup. 9 | 10 | ## Pull Request Workflow 11 | 12 | 1. Create a fork of the the repository 13 | 2. Make changes in your fork, tagging the #NUM of the issue in question. 14 | 3. After testing development, go to your fork, and on your branch to be merged you should see "This branch is # comit ahead of delashum:master". To the right you can click 'Contribute' to open a **Pull Request** for your changes. 15 | 4. Work with Delashum or any other maintainers to merge your changes in. 16 | -------------------------------------------------------------------------------- /src/plugins/link.ts: -------------------------------------------------------------------------------- 1 | import {regexPlugin} from './plugin-helper' 2 | 3 | import type {LinkMeta} from 'src/_types' 4 | export const linkPlugin = (linkMap: Map) => 5 | regexPlugin(/\[\[([^\]]+)\]\]/, (match: string[], utils: any) => { 6 | const content = match[1] 7 | const [link, label] = content.trim().split('|') 8 | const linkItem = linkMap.get(link) 9 | let displayText = label ? label : linkItem ? linkItem.linkName : link 10 | if (label) { 11 | displayText = label 12 | } else if (linkItem) { 13 | displayText = linkItem.linkName 14 | } else { 15 | displayText = link 16 | } 17 | if (!linkItem) return `[[${content}]]` 18 | return `${utils.escape(displayText)}` 21 | }) 22 | -------------------------------------------------------------------------------- /src/worker_helpers.ts: -------------------------------------------------------------------------------- 1 | // I realized this isn't plausible because creating workers via blob limits to inline code only and doesn't allow calls to other functions 2 | export const workerizeFunction = async any>( 3 | fn: T, 4 | ) => { 5 | const functionWrapper = (id: string, args: Parameters) => { 6 | fn() 7 | } 8 | var blobURL = URL.createObjectURL( 9 | new Blob(['(', fn.toString(), ')()'], { 10 | type: 'application/javascript', 11 | }), 12 | ) 13 | const worker = new Worker(blobURL) 14 | 15 | return (...args: Parameters): Promise> => { 16 | const id = Math.random() 17 | worker.postMessage([id, args]) 18 | const listener = () => { 19 | worker.removeEventListener('message', listener) 20 | } 21 | worker.addEventListener('message', listener) 22 | return new Promise(r => r(null)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/svelte/CheckCircle.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 |
8 | 9 | 31 | -------------------------------------------------------------------------------- /src/utils/files.ts: -------------------------------------------------------------------------------- 1 | import {App, MarkdownView, Keymap} from 'obsidian' 2 | 3 | import {ensureMdExtension, getFileFromPath} from './helpers' 4 | 5 | export const navToFile = async ( 6 | app: App, 7 | path: string, 8 | ev: MouseEvent, 9 | line?: number, 10 | ) => { 11 | path = ensureMdExtension(path) 12 | const file = getFileFromPath(app.vault, path) 13 | if (!file) return 14 | const mod = Keymap.isModEvent(ev) 15 | const leaf = app.workspace.getLeaf(mod) 16 | await leaf.openFile(file) 17 | if (line) { 18 | app.workspace.getActiveViewOfType(MarkdownView).editor.setCursor(line) 19 | } 20 | } 21 | 22 | export const hoverFile = (event: MouseEvent, app: App, filePath: string) => { 23 | const targetElement = event.currentTarget 24 | const timeoutHandle = setTimeout(() => { 25 | app.workspace.trigger('link-hover', {}, targetElement, filePath, filePath) 26 | }, 800) 27 | targetElement.addEventListener('mouseleave', () => { 28 | clearTimeout(timeoutHandle) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.9.12", 3 | "1.0.1": "0.9.12", 4 | "1.0.2": "0.9.12", 5 | "1.0.3": "0.9.12", 6 | "1.0.4": "0.9.12", 7 | "1.0.5": "0.9.12", 8 | "1.0.6": "0.9.12", 9 | "1.0.7": "0.9.12", 10 | "1.0.8": "0.9.12", 11 | "1.0.9": "0.9.12", 12 | "1.0.10": "0.9.12", 13 | "1.0.11": "0.9.12", 14 | "1.0.12": "0.9.12", 15 | "1.0.13": "0.9.12", 16 | "1.1.0": "0.9.12", 17 | "1.1.1": "0.9.12", 18 | "1.2.1": "0.9.12", 19 | "1.2.2": "0.9.12", 20 | "1.2.3": "0.9.12", 21 | "2.0.0": "0.9.12", 22 | "2.0.1": "0.9.12", 23 | "2.0.2": "0.9.12", 24 | "2.0.3": "0.14.5", 25 | "2.0.4": "0.14.5", 26 | "2.0.5": "0.14.5", 27 | "2.1.0": "0.14.5", 28 | "2.2.0": "0.14.5", 29 | "2.2.1": "0.14.5", 30 | "2.2.2": "0.14.5", 31 | "2.2.3": "0.14.5", 32 | "2.2.4": "0.14.5", 33 | "2.2.5": "0.14.5", 34 | "2.2.6": "0.14.5", 35 | "2.2.7": "0.14.5", 36 | "2.2.8": "0.14.5", 37 | "2.2.9": "0.14.5", 38 | "2.2.10": "0.14.5", 39 | "2.2.11": "0.14.5", 40 | "2.2.12": "0.14.5", 41 | "2.2.13": "0.14.5", 42 | "2.2.14": "0.14.5" 43 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) delashum 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-checklist-plugin", 3 | "version": "2.2.14", 4 | "description": "A plugin for Obsidian.md which consoldiates todo items into a single view.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [ 12 | "obsidian" 13 | ], 14 | "author": "delashum", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@tsconfig/svelte": "^1.0.10", 18 | "@types/markdown-it": "^12.0.1", 19 | "@types/node": "^16.11.6", 20 | "@typescript-eslint/eslint-plugin": "5.29.0", 21 | "@typescript-eslint/parser": "5.29.0", 22 | "builtin-modules": "3.3.0", 23 | "esbuild": "0.14.47", 24 | "esbuild-svelte": "^0.7.1", 25 | "markdown-it": "^13.0.1", 26 | "minimatch": "^5.1.0", 27 | "obsidian": "latest", 28 | "svelte": "^3.49.0", 29 | "svelte-preprocess": "^4.10.7", 30 | "tslib": "^2.4.0", 31 | "typescript": "^4.7.4" 32 | }, 33 | "dependencies": { 34 | "prettier": "^3.0.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/plugins/plugin-helper.ts: -------------------------------------------------------------------------------- 1 | import type MD from 'markdown-it' 2 | 3 | const escape = (html: string) => 4 | String(html) 5 | .replace(/&/g, '&') 6 | .replace(/"/g, '"') 7 | .replace(/'/g, ''') 8 | .replace(//g, '>') 10 | 11 | const utils = { 12 | escape, 13 | } 14 | 15 | let counter = 0 16 | 17 | export const regexPlugin = ( 18 | regexp: RegExp, 19 | replacer: ( 20 | match: string[], 21 | utils: {escape: (html: string) => string}, 22 | ) => string, 23 | ) => { 24 | const flags = 25 | (regexp.global ? 'g' : '') + 26 | (regexp.multiline ? 'm' : '') + 27 | (regexp.ignoreCase ? 'i' : '') 28 | const _regexp = RegExp('^' + regexp.source, flags) 29 | const id = 'regexp-' + counter++ 30 | 31 | return (md: MD) => { 32 | md.inline.ruler.push(id, (state, silent) => { 33 | var match = _regexp.exec(state.src.slice(state.pos)) 34 | if (!match) return false 35 | 36 | state.pos += match[0].length 37 | 38 | if (silent) return true 39 | 40 | var token = state.push(id, '', 0) 41 | token.meta = {match: match} 42 | return true 43 | }) 44 | md.renderer.rules[id] = (tokens: any, idx: number) => { 45 | return replacer(tokens[idx].meta.match, utils) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/_types.ts: -------------------------------------------------------------------------------- 1 | import type {CachedMetadata, TagCache, TFile} from 'obsidian' 2 | 3 | export type TodoItem = { 4 | checked: boolean 5 | filePath: string 6 | fileName: string 7 | fileLabel: string 8 | fileCreatedTs: number 9 | mainTag?: string 10 | subTag?: string 11 | line: number 12 | spacesIndented: number 13 | fileInfo: FileInfo 14 | originalText: string 15 | rawHTML: string 16 | } 17 | 18 | type BaseGroup = { 19 | type: GroupByType 20 | todos: TodoItem[] 21 | id: string 22 | sortName: string 23 | className: string 24 | oldestItem: number 25 | newestItem: number 26 | groups?: TodoGroup[] 27 | } 28 | 29 | export type PageGroup = BaseGroup & { 30 | type: 'page' 31 | pageName?: string 32 | } 33 | export type TagGroup = BaseGroup & { 34 | type: 'tag' 35 | mainTag?: string 36 | subTags?: string 37 | } 38 | 39 | export type TodoGroup = PageGroup | TagGroup 40 | 41 | export type FileInfo = { 42 | content: string 43 | cache: CachedMetadata 44 | parseEntireFile: boolean 45 | frontmatterTag: string 46 | file: TFile 47 | validTags: TagCache[] 48 | } 49 | 50 | export type TagMeta = {main: string; sub: string} 51 | export type LinkMeta = {filePath: string; linkName: string} 52 | 53 | export type GroupByType = 'page' | 'tag' 54 | export type SortDirection = 'new->old' | 'old->new' | 'a->z' | 'z->a' 55 | export type LookAndFeel = 'compact' | 'classic' 56 | 57 | export type Icon = 'chevron' | 'settings' 58 | 59 | export type KeysOfType = { 60 | [K in keyof T]: T[K] extends V ? K : never 61 | }[keyof T] 62 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild' 2 | import process from 'process' 3 | import builtins from 'builtin-modules' 4 | import sveltePlugin from 'esbuild-svelte' 5 | import autoPreprocess from 'svelte-preprocess' 6 | 7 | const banner = `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 9 | if you want to view the source, please visit the github repository of this plugin 10 | */ 11 | ` 12 | 13 | const prod = process.argv[2] === 'production' 14 | 15 | esbuild 16 | .build({ 17 | banner: { 18 | js: banner, 19 | }, 20 | entryPoints: ['src/main.ts'], 21 | bundle: true, 22 | external: [ 23 | 'obsidian', 24 | 'electron', 25 | '@codemirror/autocomplete', 26 | '@codemirror/closebrackets', 27 | '@codemirror/collab', 28 | '@codemirror/commands', 29 | '@codemirror/comment', 30 | '@codemirror/fold', 31 | '@codemirror/gutter', 32 | '@codemirror/highlight', 33 | '@codemirror/history', 34 | '@codemirror/language', 35 | '@codemirror/lint', 36 | '@codemirror/matchbrackets', 37 | '@codemirror/panel', 38 | '@codemirror/rangeset', 39 | '@codemirror/rectangular-selection', 40 | '@codemirror/search', 41 | '@codemirror/state', 42 | '@codemirror/stream-parser', 43 | '@codemirror/text', 44 | '@codemirror/tooltip', 45 | '@codemirror/view', 46 | '@lezer/common', 47 | '@lezer/highlight', 48 | '@lezer/lr', 49 | ...builtins, 50 | ], 51 | format: 'cjs', 52 | watch: !prod, 53 | target: 'es2016', 54 | logLevel: 'info', 55 | sourcemap: prod ? false : 'inline', 56 | treeShaking: true, 57 | plugins: [ 58 | sveltePlugin({ 59 | preprocess: autoPreprocess(), 60 | compilerOptions: {css: true}, 61 | }), 62 | ], 63 | outfile: 'main.js', 64 | }) 65 | .catch(() => process.exit(1)) 66 | -------------------------------------------------------------------------------- /docs/SETUP.md: -------------------------------------------------------------------------------- 1 | # Compilation (universal) 2 | 3 | 1. Install npm by the documentation [here](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) 4 | 2. Test that you have npm installed and updated by running `npm --version`. 5 | 3. Run `sudo npm i` to install all the packages (this requires `sudo` due to our use of a `package-lock.json`) 6 | - NOTE: If on windows, instead of sudo, make sure you run from an elevated powershell prompt. Run this command to do get elevated priveleges from your current console: `start-process PowerShell -verb runas` and accept the prompt) 7 | - If you still run into errors, run `npm install -g npm` to update to latest version of npm (updating requires sudo/elevated prompt as mentioned above) 8 | 4. To compile the non-minified output script, run `npm run dev` to generate the `main.js` file from Svelte files (note: `npm run prod` will do the same thing, but create a minified version not ideal for development) 9 | 10 | ## Linux 11 | 12 | 1. Create a test obsidian vault (i.e. `obsidian-dev`) 13 | `mkdir ~/obsidian-dev/` 14 | 2. Open the vault folder in obsidian. It will make an `.obsidian` hidden folder. 15 | 3. Disable "Safe mode" from Settings > Community Plugins 16 | 4. Create a symlink to your cloned & build project (see above) 17 | 18 | ```bash 19 | ln -s ~/repos/obsidian-checklist-plugin/ ~/obsidian-dev-vault/.obsidian/plugins/obsidian-checklist-plugin 20 | ``` 21 | 22 | 5. Make changes & compile the repository, then make sure to reload the changes in Obsidian from Community Plugins > Installed plugins. (Note: this may require restarting obsidian at times, possibly due to caching, if the refresh doesn't work) 23 | 24 | ## Windows 25 | 26 | 1. Manually copy the compiled `main.js` into your development vault's folder this plugin, i.e. `.obsidian\plugins\obsidian-checklist-plugin`, with a different folder name if you prefer. 27 | 28 | # Troubleshooting 29 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* no content */ 2 | .checklist-plugin-main { 3 | --checklist-checkboxSize: 20px; 4 | --checklist-checkboxCheckedSize: 12px; 5 | --checklist-checkboxBorder: 2px solid var(--text-muted); 6 | --checklist-checkboxFill: var(--text-muted); 7 | --checklist-listItemBorderRadius: 8px; 8 | --checklist-listItemMargin: 0 0 12px; 9 | --checklist-listItemBackground: var(--interactive-normal); 10 | --checklist-listItemBackground--hover: var(--interactive-hover); 11 | --checklist-listItemMargin--compact: 0 0 8px; 12 | --checklist-listItemBoxShadow: none; 13 | --checklist-headerMargin: 0 0 8px; 14 | --checklist-headerGap: 4px; 15 | --checklist-headerFontSize: 18px; 16 | --checklist-headerFontWeight: 600; 17 | --checklist-iconSize: 24px; 18 | --checklist-iconFill: var(--text-normal); 19 | --checklist-iconFill--accent: #777; 20 | --checklist-textColor: var(--text-muted); 21 | --checklist-accentColor: var(--text-accent); 22 | --checklist-accentColor--active: var(--text-accent-hover); 23 | --checklist-pageMargin: 0 0 4px; 24 | --checklist-loaderSize: 16px; 25 | --checklist-loaderBorderColor: var(--text-muted) var(--text-muted) 26 | var(--text-normal); 27 | --checklist-buttonPadding: 0 5px; 28 | --checklist-buttonBoxShadow: none; 29 | --checklist-countPadding: 0 6px; 30 | --checklist-countBackground: var(--interactive-normal); 31 | --checklist-countFontSize: 13px; 32 | --checklist-togglePadding: 8px 8px 8px 12px; 33 | --checklist-contentPadding: 8px 12px 8px 0; 34 | --checklist-contentPadding--compact: 4px 8px; 35 | --checklist-togglePadding--compact: 4px 8px; 36 | --checklist-countBorderRadius: 4px; 37 | --checklist-tagBaseColor: var(--text-faint); 38 | --checklist-tagSubColor: #bbb; 39 | --checklist-groupMargin: 8px; 40 | --checklist-contentFontSize: var(--editor-font-size); 41 | --checklist-searchBackground: var(--background-primary); 42 | } 43 | 44 | .checklist-plugin-main button { 45 | margin: initial; 46 | } 47 | 48 | .checklist-plugin-main p { 49 | margin: initial; 50 | word-break: break-word; 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/groups.ts: -------------------------------------------------------------------------------- 1 | import {classifyString, sortGenericItemsInplace} from './helpers' 2 | 3 | import type {TodoItem, TodoGroup, GroupByType, SortDirection} from 'src/_types' 4 | export const groupTodos = ( 5 | items: TodoItem[], 6 | groupBy: GroupByType, 7 | sortGroups: SortDirection, 8 | sortItems: SortDirection, 9 | subGroups: boolean, 10 | subGroupSort: SortDirection, 11 | ): TodoGroup[] => { 12 | const groups: TodoGroup[] = [] 13 | for (const item of items) { 14 | const itemKey = 15 | groupBy === 'page' 16 | ? item.filePath 17 | : `#${[item.mainTag, item.subTag].filter(e => e != null).join('/')}` 18 | let group = groups.find(g => g.id === itemKey) 19 | if (!group) { 20 | const newGroup: TodoGroup = { 21 | id: itemKey, 22 | sortName: '', 23 | className: '', 24 | type: groupBy, 25 | todos: [], 26 | oldestItem: Infinity, 27 | newestItem: 0, 28 | } 29 | 30 | if (newGroup.type === 'page') { 31 | newGroup.pageName = item.fileLabel 32 | newGroup.sortName = item.fileLabel 33 | newGroup.className = classifyString(item.fileLabel) 34 | } else if (newGroup.type === 'tag') { 35 | newGroup.mainTag = item.mainTag 36 | newGroup.subTags = item.subTag 37 | newGroup.sortName = item.mainTag + (item.subTag ?? '0') 38 | newGroup.className = classifyString( 39 | (newGroup.mainTag ?? '') + (newGroup.subTags ?? ''), 40 | ) 41 | } 42 | groups.push(newGroup) 43 | group = newGroup 44 | } 45 | if (group.newestItem < item.fileCreatedTs) 46 | group.newestItem = item.fileCreatedTs 47 | if (group.oldestItem > item.fileCreatedTs) 48 | group.oldestItem = item.fileCreatedTs 49 | 50 | group.todos.push(item) 51 | } 52 | 53 | const nonEmptyGroups = groups.filter(g => g.todos.length > 0) 54 | 55 | sortGenericItemsInplace( 56 | nonEmptyGroups, 57 | sortGroups, 58 | 'sortName', 59 | sortGroups === 'new->old' ? 'newestItem' : 'oldestItem', 60 | ) 61 | 62 | if (!subGroups) 63 | for (const g of groups) 64 | sortGenericItemsInplace( 65 | g.todos, 66 | sortItems, 67 | 'originalText', 68 | 'fileCreatedTs', 69 | ) 70 | else 71 | for (const g of nonEmptyGroups) 72 | g.groups = groupTodos( 73 | g.todos, 74 | groupBy === 'page' ? 'tag' : 'page', 75 | subGroupSort, 76 | sortItems, 77 | false, 78 | null, 79 | ) 80 | 81 | return nonEmptyGroups 82 | } 83 | -------------------------------------------------------------------------------- /src/svelte/ChecklistItem.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 |
  • 38 | 47 |
    handleClick(ev, item)} class="content" /> 48 |
  • 49 | 50 | 88 | -------------------------------------------------------------------------------- /src/svelte/App.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 |
    34 |
    41 | {#if todoGroups.length === 0} 42 |
    43 | {#if _hiddenTags.length === todoTags.length} 44 | All checklist set to hidden 45 | {:else if visibleTags.length} 46 | No checklists found for tag{visibleTags.length > 1 ? "s" : ""}: {visibleTags.map((e) => `#${e}`).join(" ")} 47 | {:else} 48 | No checklists found in all files 49 | {/if} 50 |
    51 | {:else} 52 | {#each todoGroups as group} 53 | 60 | {/each} 61 | {/if} 62 |
    63 | 64 | 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **LOOKING FOR ANOTHER MAINTAINER TO HELP OUT** There's quite a bit of work to do on this plugin still and i've been neglecting it because work is too busy. I would love some help, or at least someone who could approve PRs and triage issues. Send me an email at delashum@gmail.com if you're interested. 2 | 3 | # obsidian-checklist-plugin 4 | 5 | This plugin consolidates checklists from across files into a single view. 6 | 7 | ![screenshot-main](https://raw.githubusercontent.com/delashum/obsidian-checklist-plugin/master/images/screenshot-two-files.png) 8 | 9 | ## Usage 10 | 11 | After enabling this plugin, you will see the checklist appear in the right sidebar. If you do not you can run the `Checklist: Open View` command from the command palette to get it to appear. 12 | 13 | By default block of checklist items you tag with `#todo` will appear in this sidebar. 14 | 15 | You can complete checklist items by checking them off in your editor (e.g. `- [ ]` -> `- [x]`) or by clicking a checklist item in the sidebar which will update your `.md` file for you 16 | 17 | ## Configuration 18 | 19 | ![screenshot-settings](https://raw.githubusercontent.com/delashum/obsidian-checklist-plugin/master/images/screenshot-settings.png) 20 | 21 | **Tag name:** The default tag to lookup checklist items by is `#todo`, but may be changed to whatever you like 22 | 23 | **Show completed?:** By default the plugin will only show uncompleted tasks, and as tasks are completed they will filter out of the sidebar. You may choose to show all tasks 24 | 25 | ![screenshot-completed](https://raw.githubusercontent.com/delashum/obsidian-checklist-plugin/master/images/screenshot-show-completed.png) 26 | 27 | **Show All Todos In File?:** By default the plugin will only show tasks in the block that is tagged - changing this will show all tasks present in a file if the tag is present anywhere on the page. 28 | 29 | **Group by:** You can group by either file or tagname. If you choose to group by tag name they will appear in the order that they first appear in your files (or last depending on sort order) 30 | 31 | ![screenshot-tags](https://raw.githubusercontent.com/delashum/obsidian-checklist-plugin/master/images/screenshot-sub-tag.png) 32 | 33 | **Sort order:** By default checklist items will appear in the order they appear in the file, with files ordered with the oldest at the top. This can be changed to show the newest files at the top. 34 | 35 | ## Glob File Matching 36 | 37 | The "Include Files" setting uses Glob file matching. Specifically the plugin uses [minimatch](https://github.com/isaacs/minimatch) to match the file pattern - so any specific oddities will come from that plugin. 38 | 39 | Couple of common examples to help structure your glob: 40 | 41 | - `!{_templates/**,_archive/**}` will include everything except for the two directories `_templates` and `_archive`. 42 | - `{Daily/**,Weekly/**}` will only include files in the `Daily` & `Weekly` directories 43 | 44 | I recommend the [Digital Ocean Glob Tool](https://www.digitalocean.com/community/tools/glob) for figuring out how globs work - although the implementation is not identical to minimatch so there might be slight differences. 45 | -------------------------------------------------------------------------------- /src/svelte/Icon.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | {#if name === "chevron"} 17 | 27 | {:else if name === "settings"} 28 | 33 | {/if} 34 | 35 | 69 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import {Plugin} from 'obsidian' 2 | 3 | import {TODO_VIEW_TYPE} from './constants' 4 | import {DEFAULT_SETTINGS, TodoSettings, TodoSettingTab} from './settings' 5 | import TodoListView from './view' 6 | 7 | export default class TodoPlugin extends Plugin { 8 | private settings: TodoSettings 9 | 10 | get view() { 11 | return this.app.workspace.getLeavesOfType(TODO_VIEW_TYPE)[0] 12 | ?.view as TodoListView 13 | } 14 | 15 | async onload() { 16 | await this.loadSettings() 17 | 18 | this.addSettingTab(new TodoSettingTab(this.app, this)) 19 | this.addCommand({ 20 | id: 'show-checklist-view', 21 | name: 'Show Checklist Pane', 22 | callback: () => { 23 | const workspace = this.app.workspace 24 | const views = workspace.getLeavesOfType(TODO_VIEW_TYPE) 25 | if (views.length === 0) { 26 | workspace 27 | .getRightLeaf(false) 28 | .setViewState({ 29 | type: TODO_VIEW_TYPE, 30 | active: true, 31 | }) 32 | .then(() => { 33 | const todoLeaf = workspace.getLeavesOfType(TODO_VIEW_TYPE)[0] 34 | workspace.revealLeaf(todoLeaf) 35 | workspace.setActiveLeaf(todoLeaf, true, true) 36 | }) 37 | } else { 38 | views[0].setViewState({ 39 | active: true, 40 | type: TODO_VIEW_TYPE, 41 | }) 42 | workspace.revealLeaf(views[0]) 43 | workspace.setActiveLeaf(views[0], true, true) 44 | } 45 | }, 46 | }) 47 | this.addCommand({ 48 | id: 'refresh-checklist-view', 49 | name: 'Refresh List', 50 | callback: () => { 51 | this.view.refresh() 52 | }, 53 | }) 54 | this.registerView(TODO_VIEW_TYPE, leaf => { 55 | const newView = new TodoListView(leaf, this) 56 | return newView 57 | }) 58 | 59 | if (this.app.workspace.layoutReady) this.initLeaf() 60 | else this.app.workspace.onLayoutReady(() => this.initLeaf()) 61 | } 62 | 63 | initLeaf(): void { 64 | if (this.app.workspace.getLeavesOfType(TODO_VIEW_TYPE).length) return 65 | 66 | this.app.workspace.getRightLeaf(false).setViewState({ 67 | type: TODO_VIEW_TYPE, 68 | active: true, 69 | }) 70 | } 71 | 72 | async onunload() { 73 | this.app.workspace.getLeavesOfType(TODO_VIEW_TYPE)[0]?.detach() 74 | } 75 | 76 | async loadSettings() { 77 | const loadedData = await this.loadData() 78 | this.settings = {...DEFAULT_SETTINGS, ...loadedData} 79 | } 80 | 81 | async updateSettings(updates: Partial) { 82 | Object.assign(this.settings, updates) 83 | await this.saveData(this.settings) 84 | const onlyRepaintWhenChanges = [ 85 | 'autoRefresh', 86 | 'lookAndFeel', 87 | '_collapsedSections', 88 | ] 89 | const onlyReGroupWhenChanges = [ 90 | 'subGroups', 91 | 'groupBy', 92 | 'sortDirectionGroups', 93 | 'sortDirectionSubGroups', 94 | 'sortDirectionItems', 95 | ] 96 | if (onlyRepaintWhenChanges.includes(Object.keys(updates)[0])) 97 | this.view.rerender() 98 | else 99 | this.view.refresh( 100 | !onlyReGroupWhenChanges.includes(Object.keys(updates)[0]), 101 | ) 102 | } 103 | 104 | getSettingValue(setting: K): TodoSettings[K] { 105 | return this.settings[setting] 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/svelte/ChecklistGroup.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
    21 |
    22 |
    23 | {#if group.type === "page"} 24 | {group.pageName} 25 | {:else if group.mainTag} 26 | # 27 | {`${group.mainTag}${group.subTags != null ? "/" : ""}`} 30 | {#if group.subTags != null} 31 | {group.subTags} 32 | {/if} 33 | {:else} 34 | All Tags 35 | {/if} 36 |
    37 |
    38 |
    {group.todos.length}
    39 | 42 |
    43 | {#if !isCollapsed} 44 |
      45 | {#each group.todos as item} 46 | 47 | {/each} 48 |
    49 | {/if} 50 |
    51 | 52 | 122 | -------------------------------------------------------------------------------- /src/svelte/Header.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
    16 | onSearch(search)} 22 | /> 23 |
    24 | { 28 | showPopover = !showPopover 29 | }} 30 | /> 31 | {#if showPopover} 32 |
    { 35 | showPopover = false 36 | }} 37 | class="popover" 38 | > 39 |
    40 |
    Show Tags?
    41 | {#each todoTags as tag} 42 |
    43 | 50 |
    51 | {/each} 52 | {#if todoTags.length === 0} 53 |
    No tags specified
    54 | {/if} 55 |
    56 |
    57 | {/if} 58 |
    59 |
    60 | 61 | 132 | -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import {CachedMetadata, parseFrontMatterTags, TFile, Vault} from 'obsidian' 2 | 3 | import {LOCAL_SORT_OPT} from '../constants' 4 | 5 | import type {SortDirection, TagMeta, LinkMeta, KeysOfType} from 'src/_types' 6 | export const isMacOS = () => window.navigator.userAgent.includes('Macintosh') 7 | export const classifyString = (str: string) => { 8 | const sanitzedGroupName = (str ?? '').replace(/[^A-Za-z0-9]/g, '') 9 | const dasherizedGroupName = sanitzedGroupName.replace( 10 | /^([A-Z])|[\s\._](\w)/g, 11 | function (_, p1, p2) { 12 | if (p2) return '-' + p2.toLowerCase() 13 | return p1.toLowerCase() 14 | }, 15 | ) 16 | return dasherizedGroupName 17 | } 18 | 19 | export const removeTagFromText = (text: string, tag: string) => { 20 | if (!text) return '' 21 | if (!tag) return text.trim() 22 | return text.replace(new RegExp(`\\s?\\#${tag}[^\\s]*`, 'g'), '').trim() 23 | } 24 | 25 | export const getTagMeta = (tag: string): TagMeta => { 26 | const tagMatch = /^\#([^\/]+)\/?(.*)?$/.exec(tag) 27 | if (!tagMatch) return {main: null, sub: null} 28 | const [full, main, sub] = tagMatch 29 | return {main, sub} 30 | } 31 | 32 | export const retrieveTag = (tagMeta: TagMeta): string => { 33 | return tagMeta.main ? tagMeta.main : tagMeta.sub ? tagMeta.sub : '' 34 | } 35 | 36 | export const mapLinkMeta = (linkMeta: LinkMeta[]) => { 37 | const map = new Map() 38 | for (const link of linkMeta) map.set(link.filePath, link) 39 | return map 40 | } 41 | 42 | export const setLineTo = (line: string, setTo: boolean) => 43 | line.replace( 44 | /^((\s|\>)*([\-\*]|[0-9]+\.)\s\[)([^\]]+)(\].*$)/, 45 | `$1${setTo ? 'x' : ' '}$5`, 46 | ) 47 | 48 | export const getAllLinesFromFile = (cache: string) => cache.split(/\r?\n/) 49 | export const combineFileLines = (lines: string[]) => lines.join('\n') 50 | export const lineIsValidTodo = (line: string) => { 51 | return /^(\s|\>)*([\-\*]|[0-9]+\.)\s\[(.{1})\]\s{1,4}\S+/.test(line) 52 | } 53 | export const extractTextFromTodoLine = (line: string) => 54 | /^(\s|\>)*([\-\*]|[0-9]+\.)\s\[(.{1})\]\s{1,4}(\S{1}.*)$/.exec(line)?.[4] 55 | export const getIndentationSpacesFromTodoLine = (line: string) => 56 | /^(\s*)([\-\*]|[0-9]+\.)\s\[(.{1})\]\s{1,4}(\S+)/.exec(line)?.[1]?.length ?? 0 57 | export const todoLineIsChecked = (line: string) => 58 | /^(\s|\>)*([\-\*]|[0-9]+\.)\s\[(\S{1})\]/.test(line) 59 | export const getFileLabelFromName = (filename: string) => 60 | /^(.+)\.md$/.exec(filename)?.[1] 61 | 62 | export const sortGenericItemsInplace = < 63 | T, 64 | NK extends KeysOfType, 65 | TK extends KeysOfType, 66 | >( 67 | items: T[], 68 | direction: SortDirection, 69 | sortByNameKey: NK, 70 | sortByTimeKey: TK, 71 | ) => { 72 | if (direction === 'a->z') 73 | items.sort((a, b) => 74 | (a[sortByNameKey] as any).localeCompare( 75 | b[sortByNameKey], 76 | navigator.language, 77 | LOCAL_SORT_OPT, 78 | ), 79 | ) 80 | if (direction === 'z->a') 81 | items.sort((a, b) => 82 | (b[sortByNameKey] as any).localeCompare( 83 | a[sortByNameKey], 84 | navigator.language, 85 | LOCAL_SORT_OPT, 86 | ), 87 | ) 88 | if (direction === 'new->old') 89 | items.sort((a, b) => (b[sortByTimeKey] as any) - (a[sortByTimeKey] as any)) 90 | if (direction === 'old->new') 91 | items.sort((a, b) => (a[sortByTimeKey] as any) - (b[sortByTimeKey] as any)) 92 | } 93 | 94 | export const ensureMdExtension = (path: string) => { 95 | if (!/\.md$/.test(path)) return `${path}.md` 96 | return path 97 | } 98 | 99 | export const getFrontmatterTags = ( 100 | cache: CachedMetadata, 101 | todoTags: string[] = [], 102 | ) => { 103 | const frontMatterTags: string[] = 104 | parseFrontMatterTags(cache?.frontmatter) ?? [] 105 | if (todoTags.length > 0) 106 | return frontMatterTags.filter((tag: string) => 107 | todoTags.includes(getTagMeta(tag).main), 108 | ) 109 | return frontMatterTags 110 | } 111 | 112 | export const getAllTagsFromMetadata = (cache: CachedMetadata): string[] => { 113 | if (!cache) return [] 114 | const frontmatterTags = getFrontmatterTags(cache) 115 | const blockTags = (cache.tags ?? []).map(e => e.tag) 116 | return [...frontmatterTags, ...blockTags] 117 | } 118 | 119 | export const getFileFromPath = (vault: Vault, path: string) => { 120 | let file = vault.getAbstractFileByPath(path) 121 | if (file instanceof TFile) return file 122 | const files = vault.getMarkdownFiles() 123 | file = files.find(e => e.name === path) 124 | if (file instanceof TFile) return file 125 | } 126 | -------------------------------------------------------------------------------- /src/view.ts: -------------------------------------------------------------------------------- 1 | import {ItemView, WorkspaceLeaf} from 'obsidian' 2 | 3 | import {TODO_VIEW_TYPE} from './constants' 4 | import App from './svelte/App.svelte' 5 | import {groupTodos, parseTodos} from './utils' 6 | 7 | import type {TodoSettings} from './settings' 8 | import type TodoPlugin from './main' 9 | import type {TodoGroup, TodoItem} from './_types' 10 | export default class TodoListView extends ItemView { 11 | private _app: App 12 | private lastRerender = 0 13 | private groupedItems: TodoGroup[] = [] 14 | private itemsByFile = new Map() 15 | private searchTerm = '' 16 | 17 | constructor( 18 | leaf: WorkspaceLeaf, 19 | private plugin: TodoPlugin, 20 | ) { 21 | super(leaf) 22 | } 23 | 24 | getViewType(): string { 25 | return TODO_VIEW_TYPE 26 | } 27 | 28 | getDisplayText(): string { 29 | return 'Todo List' 30 | } 31 | 32 | getIcon(): string { 33 | return 'checkmark' 34 | } 35 | 36 | get todoTagArray() { 37 | return this.plugin 38 | .getSettingValue('todoPageName') 39 | .trim() 40 | .split('\n') 41 | .map(e => e.toLowerCase()) 42 | .filter(e => e) 43 | } 44 | 45 | get visibleTodoTagArray() { 46 | return this.todoTagArray.filter( 47 | t => !this.plugin.getSettingValue('_hiddenTags').includes(t), 48 | ) 49 | } 50 | 51 | async onClose() { 52 | this._app.$destroy() 53 | } 54 | 55 | async onOpen(): Promise { 56 | this._app = new App({ 57 | target: (this as any).contentEl, 58 | props: this.props(), 59 | }) 60 | this.registerEvent( 61 | this.app.metadataCache.on('resolved', async () => { 62 | if (!this.plugin.getSettingValue('autoRefresh')) return 63 | await this.refresh() 64 | }), 65 | ) 66 | this.registerEvent( 67 | this.app.workspace.on('active-leaf-change', async () => { 68 | if (!this.plugin.getSettingValue('showOnlyActiveFile')) return 69 | await this.refresh() 70 | }) 71 | ) 72 | this.registerEvent( 73 | this.app.vault.on('delete', file => this.deleteFile(file.path)), 74 | ) 75 | this.refresh() 76 | } 77 | 78 | async refresh(all = false) { 79 | if (all) { 80 | this.lastRerender = 0 81 | this.itemsByFile.clear() 82 | } 83 | await this.calculateAllItems() 84 | this.groupItems() 85 | this.renderView() 86 | this.lastRerender = +new Date() 87 | } 88 | 89 | rerender() { 90 | this.renderView() 91 | } 92 | 93 | private deleteFile(path: string) { 94 | this.itemsByFile.delete(path) 95 | this.groupItems() 96 | this.renderView() 97 | } 98 | 99 | private props() { 100 | return { 101 | todoTags: this.todoTagArray, 102 | lookAndFeel: this.plugin.getSettingValue('lookAndFeel'), 103 | subGroups: this.plugin.getSettingValue('subGroups'), 104 | _collapsedSections: this.plugin.getSettingValue('_collapsedSections'), 105 | _hiddenTags: this.plugin.getSettingValue('_hiddenTags'), 106 | app: this.app, 107 | todoGroups: this.groupedItems, 108 | updateSetting: (updates: Partial) => 109 | this.plugin.updateSettings(updates), 110 | onSearch: (val: string) => { 111 | this.searchTerm = val 112 | this.refresh() 113 | }, 114 | } 115 | } 116 | 117 | private async calculateAllItems() { 118 | const todosForUpdatedFiles = await parseTodos( 119 | this.app.vault.getMarkdownFiles(), 120 | this.todoTagArray.length === 0 ? ['*'] : this.visibleTodoTagArray, 121 | this.app.metadataCache, 122 | this.app.vault, 123 | this.plugin.getSettingValue('includeFiles'), 124 | this.plugin.getSettingValue('showChecked'), 125 | this.plugin.getSettingValue('showAllTodos'), 126 | this.lastRerender, 127 | ) 128 | for (const [file, todos] of todosForUpdatedFiles) { 129 | this.itemsByFile.set(file.path, todos) 130 | } 131 | } 132 | 133 | private groupItems() { 134 | const flattenedItems = Array.from(this.itemsByFile.values()).flat() 135 | const viewOnlyOpen = this.plugin.getSettingValue('showOnlyActiveFile'); 136 | const openFile = this.app.workspace.getActiveFile(); 137 | const filteredItems = viewOnlyOpen ? flattenedItems.filter(i => i.filePath === openFile.path) : flattenedItems; 138 | const searchedItems = filteredItems.filter(e => 139 | e.originalText.toLowerCase().includes(this.searchTerm.toLowerCase()), 140 | ) 141 | this.groupedItems = groupTodos( 142 | searchedItems, 143 | this.plugin.getSettingValue('groupBy'), 144 | this.plugin.getSettingValue('sortDirectionGroups'), 145 | this.plugin.getSettingValue('sortDirectionItems'), 146 | this.plugin.getSettingValue('subGroups'), 147 | this.plugin.getSettingValue('sortDirectionSubGroups'), 148 | ) 149 | } 150 | 151 | private renderView() { 152 | this._app.$set(this.props()) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/utils/tasks.ts: -------------------------------------------------------------------------------- 1 | import MD from 'markdown-it' 2 | import minimatch from 'minimatch' 3 | 4 | import {commentPlugin} from '../plugins/comment' 5 | import {highlightPlugin} from '../plugins/highlight' 6 | import {linkPlugin} from '../plugins/link' 7 | import {tagPlugin} from '../plugins/tag' 8 | import { 9 | combineFileLines, 10 | extractTextFromTodoLine, 11 | getAllLinesFromFile, 12 | getAllTagsFromMetadata, 13 | getFileFromPath, 14 | getFileLabelFromName, 15 | getFrontmatterTags, 16 | getIndentationSpacesFromTodoLine, 17 | getTagMeta, 18 | retrieveTag, 19 | lineIsValidTodo, 20 | mapLinkMeta, 21 | removeTagFromText, 22 | setLineTo, 23 | todoLineIsChecked, 24 | } from './helpers' 25 | 26 | import type { 27 | App, 28 | LinkCache, 29 | MetadataCache, 30 | TagCache, 31 | TFile, 32 | Vault, 33 | } from 'obsidian' 34 | import type {TodoItem, TagMeta, FileInfo} from 'src/_types' 35 | 36 | /** 37 | * Finds all of the {@link TodoItem todos} in the {@link TFile files} that have been updated since the last re-render. 38 | * 39 | * @param files The files to search for todos. 40 | * @param todoTags The tag(s) that should be present on todos in order to be displayed by this plugin. 41 | * @param cache The Obsidian {@link MetadataCache} object. 42 | * @param vault The Obsidian {@link Vault} object. 43 | * @param includeFiles The pattern of files to include in the search for todos. 44 | * @param showChecked Whether the user wants to show completed todos in the plugin's UI. 45 | * @param lastRerender Timestamp of the last time we re-rendered the checklist. 46 | * @returns A map containing each {@link TFile file} that was updated, and the {@link TodoItem todos} in that file. 47 | * If there are no todos in a file, that file will still be present in the map, but the value for its entry will be an 48 | * empty array. This is required to account for the case where a file that previously had todos no longer has any. 49 | */ 50 | export const parseTodos = async ( 51 | files: TFile[], 52 | todoTags: string[], 53 | cache: MetadataCache, 54 | vault: Vault, 55 | includeFiles: string, 56 | showChecked: boolean, 57 | showAllTodos: boolean, 58 | lastRerender: number, 59 | ): Promise> => { 60 | const includePattern = includeFiles.trim() 61 | ? includeFiles.trim().split('\n') 62 | : ['**/*'] 63 | const filesWithCache = await Promise.all( 64 | files 65 | .filter(file => { 66 | if (file.stat.mtime < lastRerender) return false 67 | if (!includePattern.some(p => minimatch(file.path, p))) return false 68 | if (todoTags.length === 1 && todoTags[0] === '*') return true 69 | const fileCache = cache.getFileCache(file) 70 | const allTags = getAllTagsFromMetadata(fileCache) 71 | const tagsOnPage = allTags.filter(tag => 72 | todoTags.includes(retrieveTag(getTagMeta(tag)).toLowerCase()), 73 | ) 74 | return tagsOnPage.length > 0 75 | }) 76 | .map>(async file => { 77 | const fileCache = cache.getFileCache(file) 78 | const tagsOnPage = 79 | fileCache?.tags?.filter(e => 80 | todoTags.includes(retrieveTag(getTagMeta(e.tag)).toLowerCase()), 81 | ) ?? [] 82 | const frontMatterTags = getFrontmatterTags(fileCache, todoTags) 83 | const hasFrontMatterTag = frontMatterTags.length > 0 84 | const parseEntireFile = 85 | todoTags[0] === '*' || hasFrontMatterTag || showAllTodos 86 | const content = await vault.cachedRead(file) 87 | return { 88 | content, 89 | cache: fileCache, 90 | validTags: tagsOnPage.map(e => ({ 91 | ...e, 92 | tag: e.tag.toLowerCase(), 93 | })), 94 | file, 95 | parseEntireFile, 96 | frontmatterTag: todoTags.length ? frontMatterTags[0] : undefined, 97 | } 98 | }), 99 | ) 100 | 101 | const todosForUpdatedFiles = new Map() 102 | for (const fileInfo of filesWithCache) { 103 | let todos = findAllTodosInFile(fileInfo) 104 | if (!showChecked) { 105 | todos = todos.filter(todo => !todo.checked) 106 | } 107 | todosForUpdatedFiles.set(fileInfo.file, todos) 108 | } 109 | 110 | return todosForUpdatedFiles 111 | } 112 | 113 | export const toggleTodoItem = async (item: TodoItem, app: App) => { 114 | const file = getFileFromPath(app.vault, item.filePath) 115 | if (!file) return 116 | const currentFileContents = await app.vault.read(file) 117 | const currentFileLines = getAllLinesFromFile(currentFileContents) 118 | if (!currentFileLines[item.line].includes(item.originalText)) return 119 | const newData = setTodoStatusAtLineTo( 120 | currentFileLines, 121 | item.line, 122 | !item.checked, 123 | ) 124 | app.vault.modify(file, newData) 125 | item.checked = !item.checked 126 | } 127 | 128 | const findAllTodosInFile = (file: FileInfo): TodoItem[] => { 129 | if (!file.parseEntireFile) 130 | return file.validTags.flatMap(tag => findAllTodosFromTagBlock(file, tag)) 131 | 132 | if (!file.content) return [] 133 | const fileLines = getAllLinesFromFile(file.content) 134 | const links = [] 135 | if (file.cache?.links) { 136 | links.push(...file.cache.links) 137 | } 138 | if (file.cache?.embeds) { 139 | links.push(...file.cache.embeds) 140 | } 141 | const tagMeta = file.frontmatterTag 142 | ? getTagMeta(file.frontmatterTag) 143 | : undefined 144 | 145 | const todos: TodoItem[] = [] 146 | for (let i = 0; i < fileLines.length; i++) { 147 | const line = fileLines[i] 148 | if (line.length === 0) continue 149 | if (lineIsValidTodo(line)) { 150 | todos.push(formTodo(line, file, links, i, tagMeta)) 151 | } 152 | } 153 | 154 | return todos 155 | } 156 | 157 | const findAllTodosFromTagBlock = (file: FileInfo, tag: TagCache) => { 158 | const fileContents = file.content 159 | const links = [] 160 | if (file.cache?.links) { 161 | links.push(...file.cache.links) 162 | } 163 | if (file.cache?.embeds) { 164 | links.push(...file.cache.embeds) 165 | } 166 | if (!fileContents) return [] 167 | const fileLines = getAllLinesFromFile(fileContents) 168 | const tagMeta = getTagMeta(tag.tag) 169 | const tagLine = fileLines[tag.position.start.line] 170 | if (lineIsValidTodo(tagLine)) { 171 | return [formTodo(tagLine, file, links, tag.position.start.line, tagMeta)] 172 | } 173 | 174 | const todos: TodoItem[] = [] 175 | for (let i = tag.position.start.line; i < fileLines.length; i++) { 176 | const line = fileLines[i] 177 | if (i === tag.position.start.line + 1 && line.length === 0) continue 178 | if (line.length === 0) break 179 | if (lineIsValidTodo(line)) { 180 | todos.push(formTodo(line, file, links, i, tagMeta)) 181 | } 182 | } 183 | 184 | return todos 185 | } 186 | 187 | const formTodo = ( 188 | line: string, 189 | file: FileInfo, 190 | links: LinkCache[], 191 | lineNum: number, 192 | tagMeta?: TagMeta, 193 | ): TodoItem => { 194 | const relevantLinks = links 195 | .filter(link => link.position.start.line === lineNum) 196 | .map(link => ({filePath: link.link, linkName: link.displayText})) 197 | const linkMap = mapLinkMeta(relevantLinks) 198 | const rawText = extractTextFromTodoLine(line) 199 | const spacesIndented = getIndentationSpacesFromTodoLine(line) 200 | const tagStripped = removeTagFromText(rawText, tagMeta?.main) 201 | const md = new MD() 202 | .use(commentPlugin) 203 | .use(linkPlugin(linkMap)) 204 | .use(tagPlugin) 205 | .use(highlightPlugin) 206 | return { 207 | mainTag: tagMeta?.main, 208 | subTag: tagMeta?.sub, 209 | checked: todoLineIsChecked(line), 210 | filePath: file.file.path, 211 | fileName: file.file.name, 212 | fileLabel: getFileLabelFromName(file.file.name), 213 | fileCreatedTs: file.file.stat.ctime, 214 | rawHTML: md.render(tagStripped), 215 | line: lineNum, 216 | spacesIndented, 217 | fileInfo: file, 218 | originalText: rawText, 219 | } 220 | } 221 | 222 | const setTodoStatusAtLineTo = ( 223 | fileLines: string[], 224 | line: number, 225 | setTo: boolean, 226 | ) => { 227 | fileLines[line] = setLineTo(fileLines[line], setTo) 228 | return combineFileLines(fileLines) 229 | } 230 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import {App, PluginSettingTab, Setting} from 'obsidian' 2 | 3 | import type TodoPlugin from './main' 4 | import type {GroupByType, LookAndFeel, SortDirection} from './_types' 5 | 6 | export interface TodoSettings { 7 | todoPageName: string 8 | showChecked: boolean 9 | showAllTodos: boolean 10 | showOnlyActiveFile: boolean 11 | autoRefresh: boolean 12 | groupBy: GroupByType 13 | subGroups: boolean 14 | sortDirectionItems: SortDirection 15 | sortDirectionGroups: SortDirection 16 | sortDirectionSubGroups: SortDirection 17 | includeFiles: string 18 | lookAndFeel: LookAndFeel 19 | _collapsedSections: string[] 20 | _hiddenTags: string[] 21 | } 22 | 23 | export const DEFAULT_SETTINGS: TodoSettings = { 24 | todoPageName: 'todo', 25 | showChecked: false, 26 | showAllTodos: false, 27 | showOnlyActiveFile: false, 28 | autoRefresh: true, 29 | subGroups: false, 30 | groupBy: 'page', 31 | sortDirectionItems: 'new->old', 32 | sortDirectionGroups: 'new->old', 33 | sortDirectionSubGroups: 'new->old', 34 | includeFiles: '', 35 | lookAndFeel: 'classic', 36 | _collapsedSections: [], 37 | _hiddenTags: [], 38 | } 39 | 40 | export class TodoSettingTab extends PluginSettingTab { 41 | constructor( 42 | app: App, 43 | private plugin: TodoPlugin, 44 | ) { 45 | super(app, plugin) 46 | } 47 | 48 | display(): void { 49 | this.containerEl.empty() 50 | 51 | this.containerEl.createEl('h3', { 52 | text: 'General Settings', 53 | }) 54 | 55 | this.buildSettings() 56 | } 57 | 58 | private buildSettings() { 59 | /** GENERAL */ 60 | 61 | new Setting(this.containerEl).setName('General') 62 | 63 | new Setting(this.containerEl) 64 | .setName('Tag name') 65 | .setDesc( 66 | 'e.g. "todo" will match #todo. You may add mutliple tags separated by a newline. Leave empty to capture all', 67 | ) 68 | .addTextArea(text => 69 | text 70 | .setPlaceholder('todo') 71 | .setValue(this.plugin.getSettingValue('todoPageName')) 72 | .onChange(async value => { 73 | await this.plugin.updateSettings({ 74 | todoPageName: value, 75 | }) 76 | }), 77 | ) 78 | 79 | new Setting(this.containerEl) 80 | .setName('Show Completed?') 81 | .addToggle(toggle => { 82 | toggle.setValue(this.plugin.getSettingValue('showChecked')) 83 | toggle.onChange(async value => { 84 | await this.plugin.updateSettings({showChecked: value}) 85 | }) 86 | }) 87 | 88 | new Setting(this.containerEl) 89 | .setName('Show All Todos In File?') 90 | .setDesc( 91 | 'Show all items in file if tag is present, or only items attached to the block where the tag is located. Only has an effect if Tag Name is not empty', 92 | ) 93 | .addToggle(toggle => { 94 | toggle.setValue(this.plugin.getSettingValue('showAllTodos')) 95 | toggle.onChange(async value => { 96 | await this.plugin.updateSettings({showAllTodos: value}) 97 | }) 98 | }) 99 | 100 | new Setting(this.containerEl) 101 | .setName('Show only in currently active file?') 102 | .setDesc( 103 | 'Show only todos present in currently active file?' 104 | ) 105 | .addToggle(toggle => { 106 | toggle.setValue(this.plugin.getSettingValue('showOnlyActiveFile')) 107 | toggle.onChange(async value => { 108 | await this.plugin.updateSettings({showOnlyActiveFile: value}) 109 | }) 110 | }) 111 | 112 | /** GORUPING & SORTING */ 113 | 114 | new Setting(this.containerEl).setName('Grouping & Sorting') 115 | 116 | new Setting(this.containerEl).setName('Group By').addDropdown(dropdown => { 117 | dropdown.addOption('page', 'Page') 118 | dropdown.addOption('tag', 'Tag') 119 | dropdown.setValue(this.plugin.getSettingValue('groupBy')) 120 | dropdown.onChange(async (value: GroupByType) => { 121 | await this.plugin.updateSettings({groupBy: value}) 122 | }) 123 | }) 124 | 125 | // new Setting(this.containerEl) 126 | // .setName("Enable Sub-Groups?") 127 | // .addToggle((toggle) => { 128 | // toggle.setValue(this.plugin.getSettingValue("subGroups")) 129 | // toggle.onChange(async (value) => { 130 | // await this.plugin.updateSettings({ subGroups: value }) 131 | // }) 132 | // }) 133 | // .setDesc("When grouped by page you will see sub-groups by tag, and vice versa.") 134 | 135 | new Setting(this.containerEl) 136 | .setName('Item Sort') 137 | .addDropdown(dropdown => { 138 | dropdown.addOption('a->z', 'A -> Z') 139 | dropdown.addOption('z->a', 'Z -> A') 140 | dropdown.addOption('new->old', 'New -> Old') 141 | dropdown.addOption('old->new', 'Old -> New') 142 | dropdown.setValue(this.plugin.getSettingValue('sortDirectionItems')) 143 | dropdown.onChange(async (value: SortDirection) => { 144 | await this.plugin.updateSettings({ 145 | sortDirectionItems: value, 146 | }) 147 | }) 148 | }) 149 | .setDesc( 150 | 'Time sorts are based on last time the file for a particular item was edited', 151 | ) 152 | 153 | new Setting(this.containerEl) 154 | .setName('Group Sort') 155 | .addDropdown(dropdown => { 156 | dropdown.addOption('a->z', 'A -> Z') 157 | dropdown.addOption('z->a', 'Z -> A') 158 | dropdown.addOption('new->old', 'New -> Old') 159 | dropdown.addOption('old->new', 'Old -> New') 160 | dropdown.setValue(this.plugin.getSettingValue('sortDirectionGroups')) 161 | dropdown.onChange(async (value: SortDirection) => { 162 | await this.plugin.updateSettings({ 163 | sortDirectionGroups: value, 164 | }) 165 | }) 166 | }) 167 | .setDesc( 168 | 'Time sorts are based on last time the file for the newest or oldest item in a group was edited', 169 | ) 170 | 171 | // new Setting(this.containerEl) 172 | // .setName("Sub-Group Sort") 173 | // .addDropdown((dropdown) => { 174 | // dropdown.addOption("a->z", "A -> Z") 175 | // dropdown.addOption("z->a", "Z -> A") 176 | // dropdown.addOption("new->old", "New -> Old") 177 | // dropdown.addOption("old->new", "Old -> New") 178 | // dropdown.setValue(this.plugin.getSettingValue("sortDirectionSubGroups")) 179 | // dropdown.onChange(async (value: SortDirection) => { 180 | // await this.plugin.updateSettings({ sortDirectionSubGroups: value }) 181 | // }) 182 | // }) 183 | // .setDesc("Time sorts are based on last time the file for the newest or oldest item in a group was edited") 184 | 185 | /** STYLING */ 186 | 187 | new Setting(this.containerEl).setName('Styling') 188 | 189 | new Setting(this.containerEl) 190 | .setName('Look and Feel') 191 | .addDropdown(dropdown => { 192 | dropdown.addOption('classic', 'Classic') 193 | dropdown.addOption('compact', 'Compact') 194 | dropdown.setValue(this.plugin.getSettingValue('lookAndFeel')) 195 | dropdown.onChange(async (value: LookAndFeel) => { 196 | await this.plugin.updateSettings({lookAndFeel: value}) 197 | }) 198 | }) 199 | 200 | /** ADVANCED */ 201 | 202 | new Setting(this.containerEl).setName('Advanced') 203 | 204 | new Setting(this.containerEl) 205 | .setName('Include Files') 206 | .setDesc( 207 | 'Include all files that match this glob pattern. Examples on plugin page/github readme. Leave empty to check all files.', 208 | ) 209 | .setTooltip('**/*') 210 | .addText(text => 211 | text 212 | .setValue(this.plugin.getSettingValue('includeFiles')) 213 | .onChange(async value => { 214 | await this.plugin.updateSettings({ 215 | includeFiles: value, 216 | }) 217 | }), 218 | ) 219 | 220 | new Setting(this.containerEl) 221 | .setName('Auto Refresh List?') 222 | .addToggle(toggle => { 223 | toggle.setValue(this.plugin.getSettingValue('autoRefresh')) 224 | toggle.onChange(async value => { 225 | await this.plugin.updateSettings({autoRefresh: value}) 226 | }) 227 | }) 228 | .setDesc( 229 | 'It\'s recommended to leave this on unless you are expereince performance issues due to a large vault. You can then reload manually using the "Checklist: refresh" command', 230 | ) 231 | } 232 | } 233 | --------------------------------------------------------------------------------