This commit is contained in:
2026-03-28 10:55:42 -04:00
commit 6a2d0cffd6
21 changed files with 2175 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@ -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

76
README.md Normal file
View File

@ -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
```

41
esbuild.config.mjs Normal file
View File

@ -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();
}

11
manifest.json Normal file
View File

@ -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
}

642
package-lock.json generated Normal file
View File

@ -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
}
}
}

22
package.json Normal file
View File

@ -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"
}
}

136
requirements.md Normal file
View File

@ -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.).

9
src/constants.ts Normal file
View File

@ -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';

85
src/daily-notes.ts Normal file
View File

@ -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<typeof moment>,
notes: Record<string, TFile>
): TFile | null;
createDailyNote(date: ReturnType<typeof moment>): Promise<TFile>;
getAllDailyNotes(): Record<string, TFile>;
}
let dni: DailyNotesInterface | null = null;
async function getDni(): Promise<DailyNotesInterface> {
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<void> {
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');
}

45
src/file-intercept.ts Normal file
View File

@ -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<SetViewState>[0],
eState?: Parameters<SetViewState>[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;
}

96
src/main.ts Normal file
View File

@ -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<void> {
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<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
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<void> {
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);
}
}

123
src/parser.ts Normal file
View File

@ -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';
}

102
src/settings.ts Normal file
View File

@ -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();
})
);
}
}

8
src/types.ts Normal file
View File

@ -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;
}

View File

@ -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;
}

136
src/view/TaskEditorModal.ts Normal file
View File

@ -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<TFile> {
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);
}
}

193
src/view/TaskFileView.ts Normal file
View File

@ -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<void> {
this.contentEl.addClass('yaotp-view');
this.rootEl = this.contentEl;
}
async onClose(): Promise<void> {
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<void> {
if (!this.file) return;
await this.app.vault.modify(this.file, serializeTaskFile(this.tasks));
}
private async completeTask(index: number): Promise<void> {
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<void> {
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<void> {
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<void> {
// 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);
}
}

View File

@ -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 = '&#8942;';
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 = '&#8942;'; // 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;
}
}
}

231
styles.css Normal file
View File

@ -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;
}

20
tsconfig.json Normal file
View File

@ -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"]
}

3
versions.json Normal file
View File

@ -0,0 +1,3 @@
{
"0.1.0": "1.4.0"
}