Initial
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
76
README.md
Normal 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
41
esbuild.config.mjs
Normal 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
11
manifest.json
Normal 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
642
package-lock.json
generated
Normal 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
22
package.json
Normal 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
136
requirements.md
Normal 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
9
src/constants.ts
Normal 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
85
src/daily-notes.ts
Normal 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
45
src/file-intercept.ts
Normal 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
96
src/main.ts
Normal 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
123
src/parser.ts
Normal 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
102
src/settings.ts
Normal 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
8
src/types.ts
Normal 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;
|
||||
}
|
||||
59
src/view/FileSwitcherBar.ts
Normal file
59
src/view/FileSwitcherBar.ts
Normal 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
136
src/view/TaskEditorModal.ts
Normal 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
193
src/view/TaskFileView.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
111
src/view/TaskListComponent.ts
Normal file
111
src/view/TaskListComponent.ts
Normal 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 = '⋮';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
231
styles.css
Normal file
231
styles.css
Normal 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
20
tsconfig.json
Normal 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
3
versions.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"0.1.0": "1.4.0"
|
||||
}
|
||||
Reference in New Issue
Block a user