commit 6a2d0cffd694271c050bce9b6abdf036b9fb853a Author: Matt Wiseley Date: Sat Mar 28 10:55:42 2026 -0400 Initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc09cf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ + +# Build output +main.js +main.js.map + +# TypeScript +*.tsbuildinfo + +# Environment & secrets +.env + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ + +# Claude Code local settings +.claude/settings.local.json + +# User settings +data.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a27abc --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# YAOTP — Yet Another Obsidian Task Plugin + +A plugin for [Obsidian](https://obsidian.md) that provides a Todoist-like task management UI for designated markdown files. Instead of editing raw markdown, you interact with a clean task list interface while all changes persist as standard `- [ ]` / `- [x]` checklist items. + +## Features + +- Custom task view for any markdown file matching a configurable pattern (default: `Tasks/*.md`) +- Drag-and-drop task reordering (desktop and mobile long-press) +- Task editing modal for text, notes, and moving tasks between files +- File switcher header bar to navigate between task lists +- Completing a task moves it to today's Daily Note under a `#### Tasks` heading +- All state stored as plain markdown — no database, no lock-in + +## Requirements + +- Obsidian 1.4.0 or later +- Obsidian's built-in Daily Notes plugin (for task completion) + +## Development + +### Setup + +```bash +npm install +``` + +### Watch mode + +```bash +npm run dev +``` + +This starts esbuild in watch mode. It bundles `src/main.ts` to `main.js` with inline source maps and re-bundles on every file change. + +### Load in Obsidian + +1. Copy (or symlink) this repo into your vault's `.obsidian/plugins/yaotp/` directory. +2. Enable the plugin in **Settings → Community Plugins**. +3. Obsidian will hot-reload the plugin when `main.js` changes while in watch mode. + +## Production Build + +```bash +npm run build +``` + +This runs TypeScript type-checking followed by a minified esbuild bundle with no source maps. The output is `main.js`, ready to ship. + +## Configuration + +Plugin settings are available under **Settings → YAOTP — Task Manager**: + +| Setting | Default | Description | +|---|---|---| +| Inbox file | `Tasks/Inbox.md` | Default file opened on plugin load | +| Task file regex | `^Tasks\/.*\.md$` | Pattern that identifies task files | +| Daily note date format | `YYYY-MM-DD` | Moment.js format matching your Daily Notes setup | +| Daily note folder | *(vault root)* | Folder where daily notes are created | + +## Project Structure + +``` +src/ +├── main.ts # Plugin entry point +├── types.ts # Task type definition +├── parser.ts # Markdown ↔ Task[] serialization +├── settings.ts # Settings interface and UI tab +├── constants.ts # Shared constants +├── daily-notes.ts # Daily Notes integration +├── file-intercept.ts # Redirects task file opens to custom view +└── view/ + ├── TaskFileView.ts # Main view (extends TextFileView) + ├── TaskListComponent.ts # Sortable task list renderer + ├── TaskEditorModal.ts # Task edit modal and file picker + └── FileSwitcherBar.ts # File switcher header bar +``` diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..9eaac85 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,41 @@ +import esbuild from 'esbuild'; +import process from 'process'; +import builtins from 'builtin-modules'; + +const prod = process.argv[2] === 'production'; + +const context = await esbuild.context({ + entryPoints: ['src/main.ts'], + bundle: true, + external: [ + 'obsidian', + 'electron', + 'moment', + '@codemirror/autocomplete', + '@codemirror/collab', + '@codemirror/commands', + '@codemirror/language', + '@codemirror/lint', + '@codemirror/search', + '@codemirror/state', + '@codemirror/view', + '@lezer/common', + '@lezer/highlight', + '@lezer/lr', + ...builtins, + ], + format: 'cjs', + target: 'es2018', + logLevel: 'info', + sourcemap: prod ? false : 'inline', + treeShaking: true, + outfile: 'main.js', + minify: prod, +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..de35072 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "yaotp", + "name": "YAOTP — Task Manager", + "version": "0.1.0", + "minAppVersion": "1.4.0", + "description": "A task management GUI for designated markdown files.", + "author": "matt", + "authorUrl": "", + "fundingUrl": "", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bce2909 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,642 @@ +{ + "name": "yaotp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yaotp", + "version": "0.1.0", + "dependencies": { + "obsidian-daily-notes-interface": "^0.9.4", + "sortablejs": "^1.15.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/sortablejs": "^1.15.0", + "builtin-modules": "^3.3.0", + "esbuild": "^0.21.0", + "obsidian": "latest", + "typescript": "^5.0.0" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/sortablejs": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz", + "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/obsidian-daily-notes-interface": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/obsidian-daily-notes-interface/-/obsidian-daily-notes-interface-0.9.4.tgz", + "integrity": "sha512-PILoRtZUB5wEeGnDQAPMlkVlXwDYoxkLR8Wl4STU2zLNwhcq9kKvQexiXi7sfjGlpTnL+LeAOfEVWyeVndneKg==", + "license": "MIT", + "dependencies": { + "obsidian": "github:obsidianmd/obsidian-api#master", + "tslib": "2.1.0" + }, + "bin": { + "obsidian-daily-notes-interface": "dist/main.js" + } + }, + "node_modules/obsidian-daily-notes-interface/node_modules/obsidian": { + "version": "1.12.3", + "resolved": "git+ssh://git@github.com/obsidianmd/obsidian-api.git#d5b94f56e3a909396ae05941e67ddb51a167180d", + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..aad0ae3 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "yaotp", + "version": "0.1.0", + "description": "Yet Another Obsidian Task Plugin", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc --noEmit --skipLibCheck && node esbuild.config.mjs production" + }, + "dependencies": { + "obsidian-daily-notes-interface": "^0.9.4", + "sortablejs": "^1.15.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/sortablejs": "^1.15.0", + "builtin-modules": "^3.3.0", + "esbuild": "^0.21.0", + "obsidian": "latest", + "typescript": "^5.0.0" + } +} diff --git a/requirements.md b/requirements.md new file mode 100644 index 0000000..79e26cc --- /dev/null +++ b/requirements.md @@ -0,0 +1,136 @@ +# Yet Another Obsidian Task Plugin (YAOTP) — Requirements + +## Overview + +An Obsidian plugin that provides a more traditional task management GUI for designated markdown files within a vault. Task files are rendered in a custom view rather than the default markdown editor, and all changes are persisted back to the underlying markdown. The plugin supports both desktop and mobile Obsidian. + +--- + +## 1. Task Files + +### 1.1 Detection + +- A **task file** is any markdown file whose vault path matches a user-configured regex pattern. +- Default pattern: all `.md` files inside the top-level `Tasks/` folder (e.g., `^Tasks\/.*\.md$`). +- When a task file is opened in Obsidian, the plugin automatically renders it in the custom task view instead of the default markdown editor. + +### 1.2 Structure + +- Task files consist entirely of markdown checklist items (`- [ ] …` / `- [x] …`). +- Any non-checklist lines immediately following a checklist item are treated as **notes** for that item. +- The ordering of checklist items in the file reflects the display order in the UI. +- Completed items remain in the file until the user marks them as done, at which point they are moved to the current Daily Note (see §5). + +### 1.3 Inbox File + +- One task file is designated as the **Inbox** — the default destination for new tasks. +- The Inbox file path is configured in plugin settings. +- Default: `Tasks/Inbox.md`. + +--- + +## 2. Task View UI + +### 2.1 Layout + +- The custom view replaces the standard markdown editor for task files. +- Each checklist item is displayed as a task row containing: + - A clickable **checkbox** on the left. + - The **task text**, which wraps to multiple lines as needed. + - Notes are **not** shown inline; they are accessible via the task editor (§3). +- The view does not render raw markdown syntax. + +### 2.2 Reordering + +- Task rows can be reordered by dragging and dropping within the view. +- On mobile, reordering is triggered by a long-press on the drag handle, followed by dragging. +- Reordering the UI immediately reorders the corresponding checklist items in the markdown file. +- Notes travel with their parent task during reordering. + +### 2.3 File Switcher + +- A header bar or sidebar control lets the user quickly switch between: + - The Inbox file. + - Any other task file in the vault (matched by the configured regex). +- The switcher also provides a **"New task file"** action that: + - Prompts for a file name. + - Creates the file in the configured tasks folder (or a location derived from the regex). + - Opens the new file in the task view. + +### 2.3 Adding a Task + +- The first row of every task list is a persistent **add-task row** that looks identical to a regular task row. +- The add-task row contains: + - A **drag handle** (visually present but disabled — not interactive). + - A **checkbox** (visually present but disabled — not interactive). + - A single-line **text input** with placeholder text `Enter new task…`. +- Typing into the input and pressing **Enter** creates a new task with that text, no notes, and inserts it at the top of the task list (immediately below the add-task row). +- The new task is immediately persisted to the markdown file. +- The add-task row is always visible, even when the list is empty. + +### 2.4 Completing a Task + +- Clicking a task's checkbox marks it as complete. +- The task (and its notes) is removed from the current task file. +- The task is appended to the **`#### Tasks`** section at the bottom of the current Daily Note (see §5). +- If the `#### Tasks` section does not exist in the Daily Note, it is created. + +--- + +## 3. Task Editor + +- Clicking the **text** of a task opens an editor modal or inline panel. +- The editor provides: + - A single **multiline text area** containing the task text on the first line, followed by the notes. The first line is the task title; any subsequent lines are notes. The user edits both in one continuous field. + - A **file selector** (dropdown or fuzzy-search picker) to move the task to a different task file. Selecting a different file removes the task from the current file and appends it to the bottom of the selected file. +- Changes are saved back to the markdown file on confirm/close. + +--- + +## 4. Markdown Persistence + +- The markdown file is the source of truth. +- All UI actions (reorder, complete, edit) must produce a corresponding update to the file via the Obsidian Vault API. +- If a task file is modified externally (e.g., edited in another pane or by a sync tool), the task view refreshes to reflect the new state. +- Checklist format written back to disk: + ``` + - [ ] Task title + + notes line 1 + notes line 2 + + - [ ] Next task + ``` + Notes are separated from the checklist item above by one blank line and followed by one blank line before the next checklist item. Completed items use `- [x] …`. + +--- + +## 5. Daily Note Integration + +- The plugin relies on Obsidian's Daily Notes core plugin (or compatible community plugin) to locate the current daily note. +- When a task is completed, the plugin: + 1. Finds or creates today's daily note. + 2. Looks for a `#### Tasks` heading at the bottom of the file. + 3. Appends the completed task and its notes under that heading, using the same format as the task file (task line, blank line, notes, blank line). + 4. If the task has no notes, only the task line is appended. +- If the Daily Notes plugin is not enabled, the plugin displays a warning and does not move the task. + +--- + +## 6. Plugin Settings + +| Setting | Description | Default | +|---|---|---| +| **Inbox file path** | Vault-relative path to the Inbox task file | `Tasks/Inbox.md` | +| **Task file regex** | Regex matched against vault-relative file paths to identify task files | `^Tasks\/.*\.md$` | +| **Daily note date format** | Date format string used to locate the daily note (should match Daily Notes plugin setting) | `YYYY-MM-DD` | +| **Daily note folder** | Folder where daily notes are stored (should match Daily Notes plugin setting) | *(empty — vault root)* | + +--- + +## 7. Out of Scope (v1) + +- Due dates, priorities, or labels on tasks. +- Subtasks / nested checklists. +- Task search or filtering. +- Sync with external task services (Todoist, Things, etc.). diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..4b31364 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,9 @@ +export const VIEW_TYPE = 'yaotp-task-view'; +export const VIEW_ICON = 'check-square'; + +export const DEFAULT_INBOX_PATH = 'Tasks/Inbox.md'; +export const DEFAULT_TASK_REGEX = '^Tasks\\/.*\\.md$'; +export const DEFAULT_DAILY_NOTE_FORMAT = 'YYYY-MM-DD'; +export const DEFAULT_DAILY_NOTE_FOLDER = ''; + +export const DAILY_NOTE_TASKS_HEADING = '#### Tasks'; diff --git a/src/daily-notes.ts b/src/daily-notes.ts new file mode 100644 index 0000000..59122f5 --- /dev/null +++ b/src/daily-notes.ts @@ -0,0 +1,85 @@ +import { App, Notice, TFile } from 'obsidian'; +import type { Task } from './types'; +import { DAILY_NOTE_TASKS_HEADING } from './constants'; + +// obsidian-daily-notes-interface re-exports moment from Obsidian's bundle. +// We import it this way so esbuild treats moment as external. +declare const moment: (date?: unknown) => { + format(fmt: string): string; +}; + +interface DailyNotesInterface { + appHasDailyNotesPluginLoaded(app: App): boolean; + getDailyNote( + date: ReturnType, + notes: Record + ): TFile | null; + createDailyNote(date: ReturnType): Promise; + getAllDailyNotes(): Record; +} + +let dni: DailyNotesInterface | null = null; + +async function getDni(): Promise { + if (!dni) { + dni = await import('obsidian-daily-notes-interface') as unknown as DailyNotesInterface; + } + return dni; +} + +/** + * Append a completed task (and its notes) to the `#### Tasks` section + * of today's daily note, creating the section and/or the note if needed. + */ +export async function appendTaskToDailyNote(task: Task, app: App): Promise { + const lib = await getDni(); + + if (!lib.appHasDailyNotesPluginLoaded(app)) { + new Notice( + 'YAOTP: Daily Notes plugin is not enabled. ' + + 'Please enable it in Settings → Core plugins.' + ); + return; + } + + const all = lib.getAllDailyNotes(); + // moment is available as a global in Obsidian's runtime + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const today = (window as any).moment(); + let file: TFile | null = lib.getDailyNote(today, all); + + if (!file) { + file = await lib.createDailyNote(today); + } + + const existing = await app.vault.read(file); + const appended = buildAppendedContent(existing, task); + await app.vault.modify(file, appended); +} + +function buildAppendedContent(existing: string, task: Task): string { + const taskLine = `- [x] ${task.text}`; + const notesBlock = + task.notes.length > 0 ? '\n\n' + task.notes.join('\n') : ''; + const entry = taskLine + notesBlock; + + const headingIndex = existing.lastIndexOf(DAILY_NOTE_TASKS_HEADING); + + if (headingIndex === -1) { + // Section doesn't exist — append it at the end + const base = existing.trimEnd(); + return base + (base.length > 0 ? '\n\n' : '') + DAILY_NOTE_TASKS_HEADING + '\n\n' + entry + '\n'; + } + + // Section exists — insert after heading and any existing items + const afterHeading = existing.slice(headingIndex + DAILY_NOTE_TASKS_HEADING.length); + // Find where the next same-or-higher-level heading starts (if any) + const nextHeadingMatch = afterHeading.match(/\n#{1,4} /); + const insertionRelative = nextHeadingMatch?.index ?? afterHeading.length; + + const before = existing.slice(0, headingIndex + DAILY_NOTE_TASKS_HEADING.length); + const sectionContent = afterHeading.slice(0, insertionRelative).trimEnd(); + const after = afterHeading.slice(insertionRelative); + + return before + sectionContent + '\n\n' + entry + (after.length > 0 ? '\n' + after : '\n'); +} diff --git a/src/file-intercept.ts b/src/file-intercept.ts new file mode 100644 index 0000000..da97d9b --- /dev/null +++ b/src/file-intercept.ts @@ -0,0 +1,45 @@ +import { WorkspaceLeaf } from 'obsidian'; +import { VIEW_TYPE } from './constants'; + +type SetViewState = WorkspaceLeaf['setViewState']; + +let originalSetViewState: SetViewState | null = null; + +/** + * Monkey-patch WorkspaceLeaf.prototype.setViewState so that opening a + * task file (identified by the given predicate) redirects to our custom + * view type instead of the default markdown editor. + */ +export function installFileIntercept(isTaskFile: (path: string) => boolean): void { + if (originalSetViewState) return; // already installed + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proto = (WorkspaceLeaf as any).prototype; + originalSetViewState = proto.setViewState as SetViewState; + + proto.setViewState = function ( + this: WorkspaceLeaf, + state: Parameters[0], + eState?: Parameters[1] + ) { + if ( + state?.type === 'markdown' && + state?.state?.file && + isTaskFile(state.state.file as string) + ) { + const newState = { ...state, type: VIEW_TYPE }; + return originalSetViewState!.call(this, newState, eState); + } + return originalSetViewState!.call(this, state, eState); + }; +} + +/** + * Restore the original setViewState. Call from Plugin.onunload(). + */ +export function uninstallFileIntercept(): void { + if (!originalSetViewState) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (WorkspaceLeaf as any).prototype.setViewState = originalSetViewState; + originalSetViewState = null; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..d59e5e5 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,96 @@ +import { Plugin, TFile } from 'obsidian'; +import { VIEW_TYPE } from './constants'; +import { DEFAULT_SETTINGS, YaotpSettings, YaotpSettingTab } from './settings'; +import { TaskFileView } from './view/TaskFileView'; +import { installFileIntercept, uninstallFileIntercept } from './file-intercept'; + +export default class YaotpPlugin extends Plugin { + settings: YaotpSettings = { ...DEFAULT_SETTINGS }; + + private taskRegex: RegExp = new RegExp(DEFAULT_SETTINGS.taskFileRegex); + + async onload(): Promise { + await this.loadSettings(); + this.rebuildRegex(); + + // Register the custom view + this.registerView( + VIEW_TYPE, + (leaf) => + new TaskFileView(leaf, this.settings, () => this.getTaskFiles()) + ); + + // Intercept file opens for task files + installFileIntercept((path) => this.isTaskFile(path)); + + // Open existing task-file leaves in the custom view on startup + this.app.workspace.onLayoutReady(() => { + this.redirectExistingLeaves(); + }); + + // Settings tab + this.addSettingTab(new YaotpSettingTab(this.app, this)); + + // Command: open inbox + this.addCommand({ + id: 'open-inbox', + name: 'Open Inbox', + callback: () => this.openInbox(), + }); + } + + onunload(): void { + uninstallFileIntercept(); + } + + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + this.rebuildRegex(); + } + + isTaskFile(path: string): boolean { + return this.taskRegex.test(path); + } + + getTaskFiles(): TFile[] { + return this.app.vault + .getMarkdownFiles() + .filter((f) => this.isTaskFile(f.path)); + } + + private rebuildRegex(): void { + try { + this.taskRegex = new RegExp(this.settings.taskFileRegex); + } catch { + // Fall back to default if the setting is invalid + this.taskRegex = new RegExp(DEFAULT_SETTINGS.taskFileRegex); + } + } + + private redirectExistingLeaves(): void { + this.app.workspace.iterateAllLeaves((leaf) => { + const state = leaf.getViewState(); + if ( + state.type === 'markdown' && + state.state?.file && + this.isTaskFile(state.state.file as string) + ) { + leaf.setViewState({ ...state, type: VIEW_TYPE }); + } + }); + } + + private async openInbox(): Promise { + const { inboxPath } = this.settings; + let file = this.app.vault.getAbstractFileByPath(inboxPath) as TFile | null; + if (!file) { + file = await this.app.vault.create(inboxPath, ''); + } + const leaf = this.app.workspace.getLeaf(false); + await leaf.openFile(file); + } +} diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..6ae3725 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,123 @@ +import type { Task } from './types'; + +const CHECKLIST_RE = /^- \[([ xX])\] (.*)$/; + +/** + * Parse a task file's markdown content into an array of Tasks. + * + * Format expected: + * - [ ] Task title + * + * notes line 1 + * notes line 2 + * + * - [ ] Next task + * + * Notes are lines between two blank-line boundaries following a checklist item. + * Blank lines that are not between a checklist item and notes, or between notes + * lines, are ignored. + */ +export function parseTaskFile(markdown: string): Task[] { + const lines = markdown.split('\n'); + const tasks: Task[] = []; + let current: Task | null = null; + // State: 'idle' | 'after-task' | 'in-notes' + // after-task: saw checklist line, waiting for blank line or next checklist + // in-notes: saw blank line after checklist, collecting note lines + let state: 'idle' | 'after-task' | 'in-notes' = 'idle'; + let noteBuffer: string[] = []; + + const flush = () => { + if (current) { + // Trim trailing blank lines from notes + while (noteBuffer.length > 0 && noteBuffer[noteBuffer.length - 1].trim() === '') { + noteBuffer.pop(); + } + current.notes = noteBuffer; + tasks.push(current); + current = null; + noteBuffer = []; + } + }; + + for (const line of lines) { + const match = CHECKLIST_RE.exec(line); + + if (match) { + // Start of a new task — flush the previous one first + flush(); + current = { + text: match[2], + notes: [], + completed: match[1].toLowerCase() === 'x', + }; + state = 'after-task'; + continue; + } + + if (current === null) { + // No active task; ignore non-checklist lines before the first task + continue; + } + + const isBlank = line.trim() === ''; + + if (state === 'after-task') { + if (isBlank) { + // Blank line after task — transition to note-collection mode + state = 'in-notes'; + } else { + // Non-blank, non-checklist line immediately after task (no separator blank) + // Treat as a note anyway, for robustness + noteBuffer.push(line); + state = 'in-notes'; + } + continue; + } + + if (state === 'in-notes') { + noteBuffer.push(line); + continue; + } + } + + flush(); + return tasks; +} + +/** + * Serialize an array of Tasks back to canonical markdown. + * + * Format produced: + * - [ ] Task title + * + * notes line 1 + * notes line 2 + * + * - [ ] Next task + * + * Tasks with no notes have no trailing blank line before the next task, + * except the final newline at EOF. + */ +export function serializeTaskFile(tasks: Task[]): string { + if (tasks.length === 0) return ''; + + const parts: string[] = []; + + for (const task of tasks) { + const marker = task.completed ? 'x' : ' '; + parts.push(`- [${marker}] ${task.text}`); + + if (task.notes.length > 0) { + parts.push(''); + for (const note of task.notes) { + parts.push(note); + } + } + + parts.push(''); + } + + // Join and ensure single trailing newline + return parts.join('\n').replace(/\n+$/, '') + '\n'; +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..deb8954 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,102 @@ +import { App, PluginSettingTab, Setting } from 'obsidian'; +import { + DEFAULT_DAILY_NOTE_FOLDER, + DEFAULT_DAILY_NOTE_FORMAT, + DEFAULT_INBOX_PATH, + DEFAULT_TASK_REGEX, +} from './constants'; +import type YaotpPlugin from './main'; + +export interface YaotpSettings { + inboxPath: string; + taskFileRegex: string; + dailyNoteFormat: string; + dailyNoteFolder: string; +} + +export const DEFAULT_SETTINGS: YaotpSettings = { + inboxPath: DEFAULT_INBOX_PATH, + taskFileRegex: DEFAULT_TASK_REGEX, + dailyNoteFormat: DEFAULT_DAILY_NOTE_FORMAT, + dailyNoteFolder: DEFAULT_DAILY_NOTE_FOLDER, +}; + +export class YaotpSettingTab extends PluginSettingTab { + plugin: YaotpPlugin; + + constructor(app: App, plugin: YaotpPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + new Setting(containerEl) + .setName('Inbox file path') + .setDesc('Vault-relative path to the default Inbox task file.') + .addText((text) => + text + .setPlaceholder('Tasks/Inbox.md') + .setValue(this.plugin.settings.inboxPath) + .onChange(async (value) => { + this.plugin.settings.inboxPath = value.trim(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName('Task file regex') + .setDesc( + 'Regular expression matched against vault-relative file paths to identify task files.' + ) + .addText((text) => { + text + .setPlaceholder('^Tasks\\/.*\\.md$') + .setValue(this.plugin.settings.taskFileRegex) + .onChange(async (value) => { + // Validate before saving + try { + new RegExp(value); + text.inputEl.removeClass('yaotp-setting-error'); + this.plugin.settings.taskFileRegex = value.trim(); + await this.plugin.saveSettings(); + } catch { + text.inputEl.addClass('yaotp-setting-error'); + } + }); + text.inputEl.style.width = '100%'; + }); + + new Setting(containerEl) + .setName('Daily note date format') + .setDesc( + 'Moment.js date format for daily notes. Should match your Daily Notes plugin setting.' + ) + .addText((text) => + text + .setPlaceholder('YYYY-MM-DD') + .setValue(this.plugin.settings.dailyNoteFormat) + .onChange(async (value) => { + this.plugin.settings.dailyNoteFormat = value.trim(); + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName('Daily note folder') + .setDesc( + 'Folder where daily notes are stored. Leave empty for the vault root. Should match your Daily Notes plugin setting.' + ) + .addText((text) => + text + .setPlaceholder('Daily Notes') + .setValue(this.plugin.settings.dailyNoteFolder) + .onChange(async (value) => { + this.plugin.settings.dailyNoteFolder = value.trim(); + await this.plugin.saveSettings(); + }) + ); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2e20520 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,8 @@ +export interface Task { + /** The text of the checklist item (without the `- [ ] ` prefix). */ + text: string; + /** Lines of notes associated with this task. Empty array if none. */ + notes: string[]; + /** Whether the task is completed (`- [x]`). */ + completed: boolean; +} diff --git a/src/view/FileSwitcherBar.ts b/src/view/FileSwitcherBar.ts new file mode 100644 index 0000000..c4537e9 --- /dev/null +++ b/src/view/FileSwitcherBar.ts @@ -0,0 +1,59 @@ +import { App, TFile } from 'obsidian'; + +export interface FileSwitcherBarOptions { + app: App; + currentFile: TFile; + taskFiles: TFile[]; + inboxPath: string; + onSwitchFile: (file: TFile) => void; + onCreateFile: (name: string) => void; +} + +/** + * Renders a header bar with a file switcher dropdown and a "New task file" button. + * Returns the container element to be prepended into the view's root. + */ +export function buildFileSwitcherBar(opts: FileSwitcherBarOptions): HTMLElement { + const { currentFile, taskFiles, inboxPath, onSwitchFile, onCreateFile } = opts; + + const bar = createDiv({ cls: 'yaotp-switcher-bar' }); + + // Left side: file selector + const selectWrap = bar.createDiv({ cls: 'yaotp-switcher-select-wrap' }); + + const select = selectWrap.createEl('select', { cls: 'yaotp-switcher-select' }); + + // Always show Inbox first if it exists in the list + const inbox = taskFiles.find((f) => f.path === inboxPath); + const others = taskFiles.filter((f) => f.path !== inboxPath); + const ordered = inbox ? [inbox, ...others] : others; + + for (const file of ordered) { + const opt = select.createEl('option', { + text: file.basename, + value: file.path, + }); + if (file.path === currentFile.path) { + opt.selected = true; + } + } + + select.addEventListener('change', () => { + const chosen = taskFiles.find((f) => f.path === select.value); + if (chosen) onSwitchFile(chosen); + }); + + // Right side: new file button + const newBtn = bar.createEl('button', { + text: '+ New list', + cls: 'yaotp-switcher-new-btn', + }); + newBtn.addEventListener('click', () => { + const name = prompt('New task file name (without .md):'); + if (name && name.trim()) { + onCreateFile(name.trim()); + } + }); + + return bar; +} diff --git a/src/view/TaskEditorModal.ts b/src/view/TaskEditorModal.ts new file mode 100644 index 0000000..c45a700 --- /dev/null +++ b/src/view/TaskEditorModal.ts @@ -0,0 +1,136 @@ +import { App, FuzzySuggestModal, Modal, TFile } from 'obsidian'; +import type { Task } from '../types'; + +export interface TaskEditorResult { + text: string; + notes: string[]; + /** If set, move the task to this file. */ + targetFile: TFile | null; +} + +type SaveCallback = (result: TaskEditorResult) => void; + +/** + * Modal for editing a task's text and notes together, and optionally + * moving it to a different task file. + */ +export class TaskEditorModal extends Modal { + private task: Task; + private taskFiles: TFile[]; + private currentFile: TFile; + private onSave: SaveCallback; + private textarea: HTMLTextAreaElement | null = null; + private selectedFile: TFile | null = null; + private fileLabel: HTMLSpanElement | null = null; + + constructor( + app: App, + task: Task, + taskFiles: TFile[], + currentFile: TFile, + onSave: SaveCallback + ) { + super(app); + this.task = task; + this.taskFiles = taskFiles; + this.currentFile = currentFile; + this.onSave = onSave; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.addClass('yaotp-editor-modal'); + + // Title + contentEl.createEl('h2', { text: 'Edit task' }); + + // Textarea: first line = task text, rest = notes + const initialValue = [this.task.text, ...this.task.notes].join('\n'); + this.textarea = contentEl.createEl('textarea', { + cls: 'yaotp-editor-textarea', + attr: { rows: '8', placeholder: 'Task title\n\nNotes…' }, + }); + this.textarea.value = initialValue; + // Auto-focus + setTimeout(() => this.textarea?.focus(), 50); + + // File selector row + const fileRow = contentEl.createDiv({ cls: 'yaotp-editor-file-row' }); + fileRow.createEl('span', { text: 'File: ', cls: 'yaotp-editor-file-label-prefix' }); + this.fileLabel = fileRow.createEl('span', { + text: this.currentFile.basename, + cls: 'yaotp-editor-file-name', + }); + const changeBtn = fileRow.createEl('button', { + text: 'Change…', + cls: 'yaotp-editor-file-btn', + }); + changeBtn.addEventListener('click', () => { + const picker = new TaskFilePicker( + this.app, + this.taskFiles, + (file) => { + this.selectedFile = file; + if (this.fileLabel) { + this.fileLabel.setText(file.basename); + } + } + ); + picker.open(); + }); + + // Buttons + const btnRow = contentEl.createDiv({ cls: 'yaotp-editor-btn-row' }); + const saveBtn = btnRow.createEl('button', { + text: 'Save', + cls: 'mod-cta yaotp-editor-save-btn', + }); + saveBtn.addEventListener('click', () => this.save()); + + const cancelBtn = btnRow.createEl('button', { text: 'Cancel' }); + cancelBtn.addEventListener('click', () => this.close()); + } + + onClose(): void { + this.contentEl.empty(); + } + + private save(): void { + if (!this.textarea) return; + const lines = this.textarea.value.split('\n'); + const text = lines[0] ?? ''; + const notes = lines.slice(1); + // Trim leading blank lines from notes + while (notes.length > 0 && notes[0].trim() === '') notes.shift(); + this.onSave({ + text, + notes, + targetFile: this.selectedFile, + }); + this.close(); + } +} + +class TaskFilePicker extends FuzzySuggestModal { + private files: TFile[]; + private onSelect: (file: TFile) => void; + + constructor(app: App, files: TFile[], onSelect: (file: TFile) => void) { + super(app); + this.files = files; + this.onSelect = onSelect; + this.setPlaceholder('Select a task file…'); + } + + getItems(): TFile[] { + return this.files; + } + + getItemText(file: TFile): string { + return file.path; + } + + onChooseItem(file: TFile): void { + this.onSelect(file); + } +} diff --git a/src/view/TaskFileView.ts b/src/view/TaskFileView.ts new file mode 100644 index 0000000..1727364 --- /dev/null +++ b/src/view/TaskFileView.ts @@ -0,0 +1,193 @@ +import { App, Notice, TextFileView, TFile, WorkspaceLeaf } from 'obsidian'; +import { parseTaskFile, serializeTaskFile } from '../parser'; +import { appendTaskToDailyNote } from '../daily-notes'; +import { TaskListComponent } from './TaskListComponent'; +import { buildFileSwitcherBar } from './FileSwitcherBar'; +import { TaskEditorModal } from './TaskEditorModal'; +import { VIEW_ICON, VIEW_TYPE } from '../constants'; +import type { Task } from '../types'; +import type { YaotpSettings } from '../settings'; + +export class TaskFileView extends TextFileView { + private tasks: Task[] = []; + private taskList: TaskListComponent | null = null; + private rootEl: HTMLElement | null = null; + private listEl: HTMLElement | null = null; + private settings: YaotpSettings; + private getTaskFiles: () => TFile[]; + + constructor( + leaf: WorkspaceLeaf, + settings: YaotpSettings, + getTaskFiles: () => TFile[] + ) { + super(leaf); + this.settings = settings; + this.getTaskFiles = getTaskFiles; + } + + getViewType(): string { + return VIEW_TYPE; + } + + getDisplayText(): string { + return this.file?.basename ?? 'Tasks'; + } + + getIcon(): string { + return VIEW_ICON; + } + + // Called by Obsidian when the file content is loaded or changes externally. + setViewData(data: string, _clear: boolean): void { + this.tasks = parseTaskFile(data); + this.renderView(); + } + + // Called by Obsidian when it needs to read back the file content. + getViewData(): string { + return serializeTaskFile(this.tasks); + } + + clear(): void { + this.tasks = []; + this.renderView(); + } + + // Obsidian calls this after the view is attached to the DOM. + async onOpen(): Promise { + this.contentEl.addClass('yaotp-view'); + this.rootEl = this.contentEl; + } + + async onClose(): Promise { + this.taskList?.destroy(); + this.taskList = null; + } + + // ── Private ────────────────────────────────────────────────────────────── + + private renderView(): void { + if (!this.rootEl) return; + + this.rootEl.empty(); + + // Switcher bar + if (this.file) { + const taskFiles = this.getTaskFiles(); + const bar = buildFileSwitcherBar({ + app: this.app, + currentFile: this.file, + taskFiles, + inboxPath: this.settings.inboxPath, + onSwitchFile: (file) => this.openFile(file), + onCreateFile: (name) => this.createAndOpenFile(name), + }); + this.rootEl.appendChild(bar); + } + + // Task list container + this.listEl = this.rootEl.createDiv({ cls: 'yaotp-list-container' }); + + this.taskList = new TaskListComponent(this.listEl, { + onComplete: (index) => this.completeTask(index), + onEdit: (index) => this.editTask(index), + onReorder: (oldIndex, newIndex) => this.reorderTask(oldIndex, newIndex), + onAdd: (text) => this.addTask(text), + }); + this.taskList.render(this.tasks); + } + + private async persistTasks(): Promise { + if (!this.file) return; + await this.app.vault.modify(this.file, serializeTaskFile(this.tasks)); + } + + private async completeTask(index: number): Promise { + const task = this.tasks[index]; + if (!task) return; + + // Mark completed and remove from this file + task.completed = true; + this.tasks.splice(index, 1); + await this.persistTasks(); + + // Append to daily note + await appendTaskToDailyNote(task, this.app); + + this.renderView(); + } + + private editTask(index: number): void { + const task = this.tasks[index]; + if (!task || !this.file) return; + + const taskFiles = this.getTaskFiles(); + + const modal = new TaskEditorModal( + this.app, + task, + taskFiles, + this.file, + async (result) => { + task.text = result.text; + task.notes = result.notes; + + if (result.targetFile && result.targetFile.path !== this.file?.path) { + // Move task to another file + this.tasks.splice(index, 1); + await this.persistTasks(); + await this.appendTaskToFile(task, result.targetFile); + } else { + await this.persistTasks(); + } + + this.renderView(); + } + ); + modal.open(); + } + + private async addTask(text: string): Promise { + this.tasks.unshift({ text, notes: [], completed: false }); + await this.persistTasks(); + this.renderView(); + } + + private reorderTask(oldIndex: number, newIndex: number): void { + const moved = this.tasks.splice(oldIndex, 1)[0]; + this.tasks.splice(newIndex, 0, moved); + this.persistTasks(); + // No need to re-render; SortableJS already updated the DOM + } + + private async appendTaskToFile(task: Task, file: TFile): Promise { + const content = await this.app.vault.read(file); + const existing = parseTaskFile(content); + existing.push(task); + await this.app.vault.modify(file, serializeTaskFile(existing)); + } + + private openFile(file: TFile): void { + this.app.workspace.getLeaf(false).openFile(file); + } + + private async createAndOpenFile(name: string): Promise { + // Derive folder from inboxPath + const inboxFolder = this.settings.inboxPath.includes('/') + ? this.settings.inboxPath.slice(0, this.settings.inboxPath.lastIndexOf('/')) + : ''; + const path = inboxFolder ? `${inboxFolder}/${name}.md` : `${name}.md`; + + let file = this.app.vault.getAbstractFileByPath(path) as TFile | null; + if (!file) { + try { + file = await this.app.vault.create(path, ''); + } catch (e) { + new Notice(`YAOTP: Could not create file: ${path}`); + return; + } + } + this.openFile(file); + } +} diff --git a/src/view/TaskListComponent.ts b/src/view/TaskListComponent.ts new file mode 100644 index 0000000..0e793dc --- /dev/null +++ b/src/view/TaskListComponent.ts @@ -0,0 +1,111 @@ +import Sortable from 'sortablejs'; +import type { Task } from '../types'; + +export interface TaskListCallbacks { + onComplete: (index: number) => void; + onEdit: (index: number) => void; + onReorder: (oldIndex: number, newIndex: number) => void; + onAdd: (text: string) => void; +} + +/** + * Builds and manages the sortable task list DOM. + */ +export class TaskListComponent { + private container: HTMLElement; + private callbacks: TaskListCallbacks; + private sortable: Sortable | null = null; + + constructor(container: HTMLElement, callbacks: TaskListCallbacks) { + this.container = container; + this.callbacks = callbacks; + } + + render(tasks: Task[]): void { + this.destroy(); + this.container.empty(); + + // Add-task row — always first, outside the sortable list + const addItem = this.container.createDiv({ cls: 'yaotp-task-item yaotp-add-task-item' }); + + const addHandle = addItem.createDiv({ cls: 'yaotp-drag-handle yaotp-drag-handle-disabled' }); + addHandle.innerHTML = '⋮'; + + addItem.createEl('input', { + cls: 'yaotp-checkbox', + type: 'checkbox', + attr: { disabled: true, 'aria-hidden': 'true' }, + }); + + const input = addItem.createEl('input', { + cls: 'yaotp-new-task-input', + type: 'text', + attr: { placeholder: 'Enter new task...' }, + }) as HTMLInputElement; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const text = input.value.trim(); + if (text) { + this.callbacks.onAdd(text); + } + } + }); + + if (tasks.length === 0) return; + + const list = this.container.createEl('ul', { cls: 'yaotp-task-list' }); + + tasks.forEach((task, index) => { + const item = list.createEl('li', { cls: 'yaotp-task-item' }); + if (task.completed) item.addClass('yaotp-task-completed'); + item.dataset.index = String(index); + + // Drag handle + const handle = item.createDiv({ cls: 'yaotp-drag-handle' }); + handle.innerHTML = '⋮'; // vertical ellipsis ⋮ + + // Checkbox + const checkbox = item.createEl('input', { + cls: 'yaotp-checkbox', + type: 'checkbox', + attr: { 'aria-label': 'Complete task' }, + }) as HTMLInputElement; + checkbox.checked = task.completed; + checkbox.addEventListener('click', (e) => { + e.stopPropagation(); + this.callbacks.onComplete(index); + }); + + // Task text (wraps) + const textEl = item.createDiv({ cls: 'yaotp-task-text' }); + textEl.setText(task.text); + if (task.notes.length > 0) { + textEl.addClass('yaotp-task-has-notes'); + } + textEl.addEventListener('click', () => { + this.callbacks.onEdit(index); + }); + }); + + this.sortable = Sortable.create(list, { + handle: '.yaotp-drag-handle', + animation: 150, + delay: 300, + delayOnTouchOnly: true, + onEnd: (evt) => { + const oldIndex = evt.oldIndex; + const newIndex = evt.newIndex; + if (oldIndex !== undefined && newIndex !== undefined && oldIndex !== newIndex) { + this.callbacks.onReorder(oldIndex, newIndex); + } + }, + }); + } + + destroy(): void { + if (this.sortable) { + this.sortable.destroy(); + this.sortable = null; + } + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..e89e064 --- /dev/null +++ b/styles.css @@ -0,0 +1,231 @@ +/* ── YAOTP — Task Plugin Styles ─────────────────────────────────────────── */ + +/* View root */ +.yaotp-view { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + padding: 0; +} + +/* ── File Switcher Bar ──────────────────────────────────────────────────── */ + +.yaotp-switcher-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-primary); + flex-shrink: 0; +} + +.yaotp-switcher-select-wrap { + flex: 1; + margin-right: 8px; +} + +.yaotp-switcher-select { + width: 100%; + background: var(--background-secondary); + color: var(--text-normal); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 4px 8px; + font-size: var(--font-ui-small); + cursor: pointer; +} + +.yaotp-switcher-new-btn { + flex-shrink: 0; + font-size: var(--font-ui-small); + padding: 4px 10px; + cursor: pointer; +} + +/* ── Task List ──────────────────────────────────────────────────────────── */ + +.yaotp-list-container { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.yaotp-task-list { + list-style: none; + margin: 0; + padding: 0; +} + +.yaotp-task-item { + display: flex; + align-items: flex-start; + padding: 8px 12px; + border-radius: 4px; + cursor: default; + gap: 8px; + transition: background 80ms ease; +} + +.yaotp-task-item:hover { + background: var(--background-secondary); +} + +/* Dim completed tasks */ +.yaotp-task-completed .yaotp-task-text { + opacity: 0.5; + text-decoration: line-through; +} + +/* Drag handle */ +.yaotp-drag-handle { + flex-shrink: 0; + color: var(--text-muted); + cursor: grab; + padding: 2px 0; + user-select: none; + font-size: 16px; + line-height: 1.4; + touch-action: none; +} + +.yaotp-drag-handle:active { + cursor: grabbing; +} + +/* Checkbox */ +.yaotp-checkbox { + flex-shrink: 0; + margin-top: 3px; + width: 16px; + height: 16px; + cursor: pointer; +} + +/* Task text */ +.yaotp-task-text { + flex: 1; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.5; + cursor: pointer; + padding: 1px 0; +} + +.yaotp-task-text:hover { + color: var(--text-accent); +} + +/* Note indicator dot */ +.yaotp-task-has-notes::after { + content: ' ·'; + color: var(--text-muted); + font-size: 1.2em; + line-height: 1; +} + +/* Add-task row */ +.yaotp-drag-handle-disabled { + opacity: 0.2; + cursor: default; + pointer-events: none; +} + +.yaotp-new-task-input { + flex: 1; + background: transparent; + border: none; + border-bottom: 1px solid var(--background-modifier-border); + color: var(--text-normal); + font-family: var(--font-text); + font-size: var(--font-ui-medium); + padding: 1px 0; + line-height: 1.5; + outline: none; +} + +.yaotp-new-task-input::placeholder { + color: var(--text-muted); +} + +.yaotp-new-task-input:focus { + border-bottom-color: var(--interactive-accent); +} + +/* Empty state */ +.yaotp-empty { + padding: 24px 16px; + color: var(--text-muted); + text-align: center; + font-size: var(--font-ui-small); +} + +/* SortableJS ghost + chosen */ +.yaotp-task-item.sortable-ghost { + opacity: 0.35; + background: var(--background-secondary); +} + +.yaotp-task-item.sortable-chosen { + background: var(--background-secondary-alt); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +/* ── Task Editor Modal ──────────────────────────────────────────────────── */ + +.yaotp-editor-modal .modal-content { + display: flex; + flex-direction: column; + gap: 12px; +} + +.yaotp-editor-textarea { + width: 100%; + min-height: 140px; + resize: vertical; + font-family: var(--font-text); + font-size: var(--font-ui-medium); + padding: 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-normal); + line-height: 1.5; + box-sizing: border-box; +} + +.yaotp-editor-textarea:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.yaotp-editor-file-row { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--font-ui-small); + color: var(--text-muted); +} + +.yaotp-editor-file-name { + color: var(--text-normal); + font-weight: 500; +} + +.yaotp-editor-file-btn { + font-size: var(--font-ui-small); + padding: 2px 8px; + cursor: pointer; +} + +.yaotp-editor-btn-row { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* Settings error state */ +.yaotp-setting-error { + border-color: var(--color-red) !important; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..978e3df --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "baseUrl": "src", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowImportingTsExtensions": true, + "moduleResolution": "bundler", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "lib": ["ES6", "DOM"] + }, + "include": ["src/**/*.ts"] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..cdffaed --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.4.0" +}