commit acb81c91ef00778891f1268fb529939cd5835abf Author: Savar-G Date: Sat Jul 18 14:16:25 2026 -0700 Initial public release diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..8799207 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @Savar-G diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..df53d4c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + verify: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Check out repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build plugin + run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ec7470a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build plugin + run: npm run build + + - name: Verify release version + env: + RELEASE_TAG: ${{ github.ref_name }} + run: node -e 'const manifest = require("./manifest.json"); if (manifest.version !== process.env.RELEASE_TAG) { throw new Error(`Tag ${process.env.RELEASE_TAG} does not match manifest ${manifest.version}`); }' + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" main.js manifest.json styles.css --verify-tag --generate-notes --title "Taskline $GITHUB_REF_NAME" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb6fbcf --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +main.js +.DS_Store +*.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5209874 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Contributing + +Bug reports and focused pull requests are welcome. + +## Develop Locally + +1. Fork and clone the repository. +2. Install dependencies with `npm ci`. +3. Run `npm test`. +4. Run `npm run build`. +5. Copy `main.js`, `manifest.json`, and `styles.css` into `/.obsidian/plugins/vault-tasks/` to test in Obsidian. + +Keep task parsing and write logic in pure modules where possible. Add a regression test for every behavior change. Do not include real vault content, personal paths, credentials, or generated `data.json` settings in commits. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d64d97e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Taskline contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cb7421 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# Taskline + +Taskline is an Obsidian task dashboard and quick-capture plugin. It reads tasks from the notes you choose, groups them into a focused Today view, and writes changes back to Markdown. + +Taskline does not create task notes during setup. You keep control of every source path, heading, route, and display group. + +## Features + +- Today, Upcoming, and All task views +- Natural-language capture for dates, priorities, recurrence, tags, and owners +- Configurable task sources, headings, areas, and capture routes +- Inline task editing, status changes, and completion +- Desktop and mobile layouts with keyboard navigation +- Local-only operation with no accounts, telemetry, or network requests +- Optional proposal rows for reviewing suggested completions or cancellations + +## Requirements + +- Obsidian 1.5.0 or later +- Markdown task notes that use `- [ ] Task` syntax +- The [Tasks plugin](https://github.com/obsidian-tasks-group/obsidian-tasks) only if you complete recurring tasks through Taskline + +Taskline can read and edit non-recurring tasks without the Tasks plugin. + +## Install + +Taskline is not yet listed in the Obsidian Community plugins directory. + +To install a release manually: + +1. Download `main.js`, `manifest.json`, and `styles.css` from the release. +2. Create `/.obsidian/plugins/vault-tasks/`. +3. Place all three files in that folder. +4. Reload Obsidian. +5. Enable **Taskline** under **Settings** -> **Community plugins**. + +The folder is named `vault-tasks` because Obsidian requires it to match the stable plugin ID in `manifest.json`. + +## Configure + +Open **Settings** -> **Taskline**. Taskline starts unconfigured and inactive. Add the source notes and headings you want, then select **Apply**. + +The settings UI accepts JSON arrays so related routes and groups can be edited together. This minimal configuration reads one note and captures new tasks under its `Inbox` heading: + +**Task sources** + +```json +[ + { + "id": "tasks", + "label": "Tasks", + "path": "Tasks.md", + "role": "tasks", + "editPolicy": "stay", + "proposals": false + } +] +``` + +**Areas** + +```json +[ + { + "id": "inbox", + "label": "Inbox", + "sourceId": "tasks", + "heading": "Inbox" + } +] +``` + +**Display order** + +```json +["inbox"] +``` + +**Fallback capture destination** + +```json +{ + "sourceId": "tasks", + "heading": "Inbox" +} +``` + +Source paths must be relative to the vault. Taskline rejects absolute paths and paths containing `..`. + +## Capture Tasks + +Run **Taskline: Add task** from the command palette. Taskline recognizes common shorthand: + +```text +Send report by tomorrow !! #work +Plan review next week #planning +Publish notes every Friday @alex +``` + +- `!`, `!!`, and `!!!` set medium, high, and urgent priority. `priority:low` sets low priority. +- Dates such as `today`, `tomorrow`, `next week`, `Jul 5`, and `2026-07-05` set the due date. +- `daily`, `weekly`, and phrases such as `every Friday` set recurrence. +- `#tag` selects a configured route or keeps a generic tag. +- `@owner` records an owner. + +Taskline writes Tasks-compatible signifiers at the end of each task line. + +## Development + +```bash +npm ci +npm test +npm run build +``` + +The production build writes `main.js` at the repository root. + +## Privacy and Security + +Taskline reads and changes only the vault files configured as task sources. It does not send data off-device. See [SECURITY.md](SECURITY.md) to report a vulnerability. + +## License + +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..73713ae --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +## Report a Vulnerability + +Do not open a public issue for a suspected vulnerability. Use GitHub's private vulnerability reporting for this repository instead. + +Include the affected version, impact, reproduction steps, and any suggested mitigation. You can expect an initial response within seven days. + +## Data Access + +Taskline has no server component, telemetry, or network integration. It reads and changes only the vault notes configured as task sources and its own Obsidian plugin settings. diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..2e3b72a --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,67 @@ +import esbuild from "esbuild"; +import process from "process"; + +const builtins = [ + "assert", + "buffer", + "child_process", + "crypto", + "events", + "fs", + "http", + "https", + "module", + "os", + "path", + "process", + "stream", + "url", + "util", + "zlib", +]; + +const banner = +`/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = process.argv[2] === "production"; + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@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", +}); + +if (prod) { + await context.rebuild(); + await context.dispose(); +} else { + await context.watch(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..0c90173 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "vault-tasks", + "name": "Taskline", + "version": "0.1.0", + "minAppVersion": "1.5.0", + "description": "Configurable task capture and Today view for Obsidian", + "author": "Taskline contributors", + "authorUrl": "https://github.com/Savar-G/taskline", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..55c792f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1868 @@ +{ + "name": "taskline", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "taskline", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "esbuild": "0.28.1", + "obsidian": "^1.13.1", + "tslib": "2.6.2", + "typescript": "5.9.3", + "vitest": "^4.1.9" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obsidian": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz", + "integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==", + "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/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true, + "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/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "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==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4ee5ad4 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "taskline", + "version": "0.1.0", + "private": true, + "description": "Configurable task capture and Today view for Obsidian", + "main": "main.js", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Savar-G/taskline.git" + }, + "bugs": { + "url": "https://github.com/Savar-G/taskline/issues" + }, + "homepage": "https://github.com/Savar-G/taskline#readme", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "test": "vitest run" + }, + "keywords": [ + "obsidian", + "plugin", + "tasks", + "productivity" + ], + "devDependencies": { + "esbuild": "0.28.1", + "obsidian": "^1.13.1", + "tslib": "2.6.2", + "typescript": "5.9.3", + "vitest": "^4.1.9" + } +} diff --git a/src/captureRules.ts b/src/captureRules.ts new file mode 100644 index 0000000..9653e0e --- /dev/null +++ b/src/captureRules.ts @@ -0,0 +1,513 @@ +// PURE module - no 'obsidian' import. Deterministic quick-add grammar for the capture bar. +// Everything here is a function of (input string, injected `today`) so it is fully +// unit-testable without a wall clock. + +import type { VtTask } from "./model"; +import { serializeTask } from "./format"; +import type { RuntimeWorkspace } from "./settings"; + +export type CaptureTokenType = "date" | "scheduled" | "priority" | "area" | "recurrence" | "owner"; + +export interface CaptureToken { + type: CaptureTokenType; + start: number; + end: number; +} + +export interface CaptureDestination { + sourceId: string; + heading: string; +} + +export interface ParsedCapture { + title: string; + priority: "p1" | "p2" | "p3" | "p4" | null; + due?: string; + scheduled?: string; + recurrence?: string; + owner?: string; + tags: string[]; + destination: CaptureDestination | null; + explicitRouteMatched: boolean; + tokens: CaptureToken[]; +} + +export function knownCaptureTags(workspace: RuntimeWorkspace): string[] { + const tags: string[] = []; + for (const route of workspace.settings.captureRoutes) { + if (!tags.includes(route.tag)) tags.push(route.tag); + for (const alias of route.aliases) if (!tags.includes(alias)) tags.push(alias); + } + for (const filter of workspace.settings.tagFilters) if (!tags.includes(filter.tag)) tags.push(filter.tag); + return tags; +} + +const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]; +const MONTHS = [ + ["jan", "january"], + ["feb", "february"], + ["mar", "march"], + ["apr", "april"], + ["may"], + ["jun", "june"], + ["jul", "july"], + ["aug", "august"], + ["sep", "sept", "september"], + ["oct", "october"], + ["nov", "november"], + ["dec", "december"], +]; + +function monthIndex(name: string): number { + const n = name.toLowerCase(); + return MONTHS.findIndex((aliases) => aliases.includes(n)); +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +function flatten(arr: string[][]): string[] { + const out: string[] = []; + for (const inner of arr) out.push(...inner); + return out; +} + +function dateOnly(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +function addDays(base: Date, n: number): Date { + const d = dateOnly(base); + d.setDate(d.getDate() + n); + return d; +} + +function fmtDate(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; +} + +interface DateMatch { + start: number; + end: number; + connector: string | null; + date: Date; +} + +function findDateMatch(input: string, today: Date): DateMatch | null { + const connector = "(?:(by|due|on|starting)\\s+)?"; + + // Explicit ISO date. Edit reconstruction uses this lossless form so an overdue task never + // rolls into next year merely because the user opened and saved it after its due date. + { + const re = new RegExp(`\\b${connector}(\\d{4})-(\\d{2})-(\\d{2})\\b`); + const m = input.match(re); + if (m && m.index !== undefined) { + const year = parseInt(m[2], 10); + const month = parseInt(m[3], 10) - 1; + const day = parseInt(m[4], 10); + const date = new Date(year, month, day); + if (date.getFullYear() === year && date.getMonth() === month && date.getDate() === day) { + return { start: m.index, end: m.index + m[0].length, connector: m[1] ?? null, date }; + } + } + } + + // 'in N days' / 'in N weeks' + { + const re = new RegExp(`\\b${connector}in\\s+(\\d+)\\s+(day|days|week|weeks)\\b`, "i"); + const m = input.match(re); + if (m && m.index !== undefined) { + const n = parseInt(m[2], 10); + const isWeeks = /week/i.test(m[3]); + return { start: m.index, end: m.index + m[0].length, connector: m[1] ?? null, date: addDays(today, isWeeks ? n * 7 : n) }; + } + } + + // 'next week' + { + const re = new RegExp(`\\b${connector}next\\s+week\\b`, "i"); + const m = input.match(re); + if (m && m.index !== undefined) { + const todayDow = today.getDay(); + const daysUntil = (1 - todayDow + 7) % 7 || 7; + return { start: m.index, end: m.index + m[0].length, connector: m[1] ?? null, date: addDays(today, daysUntil) }; + } + } + + // 'next ' + { + const re = new RegExp(`\\b${connector}next\\s+(${WEEKDAYS.join("|")})\\b`, "i"); + const m = input.match(re); + if (m && m.index !== undefined) { + const targetDow = WEEKDAYS.indexOf(m[2].toLowerCase()); + const todayDow = today.getDay(); + const daysUntil = (targetDow - todayDow + 7) % 7 || 7; + return { start: m.index, end: m.index + m[0].length, connector: m[1] ?? null, date: addDays(today, daysUntil) }; + } + } + + // plain weekday (next occurrence, including today) + { + const re = new RegExp(`\\b${connector}(${WEEKDAYS.join("|")})\\b`, "i"); + const m = input.match(re); + if (m && m.index !== undefined) { + const targetDow = WEEKDAYS.indexOf(m[2].toLowerCase()); + const todayDow = today.getDay(); + const daysUntil = (targetDow - todayDow + 7) % 7; + return { start: m.index, end: m.index + m[0].length, connector: m[1] ?? null, date: addDays(today, daysUntil) }; + } + } + + // today / tomorrow / tonight + { + const re = new RegExp(`\\b${connector}(today|tomorrow|tonight)\\b`, "i"); + const m = input.match(re); + if (m && m.index !== undefined) { + const word = m[2].toLowerCase(); + const offset = word === "tomorrow" ? 1 : 0; + return { start: m.index, end: m.index + m[0].length, connector: m[1] ?? null, date: addDays(today, offset) }; + } + } + + // month day: 'Jul 4' / 'July 4' + { + const monthAlt = flatten(MONTHS).join("|"); + const re = new RegExp(`\\b${connector}(${monthAlt})\\s+(\\d{1,2})\\b`, "i"); + const m = input.match(re); + if (m && m.index !== undefined) { + const mi = monthIndex(m[2]); + const day = parseInt(m[3], 10); + let d = new Date(today.getFullYear(), mi, day); + if (dateOnly(d) < dateOnly(today)) d = new Date(today.getFullYear() + 1, mi, day); + return { start: m.index, end: m.index + m[0].length, connector: m[1] ?? null, date: d }; + } + } + + // day month: '4 July' + { + const monthAlt = flatten(MONTHS).join("|"); + const re = new RegExp(`\\b${connector}(\\d{1,2})\\s+(${monthAlt})\\b`, "i"); + const m = input.match(re); + if (m && m.index !== undefined) { + const mi = monthIndex(m[3]); + const day = parseInt(m[2], 10); + let d = new Date(today.getFullYear(), mi, day); + if (dateOnly(d) < dateOnly(today)) d = new Date(today.getFullYear() + 1, mi, day); + return { start: m.index, end: m.index + m[0].length, connector: m[1] ?? null, date: d }; + } + } + + // numeric M/D + { + const re = new RegExp(`\\b${connector}(\\d{1,2})\\/(\\d{1,2})\\b`); + const m = input.match(re); + if (m && m.index !== undefined) { + const mi = parseInt(m[2], 10) - 1; + const day = parseInt(m[3], 10); + let d = new Date(today.getFullYear(), mi, day); + if (dateOnly(d) < dateOnly(today)) d = new Date(today.getFullYear() + 1, mi, day); + return { start: m.index, end: m.index + m[0].length, connector: m[1] ?? null, date: d }; + } + } + + return null; +} + +function blank(masked: string, start: number, end: number): string { + return masked.slice(0, start) + " ".repeat(end - start) + masked.slice(end); +} + +interface ProtectedTitle { + value: string; + end: number; +} + +/** Edit strings begin with a JSON string literal. That creates a real syntax boundary around the + * title, so words such as "Monday", "due Aug 21", "daily", or "#school" cannot be consumed as + * metadata merely because an unchanged task was opened and saved. */ +function parseProtectedTitle(input: string): ProtectedTitle | null { + if (!input.startsWith('"')) return null; + + let escaped = false; + for (let i = 1; i < input.length; i++) { + const char = input[i]; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char !== '"') continue; + if (i + 1 < input.length && !/\s/.test(input[i + 1])) return null; + try { + const value = JSON.parse(input.slice(0, i + 1)); + return typeof value === "string" ? { value, end: i + 1 } : null; + } catch { + return null; + } + } + return null; +} + +export function parseCapture(input: string, today: Date, workspace: RuntimeWorkspace): ParsedCapture { + const tokens: CaptureToken[] = []; + const removeSpans: Array<{ start: number; end: number }> = []; + // `masked` tracks already-consumed spans (blanked to same-length whitespace) so later + // passes never re-match characters a prior pass already claimed (e.g. the 'Sunday' inside + // an already-consumed 'every Sunday' recurrence phrase must not also parse as a weekday). + const protectedTitle = parseProtectedTitle(input); + let masked = protectedTitle ? blank(input, 0, protectedTitle.end) : input; + + let priority: "p1" | "p2" | "p3" | "p4" | null = null; + { + const named = masked.match(/(?:^|\s)priority:(urgent|high|medium|low)(?=\s|$)/i); + if (named && named.index !== undefined) { + const level = named[1].toLowerCase(); + priority = level === "urgent" ? "p1" : level === "high" ? "p2" : level === "medium" ? "p3" : "p4"; + const leadingSpace = named[0].match(/^\s*/)?.[0].length ?? 0; + const start = named.index + leadingSpace; + const end = named.index + named[0].length; + tokens.push({ type: "priority", start, end }); + removeSpans.push({ start, end }); + masked = blank(masked, start, end); + } else { + const m = masked.match(/(?:^|\s)(!{1,3})(?=\s|$)/); + if (m && m.index !== undefined) { + const bangs = m[1]; + priority = bangs.length === 3 ? "p1" : bangs.length === 2 ? "p2" : "p3"; + const start = m.index + (m[0].length - bangs.length); + const end = start + bangs.length; + tokens.push({ type: "priority", start, end }); + removeSpans.push({ start, end }); + masked = blank(masked, start, end); + } + } + } + + let recurrence: string | undefined; + { + // Stop at #/!/@ and at date connectors (by/due/starting) so 'every Sunday by Jul 5' + // keeps 'by Jul 5' available for the date pass. 'on' stays inside the phrase + // ('every week on Sunday' is one recurrence). + const re = /\bevery\s+[^#!@]+?(?=\s*(?:#|!|@|\b(?:by|due|starting)\b|$))/i; + const m = masked.match(re); + if (m && m.index !== undefined) { + recurrence = m[0].trim(); + const start = m.index; + const end = m.index + m[0].length; + tokens.push({ type: "recurrence", start, end }); + removeSpans.push({ start, end }); + masked = blank(masked, start, end); + } else { + const dailyM = masked.match(/\bdaily\b/i); + const weeklyM = masked.match(/\bweekly\b/i); + if (dailyM && dailyM.index !== undefined) { + recurrence = "every day"; + tokens.push({ type: "recurrence", start: dailyM.index, end: dailyM.index + dailyM[0].length }); + removeSpans.push({ start: dailyM.index, end: dailyM.index + dailyM[0].length }); + masked = blank(masked, dailyM.index, dailyM.index + dailyM[0].length); + } else if (weeklyM && weeklyM.index !== undefined) { + recurrence = "every week"; + tokens.push({ type: "recurrence", start: weeklyM.index, end: weeklyM.index + weeklyM[0].length }); + removeSpans.push({ start: weeklyM.index, end: weeklyM.index + weeklyM[0].length }); + masked = blank(masked, weeklyM.index, weeklyM.index + weeklyM[0].length); + } + } + } + + let due: string | undefined; + let scheduled: string | undefined; + { + const dm = findDateMatch(masked, today); + if (dm) { + const end = dm.end; + const dateStr = fmtDate(dm.date); + const isScheduled = dm.connector?.toLowerCase() === "starting"; + if (isScheduled) scheduled = dateStr; + else due = dateStr; + tokens.push({ type: isScheduled ? "scheduled" : "date", start: dm.start, end }); + removeSpans.push({ start: dm.start, end }); + masked = blank(masked, dm.start, end); + } + } + + // Recurrence needs an anchor date for the Tasks plugin to spawn the next occurrence + // (same rule as the vault filing skill): default to due today when none was given. + if (recurrence && !due && !scheduled) { + due = fmtDate(today); + } + + const tags: string[] = []; + let destination: CaptureDestination | null = null; + let explicitRouteMatched = false; + { + const re = /#([\w/-]+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(masked))) { + const tag = m[1].toLowerCase(); + tags.push(tag); + tokens.push({ type: "area", start: m.index, end: m.index + m[0].length }); + removeSpans.push({ start: m.index, end: m.index + m[0].length }); + if (!destination) { + const route = workspace.routeByTag.get(tag); + if (route) { + destination = route.destination; + explicitRouteMatched = true; + } + } + } + masked = masked.replace(/#([\w/-]+)/g, (whole) => " ".repeat(whole.length)); + } + + let owner: string | undefined; + { + const re = /@([\w.-]+)/g; + const m = re.exec(masked); + if (m) { + owner = m[1]; + tokens.push({ type: "owner", start: m.index, end: m.index + m[0].length }); + removeSpans.push({ start: m.index, end: m.index + m[0].length }); + } + } + + let title: string; + if (protectedTitle) { + title = protectedTitle.value; + } else { + removeSpans.sort((a, b) => b.start - a.start); + title = input; + for (const span of removeSpans) { + title = title.slice(0, span.start) + title.slice(span.end); + } + title = title.replace(/\s+/g, " ").trim(); + } + + tokens.sort((a, b) => a.start - b.start); + + return { + title, + priority, + due, + scheduled, + recurrence, + owner, + tags, + destination: destination ?? workspace.settings.fallbackCaptureDestination, + explicitRouteMatched, + tokens, + }; +} + +/** PURE inverse of parseCapture, good enough to seed the edit modal: rebuilds a capture-grammar + * string from a task so `parseCapture(taskToCaptureString(t))` reproduces the task's title, + * areas, priority, due, scheduled and recurrence. + * + * A task carrying both dates represents the due date in the grammar; the edit writer merges the + * untouched scheduled date back before serialization. The title is emitted as a JSON string literal, + * creating a protected parse boundary; dates use explicit ISO phrases so overdue tasks cannot + * roll into the next year. */ +export function taskToCaptureString(task: VtTask): string { + const parts: string[] = []; + parts.push(JSON.stringify(task.title)); + for (const tag of task.tags) parts.push(`#${tag}`); + if (task.owner) parts.push(`@${task.owner}`); + + if (task.recurrence) { + const rec = task.recurrence.trim(); + parts.push(rec.startsWith("every") ? rec : `every ${rec}`); + } + + const datePhrase = (iso: string, connector: string): string => { + return `${connector} ${iso.split("T")[0]}`; + }; + if (task.due) parts.push(datePhrase(task.due, "by")); + else if (task.scheduled) parts.push(datePhrase(task.scheduled, "starting")); + + if (task.priority === "p1") parts.push("!!!"); + else if (task.priority === "p2") parts.push("!!"); + else if (task.priority === "p3") parts.push("!"); + else if (task.priority === "p4") parts.push("priority:low"); + + return parts.join(" "); +} + +/** Resolves an edited task's destination. Stay-policy sources remain pinned to their current + * source and heading; route-policy sources follow capture routing. */ +export function resolveEditDestination( + task: VtTask, + parsed: ParsedCapture, + workspace: RuntimeWorkspace +): CaptureDestination { + const source = workspace.sourceById.get(task.sourceId); + if (!source || source.editPolicy === "stay") { + return { sourceId: task.sourceId, heading: task.heading }; + } + return parsed.explicitRouteMatched && parsed.destination + ? parsed.destination + : { sourceId: task.sourceId, heading: task.heading }; +} + +/** The capture grammar intentionally represents one date. Preserve the original task's other + * date during edits so changing or leaving the represented date cannot erase information. */ +export function mergeEditDates(task: VtTask, parsed: ParsedCapture): Pick { + if (task.due && task.scheduled) { + return { due: parsed.due, scheduled: parsed.scheduled ?? task.scheduled }; + } + return { due: parsed.due, scheduled: parsed.scheduled }; +} + +/** Serializes exactly the metadata represented by the capture preview. All source roles use this + * path; an inbox is a destination, not a request to persist the user's raw grammar. */ +export function serializeCapturedTask(parsed: ParsedCapture, capturedOn: string): string { + return serializeTask({ + sourceId: parsed.destination?.sourceId ?? "", + filePath: "", + lineNo: 0, + rawLine: "", + status: "todo", + statusChar: " ", + title: parsed.title, + tags: parsed.tags, + priority: parsed.priority, + due: parsed.due, + scheduled: parsed.scheduled, + recurrence: parsed.recurrence, + owner: parsed.owner, + provenance: { kind: "inbox", date: capturedOn }, + heading: "", + subNotes: [], + }); +} + +// ---- deterministic keyword -> area suggestion (DESIGN §9.6) ----------------- + +export interface AreaSuggestion { + tag: string; + matchedWord: string; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Deterministic, confirm-to-apply area suggestion for an untagged capture. Word-boundary, + * case-insensitive matching against AREA_SUGGESTION_TABLE; the first alias hit (table order, + * then word order within the row) wins. Never suggests once the input already carries any + * '#area' tag - the user has already made an explicit routing choice, so a keyword guess would + * be noise, not help. Pure and side-effect free: the caller (capture modal) decides whether and + * how to surface it, and nothing here ever auto-applies a tag. */ +export function suggestArea(input: string, workspace: RuntimeWorkspace): AreaSuggestion | null { + if (/#([\w/-]+)/.test(input)) return null; + + for (const entry of workspace.settings.captureRoutes) { + for (const word of entry.keywords) { + const re = new RegExp(`\\b${escapeRegExp(word)}\\b`, "i"); + const m = input.match(re); + if (m) return { tag: entry.tag, matchedWord: m[0] }; + } + } + return null; +} diff --git a/src/editProperties.ts b/src/editProperties.ts new file mode 100644 index 0000000..2fa9500 --- /dev/null +++ b/src/editProperties.ts @@ -0,0 +1,76 @@ +import { parseCapture } from "./captureRules"; +import type { RuntimeWorkspace } from "./settings"; + +export type EditablePriority = "p1" | "p2" | "p3" | "p4" | null; + +export interface QuickDate { + id: "today" | "tomorrow" | "next-week" | "next-weekend"; + label: string; + iso: string; +} + +function dateOnly(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function addDays(date: Date, days: number): Date { + const next = dateOnly(date); + next.setDate(next.getDate() + days); + return next; +} + +export function localIso(date: Date): string { + const pad = (value: number) => (value < 10 ? `0${value}` : String(value)); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +export function parseLocalIso(iso: string): Date { + const [year, month, day] = iso.split("-").map(Number); + return new Date(year, month - 1, day); +} + +export function quickDates(today: Date): QuickDate[] { + const current = dateOnly(today); + const daysUntilMonday = (1 - current.getDay() + 7) % 7 || 7; + const nextMonday = addDays(current, daysUntilMonday); + return [ + { id: "today", label: "Today", iso: localIso(current) }, + { id: "tomorrow", label: "Tomorrow", iso: localIso(addDays(current, 1)) }, + { id: "next-week", label: "Next week", iso: localIso(nextMonday) }, + { id: "next-weekend", label: "Next weekend", iso: localIso(addDays(nextMonday, 5)) }, + ]; +} + +function replaceSpan(input: string, start: number, end: number, replacement: string): string { + return `${input.slice(0, start)}${replacement}${input.slice(end)}`.replace(/\s+/g, " ").trim(); +} + +export function setCaptureDate(input: string, iso: string | null, today: Date, workspace: RuntimeWorkspace): string { + const parsed = parseCapture(input, today, workspace); + const token = parsed.tokens.find((item) => item.type === "date" || item.type === "scheduled"); + if (!iso) return token ? replaceSpan(input, token.start, token.end, "") : input.trim(); + + const phrase = `${token?.type === "scheduled" ? "starting" : "by"} ${iso}`; + return token ? replaceSpan(input, token.start, token.end, phrase) : `${input.trim()} ${phrase}`.trim(); +} + +const PRIORITY_GRAMMAR: Record, string> = { + p1: "priority:urgent", + p2: "priority:high", + p3: "priority:medium", + p4: "priority:low", +}; + +export function setCapturePriority(input: string, priority: EditablePriority, today: Date, workspace: RuntimeWorkspace): string { + const parsed = parseCapture(input, today, workspace); + const token = parsed.tokens.find((item) => item.type === "priority"); + const grammar = priority ? PRIORITY_GRAMMAR[priority] : ""; + if (token) return replaceSpan(input, token.start, token.end, grammar); + return grammar ? `${input.trim()} ${grammar}`.trim() : input.trim(); +} + +export function calendarDates(month: Date): Date[] { + const first = new Date(month.getFullYear(), month.getMonth(), 1); + const start = addDays(first, -first.getDay()); + return Array.from({ length: 42 }, (_, index) => addDays(start, index)); +} diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..4f68598 --- /dev/null +++ b/src/format.ts @@ -0,0 +1,121 @@ +// PURE module - no 'obsidian' import. The single write-formatter: every serializer in the +// plugin funnels through here so the Tasks-plugin trailing-signifier-run invariant is +// enforced in exactly one place. +// +// INVARIANT (has bitten this system twice): the Tasks plugin parses signifiers backwards +// from line-end, so all signifier emoji must form one contiguous run at the END of the +// line; annotation text ((from ...), owner, stale flags) must sit BEFORE that run. + +import { VtProvenance, VtStale, VtTask } from "./model"; + +const PRIORITY_TO_EMOJI: Record = { + p1: "🔺", + p2: "⏫", + p3: "🔼", + p4: "🔽", +}; + +function provenanceToText(p: VtProvenance): string { + switch (p.kind) { + case "inbox": + return `(from inbox ${p.date})`; + case "link": + return `(from ${p.source})`; + case "reconciled": + return `(reconciled from ${p.source})`; + case "added-by-reconcile": + return `(added by reconcile ${p.date} from ${p.source})`; + default: + return ""; + } +} + +function staleToText(s: VtStale): string { + return s.level === "alert" ? `🔴 stale ${s.days}d (escalate)` : `🟡 stale ${s.days}d`; +} + +/** Serializes a VtTask back into a single Tasks-plugin-compliant line. */ +export function serializeTask(task: VtTask): string { + const bodyParts: string[] = []; + if (task.title) bodyParts.push(task.title); + for (const tag of task.tags) bodyParts.push(`#${tag}`); + if (task.provenance) { + const text = provenanceToText(task.provenance); + if (text) bodyParts.push(text); + } + if (task.owner) bodyParts.push(`— @${task.owner}`); + if (task.stale) bodyParts.push(staleToText(task.stale)); + + // Taskline is date-only. Parsing and writing deliberately use Tasks-compatible YYYY-MM-DD. + const signifiers: string[] = []; + if (task.priority) signifiers.push(PRIORITY_TO_EMOJI[task.priority]); + if (task.recurrence) signifiers.push(`🔁 ${task.recurrence}`); + if (task.scheduled) signifiers.push(`⏳ ${task.scheduled.split("T")[0]}`); + if (task.due) signifiers.push(`📅 ${task.due.split("T")[0]}`); + if (task.doneDate) signifiers.push(`✅ ${task.doneDate}`); + if (task.cancelledDate) signifiers.push(`❌ ${task.cancelledDate}`); + + const body = [bodyParts.join(" "), signifiers.join(" ")].filter(Boolean).join(" "); + return `${task.indent ?? ""}- [${task.statusChar}] ${body}`.replace(/\s+$/, ""); +} + +const WEEKDAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTH_SHORT = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +function parseIsoDate(iso: string): Date { + const [datePart] = iso.split("T"); + const [y, m, d] = datePart.split("-").map((n) => parseInt(n, 10)); + return new Date(y, m - 1, d); +} + +/** PURE. Human-facing calendar-relative label for a task date, per DESIGN §3.2: + * Today / Tomorrow / Yesterday / short weekday within a week / else "Mon D". + * Any legacy time component is ignored because Taskline storage is date-only. */ +export function relativeDateLabel(iso: string, today: Date): string { + const date = parseIsoDate(iso); + const todayMid = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const diffDays = Math.round((date.getTime() - todayMid.getTime()) / 86400000); + + let label: string; + if (diffDays === 0) label = "Today"; + else if (diffDays === 1) label = "Tomorrow"; + else if (diffDays === -1) label = "Yesterday"; + else if (diffDays > 1 && diffDays < 7) label = WEEKDAY_SHORT[date.getDay()]; + else { + label = `${MONTH_SHORT[date.getMonth()]} ${date.getDate()}`; + if (date.getFullYear() !== todayMid.getFullYear()) label += `, ${date.getFullYear()}`; + } + + return label; +} + +/** Replaces the checkbox status char in a raw task line, e.g. ' ' -> 'x'. Tolerant: returns + * the line unchanged if it doesn't look like a checkbox line. */ +export function setStatusChar(rawLine: string, char: string): string { + return rawLine.replace(/^(\s*-\s*\[)(.)(\])/, (_whole, a: string, _b: string, c: string) => `${a}${char}${c}`); +} + +const SIGNIFIER_TOKEN = + "🔺|⏫|🔼|🔽" + + "|🔁\\s*[^🔺⏫🔼🔽📅⏳✅❌]*" + + "|⏳\\s*\\d{4}-\\d{2}-\\d{2}(?:T\\d{2}:\\d{2})?" + + "|📅\\s*\\d{4}-\\d{2}-\\d{2}(?:T\\d{2}:\\d{2})?" + + "|✅\\s*\\d{4}-\\d{2}-\\d{2}" + + "|❌\\s*\\d{4}-\\d{2}-\\d{2}"; + +const TRAILING_RUN_RE = new RegExp(`(?:\\s*(?:${SIGNIFIER_TOKEN}))+$`); + +/** Inserts annotation text immediately before the trailing signifier run (or at the end of + * the line if there is no such run), preserving the invariant. */ +export function insertAnnotation(rawLine: string, text: string): string { + const m = rawLine.match(TRAILING_RUN_RE); + if (!m || m.index === undefined) { + return `${rawLine.replace(/\s+$/, "")} ${text}`; + } + const before = rawLine.slice(0, m.index).replace(/\s+$/, ""); + const after = m[0]; + return `${before} ${text}${after}`; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..ce8655b --- /dev/null +++ b/src/main.ts @@ -0,0 +1,183 @@ +import { Notice, Plugin, WorkspaceLeaf } from "obsidian"; +import { TodayView, VIEW_TYPE_TODAY, VtTabId } from "./todayView"; +import { VtStore } from "./store"; +import { VtTask } from "./model"; +import { CaptureModal } from "./ui/captureModal"; +import { VtEditModal } from "./ui/editModal"; +import { compileWorkspace, DEFAULT_SETTINGS, RuntimeWorkspace, SettingsIssue, TasklineSettings } from "./settings"; +import { TasklineSettingTab } from "./settingsTab"; + +export default class TasklinePlugin extends Plugin { + settings: TasklineSettings = DEFAULT_SETTINGS; + workspace: RuntimeWorkspace = compileWorkspace(DEFAULT_SETTINGS); + store: VtStore | null = null; + settingsLoadIssues: SettingsIssue[] = []; + rejectedSettingsRaw: unknown = null; + private initializingStore: VtStore | null = null; + private storeReadyCbs: Set<() => void> = new Set(); + private settingsCbs: Set<() => void> = new Set(); + private loaded = false; + private storeGeneration = 0; + private settingsWrite: Promise = Promise.resolve(); + + activeTab: VtTabId = "today"; + activeFilters: Set = new Set(); + expandedGroups: Set = new Set(); + + async onload(): Promise { + this.loaded = true; + const rawSettings = await this.loadData(); + const loadedWorkspace = compileWorkspace(rawSettings); + this.settingsLoadIssues = loadedWorkspace.issues.filter((issue) => issue.level === "error"); + this.rejectedSettingsRaw = this.settingsLoadIssues.length > 0 ? rawSettings : null; + this.workspace = loadedWorkspace.issues.some((issue) => issue.level === "error") + ? compileWorkspace(DEFAULT_SETTINGS) + : loadedWorkspace; + this.settings = this.workspace.settings; + this.addSettingTab(new TasklineSettingTab(this)); + + this.registerView(VIEW_TYPE_TODAY, (leaf: WorkspaceLeaf) => new TodayView(leaf, this)); + this.addRibbonIcon("check-circle", "Open Taskline Today", () => void this.openTodayView()); + this.addCommand({ id: "open-today", name: "Taskline: Open Today", callback: () => void this.openTodayView() }); + this.addCommand({ id: "add-task", name: "Taskline: Add task", callback: () => this.openCapture() }); + + this.app.workspace.onLayoutReady(() => { + if (!this.loaded) return; + void this.initializeStore(); + }); + } + + onunload(): void { + this.loaded = false; + this.store?.dispose(); + this.initializingStore?.dispose(); + this.store = null; + this.initializingStore = null; + this.storeReadyCbs.clear(); + this.settingsCbs.clear(); + } + + async updateSettings(candidate: unknown): Promise { + const operation = this.settingsWrite.then(() => this.applySettings(candidate)); + this.settingsWrite = operation.catch(() => {}); + return operation; + } + + private async applySettings(candidateSettings: unknown): Promise { + const compiled = compileWorkspace(candidateSettings); + const errors = compiled.issues.filter((issue) => issue.level === "error"); + if (errors.length > 0) throw new Error(`Invalid Taskline settings: ${errors[0].path} - ${errors[0].message}`); + + const generation = ++this.storeGeneration; + this.initializingStore?.dispose(); + const candidate = compiled.configured ? new VtStore(this.app, compiled) : null; + this.initializingStore = candidate; + try { + if (candidate) await candidate.init(); + if (!this.loaded || generation !== this.storeGeneration) { + candidate?.dispose(); + return; + } + await this.saveData(compiled.settings); + } catch (error) { + candidate?.dispose(); + if (this.initializingStore === candidate) this.initializingStore = null; + throw error; + } + + const old = this.store; + this.settings = compiled.settings; + this.workspace = compiled; + this.settingsLoadIssues = []; + this.rejectedSettingsRaw = null; + this.store = candidate; + this.initializingStore = null; + old?.dispose(); + this.flushStoreReady(); + for (const callback of this.settingsCbs) callback(); + } + + private async initializeStore(): Promise { + const generation = ++this.storeGeneration; + this.initializingStore?.dispose(); + if (!this.workspace.configured) { + const old = this.store; + this.store = null; + old?.dispose(); + this.flushStoreReady(); + return; + } + + const candidate = new VtStore(this.app, this.workspace); + this.initializingStore = candidate; + try { + await candidate.init(); + } catch (error) { + candidate.dispose(); + if (this.initializingStore === candidate) this.initializingStore = null; + console.error("taskline: store initialization failed", error); + return; + } + if (!this.loaded || generation !== this.storeGeneration || this.initializingStore !== candidate) { + candidate.dispose(); + return; + } + const old = this.store; + this.store = candidate; + this.initializingStore = null; + old?.dispose(); + this.flushStoreReady(); + } + + private flushStoreReady(): void { + const callbacks = [...this.storeReadyCbs]; + this.storeReadyCbs.clear(); + for (const cb of callbacks) cb(); + } + + whenStoreReady(cb: () => void): () => void { + if (this.store || !this.workspace.configured) { + window.setTimeout(cb, 0); + return () => {}; + } + this.storeReadyCbs.add(cb); + return () => this.storeReadyCbs.delete(cb); + } + + onSettingsChange(cb: () => void): () => void { + this.settingsCbs.add(cb); + return () => this.settingsCbs.delete(cb); + } + + openSettings(): void { + const setting = (this.app as typeof this.app & { + setting?: { open(): void; openTabById(id: string): void }; + }).setting; + setting?.open(); + setting?.openTabById(this.manifest.id); + } + + openCapture(): void { + if (!this.workspace.configured) { + new Notice("Configure task sources in Taskline settings first."); + this.openSettings(); + return; + } + new CaptureModal(this.app, this).open(); + } + + openEdit(task: VtTask): void { + new VtEditModal(this.app, this, task).open(); + } + + async openTodayView(): Promise { + const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_TODAY)[0]; + if (existingLeaf) { + this.app.workspace.revealLeaf(existingLeaf); + return; + } + const leaf = this.app.workspace.getLeaf("tab"); + await leaf.setViewState({ type: VIEW_TYPE_TODAY, active: true }); + this.app.workspace.revealLeaf(leaf); + } +} diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..ca8e759 --- /dev/null +++ b/src/model.ts @@ -0,0 +1,95 @@ +// Pure data model types shared across the plugin. No imports from 'obsidian' here - +// this module (and the other pure modules that depend on it) must be unit-testable in node. + +export type VtStatus = + | "todo" + | "in-progress" + | "blocked" + | "planning" + | "done" + | "cancelled"; + +export type VtPriority = "p1" | "p2" | "p3" | "p4" | null; + +export interface VtProvenance { + kind: string; + date?: string; + source?: string; +} + +export interface VtStale { + level: "warn" | "alert"; + days: number; +} + +export interface VtTask { + sourceId: string; + filePath: string; + lineNo: number; + rawLine: string; + indent?: string; + status: VtStatus; + statusChar: string; + title: string; + tags: string[]; + priority: VtPriority; + due?: string; + scheduled?: string; + doneDate?: string; + cancelledDate?: string; + recurrence?: string; + provenance?: VtProvenance; + owner?: string; + stale?: VtStale; + heading: string; + subNotes: string[]; +} + +export interface VtProposal { + sourceId: string; + filePath: string; + lineNo: number; + rawLine: string; + action: "complete" | "cancel"; + text: string; + evidence?: string; + source?: string; +} + +// Maps the Tasks-plugin status char to our VtStatus enum. Unknown chars fall back to 'todo'. +export function statusCharToStatus(char: string): VtStatus { + switch (char) { + case " ": + return "todo"; + case "/": + return "in-progress"; + case "!": + return "blocked"; + case "?": + return "planning"; + case "x": + case "X": + return "done"; + case "-": + return "cancelled"; + default: + return "todo"; + } +} + +export function statusToStatusChar(status: VtStatus): string { + switch (status) { + case "todo": + return " "; + case "in-progress": + return "/"; + case "blocked": + return "!"; + case "planning": + return "?"; + case "done": + return "x"; + case "cancelled": + return "-"; + } +} diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..04a488f --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,248 @@ +// PURE module - no 'obsidian' import. Parses Tasks-plugin emoji-format lines out of the +// configured task sources. Tolerant of malformed input: never throws, returns null +// instead when a line doesn't look like a task/proposal. + +import { + VtProvenance, + VtProposal, + VtStale, + VtTask, + statusCharToStatus, +} from "./model"; +import type { RuntimeWorkspace, TaskSourceSetting } from "./settings"; + +export interface ParseCtx { + sourceId: string; + filePath: string; + lineNo: number; + heading: string; +} + +const PRIORITY_EMOJI: Record = { + "🔺": "p1", + "⏫": "p2", + "🔼": "p3", + "🔽": "p4", +}; + +const SIGNIFIER_CLASS = "🔺⏫🔼🔽📅⏳✅❌"; + +function parseProvenanceText(text: string): VtProvenance { + let m: RegExpMatchArray | null; + if ((m = text.match(/^added by reconcile (\S+) from (.+)$/))) { + return { kind: "added-by-reconcile", date: m[1], source: m[2].trim() }; + } + if ((m = text.match(/^reconciled from (.+)$/))) { + return { kind: "reconciled", source: m[1].trim() }; + } + if ((m = text.match(/^from inbox (\d{4}-\d{2}-\d{2})$/))) { + return { kind: "inbox", date: m[1] }; + } + if ((m = text.match(/^from (.+)$/))) { + return { kind: "link", source: m[1].trim() }; + } + return { kind: "unknown" }; +} + +export function parseTaskLine(line: string, ctx: ParseCtx): VtTask | null { + if (typeof line !== "string") return null; + const m = line.match(/^(\s*)-\s*\[(.)\]\s?(.*)$/); + if (!m) return null; + + const statusChar = m[2]; + let work = m[3] ?? ""; + + // priority + let priority: VtTask["priority"] = null; + const prMatch = work.match(/🔺|⏫|🔼|🔽/); + if (prMatch) { + priority = PRIORITY_EMOJI[prMatch[0]]; + work = work.replace(prMatch[0], ""); + } + + // done date + let doneDate: string | undefined; + const doneMatch = work.match(/✅\s*(\d{4}-\d{2}-\d{2})/); + if (doneMatch) { + doneDate = doneMatch[1]; + work = work.replace(doneMatch[0], ""); + } + + // cancelled date + let cancelledDate: string | undefined; + const cancelMatch = work.match(/❌\s*(\d{4}-\d{2}-\d{2})/); + if (cancelMatch) { + cancelledDate = cancelMatch[1]; + work = work.replace(cancelMatch[0], ""); + } + + // scheduled + let scheduled: string | undefined; + const schedMatch = work.match(/⏳\s*(\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2})?)/); + if (schedMatch) { + scheduled = schedMatch[1].split("T")[0]; + work = work.replace(schedMatch[0], ""); + } + + // due + let due: string | undefined; + const dueMatch = work.match(/📅\s*(\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2})?)/); + if (dueMatch) { + due = dueMatch[1].split("T")[0]; + work = work.replace(dueMatch[0], ""); + } + + // recurrence - everything after 🔁 up to the next signifier emoji or end of string + let recurrence: string | undefined; + const recRe = new RegExp(`🔁\\s*([^${SIGNIFIER_CLASS}]*)`); + const recMatch = work.match(recRe); + if (recMatch) { + recurrence = recMatch[1].trim(); + work = work.replace(recMatch[0], ""); + } + + // stale flag + let stale: VtStale | undefined; + const staleMatch = work.match(/(🟡|🔴)\s*stale\s*(\d+)d(\s*\(escalate\))?/); + if (staleMatch) { + stale = { level: staleMatch[1] === "🔴" ? "alert" : "warn", days: parseInt(staleMatch[2], 10) }; + work = work.replace(staleMatch[0], ""); + } + + // owner - '— @Name' + let owner: string | undefined; + const ownerMatch = work.match(/—\s*@([\w.-]+)/); + if (ownerMatch) { + owner = ownerMatch[1]; + work = work.replace(ownerMatch[0], ""); + } + + // provenance - scan top-level paren groups; first "from"/"reconciled from"/"added by + // reconcile" group found is kept, all matching groups are stripped from the title. + let provenance: VtProvenance | undefined; + work = work.replace(/\(([^()]*)\)/g, (whole, inner: string) => { + const trimmed = inner.trim(); + if (/^(from|reconciled from|added by reconcile)\b/.test(trimmed)) { + if (!provenance) provenance = parseProvenanceText(trimmed); + return ""; + } + return whole; + }); + + // tags + const tags: string[] = []; + work = work.replace(/#([\w/-]+)/g, (_whole, tag: string) => { + tags.push(tag.toLowerCase()); + return ""; + }); + + const title = work.replace(/\s+/g, " ").trim(); + + return { + sourceId: ctx.sourceId, + filePath: ctx.filePath, + lineNo: ctx.lineNo, + rawLine: line, + indent: m[1], + status: statusCharToStatus(statusChar), + statusChar, + title, + tags, + priority, + due, + scheduled, + doneDate, + cancelledDate, + recurrence, + provenance, + owner, + stale, + heading: ctx.heading, + subNotes: [], + }; +} + +export function parseProposalLine(line: string, ctx: ParseCtx): VtProposal | null { + if (typeof line !== "string") return null; + const m = line.match(/^\s*-\s*\(propose\s+(done|complete|cancel)\)\s*(.*)$/i); + if (!m) return null; + + const action = m[1].toLowerCase() === "cancel" ? "cancel" : "complete"; + let rest = m[2]; + let evidence: string | undefined; + let source: string | undefined; + + const evMatch = rest.match(/^(.*?)\s*-\s*evidence:\s*"([^"]*)"\s*(\[\[[^\]]*\]\])?\s*$/); + if (evMatch) { + rest = evMatch[1]; + evidence = evMatch[2]; + source = evMatch[3] ? evMatch[3].slice(2, -2) : undefined; + } + + return { + sourceId: ctx.sourceId, + filePath: ctx.filePath, + lineNo: ctx.lineNo, + rawLine: line, + action, + text: rest.trim(), + evidence, + source, + }; +} + +export interface ParsedTrackerFile { + tasks: VtTask[]; + proposals: VtProposal[]; +} + +export function parseTrackerFile( + content: string, + source: TaskSourceSetting, + _workspace?: RuntimeWorkspace +): ParsedTrackerFile { + const tasks: VtTask[] = []; + const proposals: VtProposal[] = []; + + if (typeof content !== "string") return { tasks, proposals }; + + const lines = content.split(/\r?\n/); + let heading = ""; + let lastTask: VtTask | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNo = i + 1; + + const headingMatch = line.match(/^##\s+(.+?)\s*$/); + if (headingMatch) { + heading = headingMatch[1]; + lastTask = null; + continue; + } + + const task = parseTaskLine(line, { sourceId: source.id, filePath: source.path, lineNo, heading }); + if (task) { + tasks.push(task); + lastTask = task; + continue; + } + + const proposal = source.proposals + ? parseProposalLine(line, { sourceId: source.id, filePath: source.path, lineNo, heading }) + : null; + if (proposal) { + proposals.push(proposal); + lastTask = null; + continue; + } + + const subNoteMatch = line.match(/^\s+-\s*📝\s*(.*)$/); + if (subNoteMatch && lastTask) { + lastTask.subNotes.push(subNoteMatch[1].trim()); + continue; + } + } + + return { tasks, proposals }; +} diff --git a/src/proposals.ts b/src/proposals.ts new file mode 100644 index 0000000..28bdd3d --- /dev/null +++ b/src/proposals.ts @@ -0,0 +1,18 @@ +import type { VtProposal, VtTask } from "./model"; + +export function normalizeTaskIdentity(value: string): string { + return value.trim().replace(/\s+/g, " ").toLowerCase(); +} + +export function findProposalTarget(proposal: VtProposal, tasks: VtTask[]): VtTask | null { + const identity = normalizeTaskIdentity(proposal.text); + if (!identity) return null; + const matches = tasks.filter((task) => ( + task.status !== "done" + && task.status !== "cancelled" + && normalizeTaskIdentity(task.title) === identity + )); + const localMatches = matches.filter((task) => task.filePath === proposal.filePath); + if (localMatches.length > 0) return localMatches.length === 1 ? localMatches[0] : null; + return matches.length === 1 ? matches[0] : null; +} diff --git a/src/query.ts b/src/query.ts new file mode 100644 index 0000000..ce270d1 --- /dev/null +++ b/src/query.ts @@ -0,0 +1,158 @@ +import { VtTask } from "./model"; +import type { RuntimeWorkspace, TaskSourceSetting } from "./settings"; + +export interface DayGroup { + date: Date; + tasks: VtTask[]; +} + +export interface AreaGroup { + key: string; + label: string; + tasks: VtTask[]; + mode: "flat" | "by-heading"; + color?: string; +} + +export interface CompletedResult { + tasks: VtTask[]; + truncated: boolean; +} + +function isOpen(t: VtTask): boolean { + return t.status !== "done" && t.status !== "cancelled"; +} + +function dateOnly(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +function isoToMid(iso: string): Date { + const [y, m, d] = iso.split("T")[0].split("-").map((n) => parseInt(n, 10)); + return new Date(y, m - 1, d); +} + +export function effectiveTaskIso(t: VtTask): string | null { + if (t.due && t.scheduled) return t.due < t.scheduled ? t.due : t.scheduled; + return t.due ?? t.scheduled ?? null; +} + +export function taskDate(t: VtTask): Date | null { + const iso = effectiveTaskIso(t); + return iso ? isoToMid(iso) : null; +} + +export function isTaskToday(t: VtTask, today: Date): boolean { + const date = taskDate(t); + return !!date && dayDiff(today, date) === 0; +} + +export function isTaskOverdue(t: VtTask, today: Date): boolean { + const date = taskDate(t); + return isOpen(t) && !!date && dayDiff(today, date) < 0; +} + +function dayDiff(from: Date, to: Date): number { + return Math.round((dateOnly(to).getTime() - dateOnly(from).getTime()) / 86400000); +} + +function sourceFor(workspace: RuntimeWorkspace, task: VtTask): TaskSourceSetting | undefined { + return workspace.sourceById.get(task.sourceId); +} + +export function taskArea(t: VtTask, workspace: RuntimeWorkspace): string | null { + const source = sourceFor(workspace, t); + if (!source || source.role === "inbox") return null; + if (source.groupId) return source.groupId; + const configured = workspace.areasBySourceHeading.get(`${source.id}\u0000${t.heading.trim().toLowerCase()}`); + return configured?.id ?? (t.heading || null); +} + +export function upcomingByDay(tasks: VtTask[], today: Date, days = 7): DayGroup[] { + const groups: DayGroup[] = []; + for (let n = 1; n <= days; n++) { + const day = dateOnly(today); + day.setDate(day.getDate() + n); + groups.push({ date: day, tasks: [] }); + } + for (const t of tasks) { + if (!isOpen(t)) continue; + const d = taskDate(t); + if (!d) continue; + const diff = dayDiff(today, d); + if (diff >= 1 && diff <= days) groups[diff - 1].tasks.push(t); + } + return groups; +} + +export function laterTasks(tasks: VtTask[], today: Date, afterDays = 7): VtTask[] { + return tasks.filter((t) => { + if (!isOpen(t)) return false; + const d = taskDate(t); + return !!d && dayDiff(today, d) > afterDays; + }); +} + +export function undatedOpenTasks(tasks: VtTask[]): VtTask[] { + return tasks.filter((t) => isOpen(t) && !t.due && !t.scheduled); +} + +export function allOpenGrouped(tasks: VtTask[], workspace: RuntimeWorkspace): AreaGroup[] { + const groups: AreaGroup[] = []; + const groupedSources = new Map(); + const headingGroups = new Map(); + + for (const task of tasks) { + if (!isOpen(task)) continue; + const source = sourceFor(workspace, task); + if (!source || source.role === "inbox") continue; + if (source.groupId) { + const groupTasks = groupedSources.get(source.groupId) ?? []; + groupTasks.push(task); + groupedSources.set(source.groupId, groupTasks); + continue; + } + const heading = task.heading || source.label; + const key = `${source.id}\u0000${heading}`; + const entry = headingGroups.get(key) ?? { sourceId: source.id, heading, tasks: [] }; + entry.tasks.push(task); + headingGroups.set(key, entry); + } + + for (const entry of headingGroups.values()) { + const area = workspace.areasBySourceHeading.get(`${entry.sourceId}\u0000${entry.heading.toLowerCase()}`); + groups.push({ + key: area?.id ?? `${entry.sourceId}:${entry.heading}`, + label: area?.label ?? entry.heading, + tasks: entry.tasks, + mode: "flat", + color: area?.color, + }); + } + for (const [groupId, groupTasks] of groupedSources) { + const group = workspace.groupById.get(groupId); + if (!group) continue; + groups.push({ key: group.id, label: group.label, tasks: groupTasks, mode: group.mode, color: group.color }); + } + + const unknownRank = workspace.displayRank.size + 1; + return groups.sort((a, b) => { + const rank = (key: string) => workspace.displayRank.get(key) ?? unknownRank; + return rank(a.key) - rank(b.key) || a.label.localeCompare(b.label); + }); +} + +export function inboxTasks(tasks: VtTask[], workspace: RuntimeWorkspace): VtTask[] { + return tasks.filter((task) => sourceFor(workspace, task)?.role === "inbox" && isOpen(task)); +} + +function doneKey(t: VtTask): string { + return t.doneDate ?? t.cancelledDate ?? ""; +} + +export function completedRecent(tasks: VtTask[], limit = 30): CompletedResult { + const done = tasks + .filter((t) => t.status === "done" || t.status === "cancelled") + .sort((a, b) => doneKey(b).localeCompare(doneKey(a))); + return { tasks: done.slice(0, limit), truncated: done.length > limit }; +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..be18aa6 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,391 @@ +export const SETTINGS_VERSION = 1; + +export type SourceRole = "tasks" | "inbox"; +export type EditPolicy = "route" | "stay"; +export type GroupMode = "flat" | "by-heading"; + +export interface TaskSourceSetting { + id: string; + label: string; + path: string; + role: SourceRole; + groupId?: string; + editPolicy: EditPolicy; + proposals: boolean; +} + +export interface SourceGroupSetting { + id: string; + label: string; + mode: GroupMode; + ownerDisplay: boolean; + color?: string; +} + +export interface AreaSetting { + id: string; + label: string; + sourceId: string; + heading: string; + color?: string; +} + +export interface CaptureRouteSetting { + tag: string; + aliases: string[]; + destination: { sourceId: string; heading: string }; + keywords: string[]; + showAsChip: boolean; +} + +export interface TagFilterSetting { + tag: string; + label: string; + color?: string; +} + +export interface TasklineSettings { + version: number; + sources: TaskSourceSetting[]; + sourceGroups: SourceGroupSetting[]; + areas: AreaSetting[]; + captureRoutes: CaptureRouteSetting[]; + tagFilters: TagFilterSetting[]; + displayOrder: string[]; + fallbackCaptureDestination: { sourceId: string; heading: string } | null; + ownerSelfAliases: string[]; +} + +export interface SettingsIssue { + level: "error" | "warning"; + path: string; + message: string; +} + +export interface RuntimeWorkspace { + settings: TasklineSettings; + issues: SettingsIssue[]; + configured: boolean; + sources: TaskSourceSetting[]; + sourceById: Map; + sourceByPath: Map; + groupById: Map; + areaById: Map; + areasBySourceHeading: Map; + routeByTag: Map; + tagFilterByTag: Map; + displayRank: Map; + selfAliases: Set; +} + +export const DEFAULT_SETTINGS: TasklineSettings = { + version: SETTINGS_VERSION, + sources: [], + sourceGroups: [], + areas: [], + captureRoutes: [], + tagFilters: [], + displayOrder: [], + fallbackCaptureDestination: null, + ownerSelfAliases: [], +}; + +export function normalizeVaultPath(path: string): string { + const segments = path.trim().replace(/\\/g, "/").split("/"); + const normalized: string[] = []; + for (const segment of segments) { + if (!segment || segment === ".") continue; + if (segment === "..") normalized.pop(); + else normalized.push(segment); + } + return normalized.join("/"); +} + +function isUnsafeVaultPath(path: string): boolean { + const trimmed = path.trim(); + return /^[/\\]/.test(trimmed) + || /^[a-zA-Z]:[/\\]/.test(trimmed) + || trimmed.replace(/\\/g, "/").split("/").includes(".."); +} + +function lower(value: string): string { + return value.trim().toLowerCase(); +} + +type UnknownRecord = Record; + +function record(value: unknown): UnknownRecord | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as UnknownRecord + : null; +} + +function text(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function optionalText(value: unknown): string | undefined { + const result = text(value).trim(); + return result || undefined; +} + +function enumValue( + value: unknown, + allowed: readonly T[], + fallback: T, + path: string, + issues: SettingsIssue[] +): T { + if (value === undefined) return fallback; + if (typeof value === "string" && allowed.includes(value as T)) return value as T; + issues.push({ level: "error", path, message: `Expected one of: ${allowed.join(", ")}.` }); + return fallback; +} + +function booleanValue(value: unknown, fallback: boolean, path: string, issues: SettingsIssue[]): boolean { + if (value === undefined) return fallback; + if (typeof value === "boolean") return value; + issues.push({ level: "error", path, message: "Expected a boolean." }); + return fallback; +} + +function colorValue(value: unknown, path: string, issues: SettingsIssue[]): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string" || value.trim().length === 0) { + issues.push({ level: "error", path, message: "Expected a non-empty CSS color string." }); + return undefined; + } + const color = value.trim(); + if (!/^(--[\w-]+|var\(--[\w-]+\)|#[\da-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla)\([^\r\n]+\)|[a-zA-Z]+)$/.test(color)) { + issues.push({ level: "error", path, message: "Expected a CSS color name, hex/rgb/hsl value, or CSS variable." }); + return undefined; + } + return color; +} + +function stringArray(value: unknown, path: string, issues: SettingsIssue[]): string[] { + if (!Array.isArray(value)) { + if (value !== undefined) issues.push({ level: "error", path, message: "Expected an array." }); + return []; + } + const result: string[] = []; + value.forEach((item, index) => { + if (typeof item === "string") result.push(item); + else issues.push({ level: "error", path: `${path}.${index}`, message: "Expected a string." }); + }); + return result; +} + +function objectArray( + value: unknown, + path: string, + issues: SettingsIssue[], + decode: (item: UnknownRecord, index: number) => T +): T[] { + if (!Array.isArray(value)) { + if (value !== undefined) issues.push({ level: "error", path, message: "Expected an array." }); + return []; + } + const result: T[] = []; + value.forEach((item, index) => { + const raw = record(item); + if (raw) result.push(decode(raw, index)); + else issues.push({ level: "error", path: `${path}.${index}`, message: "Expected an object." }); + }); + return result; +} + +function decodeSettings(data: unknown): { settings: TasklineSettings; issues: SettingsIssue[] } { + const issues: SettingsIssue[] = []; + const raw = record(data) ?? {}; + if (data !== undefined && data !== null && !record(data)) { + issues.push({ level: "error", path: "settings", message: "Expected a settings object." }); + } + const version = raw.version === undefined ? SETTINGS_VERSION : raw.version; + if (typeof version !== "number" || !Number.isInteger(version)) { + issues.push({ level: "error", path: "version", message: "Schema version must be an integer." }); + } else if (version > SETTINGS_VERSION) { + issues.push({ level: "error", path: "version", message: `Unsupported future schema version: ${version}` }); + } else if (version < 1) { + issues.push({ level: "error", path: "version", message: `Unsupported schema version: ${version}` }); + } + + const fallbackRaw = raw.fallbackCaptureDestination === null || raw.fallbackCaptureDestination === undefined + ? null + : record(raw.fallbackCaptureDestination); + if (raw.fallbackCaptureDestination !== null && raw.fallbackCaptureDestination !== undefined && !fallbackRaw) { + issues.push({ level: "error", path: "fallbackCaptureDestination", message: "Expected an object or null." }); + } + + return { + issues, + settings: { + version: SETTINGS_VERSION, + sources: objectArray(raw.sources, "sources", issues, (source, index) => { + const path = text(source.path); + if (isUnsafeVaultPath(path)) { + issues.push({ level: "error", path: `sources.${index}.path`, message: "Source path must stay within the vault." }); + } + return { + id: text(source.id).trim(), + label: text(source.label).trim(), + path: normalizeVaultPath(path), + role: enumValue(source.role, ["tasks", "inbox"], "tasks", `sources.${index}.role`, issues), + groupId: optionalText(source.groupId), + editPolicy: enumValue(source.editPolicy, ["route", "stay"], "route", `sources.${index}.editPolicy`, issues), + proposals: booleanValue(source.proposals, false, `sources.${index}.proposals`, issues), + }; + }), + sourceGroups: objectArray(raw.sourceGroups, "sourceGroups", issues, (group, index) => ({ + id: text(group.id).trim(), + label: text(group.label).trim(), + mode: enumValue(group.mode, ["flat", "by-heading"], "flat", `sourceGroups.${index}.mode`, issues), + ownerDisplay: booleanValue(group.ownerDisplay, false, `sourceGroups.${index}.ownerDisplay`, issues), + color: colorValue(group.color, `sourceGroups.${index}.color`, issues), + })), + areas: objectArray(raw.areas, "areas", issues, (area, index) => ({ + id: text(area.id).trim(), + label: text(area.label).trim(), + sourceId: text(area.sourceId).trim(), + heading: text(area.heading).trim(), + color: colorValue(area.color, `areas.${index}.color`, issues), + })), + captureRoutes: objectArray(raw.captureRoutes, "captureRoutes", issues, (route, index) => { + const destination = record(route.destination); + if (!destination) issues.push({ level: "error", path: `captureRoutes.${index}.destination`, message: "Destination is required and must be an object." }); + return { + tag: lower(text(route.tag)), + aliases: stringArray(route.aliases, "captureRoutes.aliases", issues).map(lower).filter(Boolean), + destination: { + sourceId: text(destination?.sourceId).trim(), + heading: text(destination?.heading).trim(), + }, + keywords: stringArray(route.keywords, "captureRoutes.keywords", issues).map(lower).filter(Boolean), + showAsChip: booleanValue(route.showAsChip, true, `captureRoutes.${index}.showAsChip`, issues), + }; + }), + tagFilters: objectArray(raw.tagFilters, "tagFilters", issues, (filter, index) => ({ + tag: lower(text(filter.tag)), + label: text(filter.label).trim() || text(filter.tag), + color: colorValue(filter.color, `tagFilters.${index}.color`, issues), + })), + displayOrder: stringArray(raw.displayOrder, "displayOrder", issues), + fallbackCaptureDestination: fallbackRaw ? { + sourceId: text(fallbackRaw.sourceId).trim(), + heading: text(fallbackRaw.heading).trim(), + } : null, + ownerSelfAliases: stringArray(raw.ownerSelfAliases, "ownerSelfAliases", issues).map(lower).filter(Boolean), + }, + }; +} + +export function createSettingsDraft(active: TasklineSettings, rejectedRaw: unknown): Record { + const rejected = record(rejectedRaw); + const source = rejected ? { ...active, ...rejected } : active; + return JSON.parse(JSON.stringify(source)) as Record; +} + +export function compileWorkspace(data: unknown): RuntimeWorkspace { + const decoded = decodeSettings(data); + const settings = decoded.settings; + const issues: SettingsIssue[] = [...decoded.issues]; + const sourceById = new Map(); + const sourceByPath = new Map(); + const groupById = new Map(); + const areaById = new Map(); + const areasBySourceHeading = new Map(); + const routeByTag = new Map(); + const tagFilterByTag = new Map(); + + const duplicate = (map: Map, key: string, path: string, kind: string, value: T): void => { + if (!key) { + issues.push({ level: "error", path, message: `${kind} is required.` }); + } else if (map.has(key)) { + issues.push({ level: "error", path, message: `Duplicate ${kind}: ${key}` }); + } else { + map.set(key, value); + } + }; + + settings.sourceGroups.forEach((group, index) => duplicate(groupById, group.id, `sourceGroups.${index}.id`, "group ID", group)); + settings.sources.forEach((source, index) => { + duplicate(sourceById, source.id, `sources.${index}.id`, "source ID", source); + duplicate(sourceByPath, lower(source.path), `sources.${index}.path`, "source path", source); + if (!source.path) issues.push({ level: "error", path: `sources.${index}.path`, message: "Source path is required." }); + if (!source.label) issues.push({ level: "error", path: `sources.${index}.label`, message: "Source label is required." }); + if (source.groupId && !groupById.has(source.groupId)) { + issues.push({ level: "error", path: `sources.${index}.groupId`, message: `Unknown source group: ${source.groupId}` }); + } + }); + settings.areas.forEach((area, index) => { + duplicate(areaById, area.id, `areas.${index}.id`, "area ID", area); + if (!sourceById.has(area.sourceId)) { + issues.push({ level: "error", path: `areas.${index}.sourceId`, message: `Unknown source: ${area.sourceId}` }); + } + if (!area.label) issues.push({ level: "error", path: `areas.${index}.label`, message: "Area label is required." }); + if (!area.heading) issues.push({ level: "error", path: `areas.${index}.heading`, message: "Area heading is required." }); + const key = `${area.sourceId}\u0000${lower(area.heading)}`; + duplicate(areasBySourceHeading, key, `areas.${index}.heading`, "source/heading", area); + }); + settings.captureRoutes.forEach((route, index) => { + const tags = [route.tag, ...route.aliases]; + tags.forEach((tag) => duplicate(routeByTag, tag, `captureRoutes.${index}`, "capture tag or alias", route)); + if (!sourceById.has(route.destination.sourceId)) { + issues.push({ level: "error", path: `captureRoutes.${index}.destination.sourceId`, message: `Unknown source: ${route.destination.sourceId}` }); + } + if (!route.destination.heading) { + issues.push({ level: "error", path: `captureRoutes.${index}.destination.heading`, message: "Destination heading is required." }); + } + for (const tag of tags) { + if (tag && !/^[\w/-]+$/.test(tag)) { + issues.push({ level: "error", path: `captureRoutes.${index}`, message: `Invalid capture tag: ${tag}` }); + } + } + }); + settings.tagFilters.forEach((filter, index) => duplicate(tagFilterByTag, filter.tag, `tagFilters.${index}.tag`, "tag filter", filter)); + + if (settings.fallbackCaptureDestination && !sourceById.has(settings.fallbackCaptureDestination.sourceId)) { + issues.push({ level: "error", path: "fallbackCaptureDestination.sourceId", message: `Unknown source: ${settings.fallbackCaptureDestination.sourceId}` }); + } + if (settings.fallbackCaptureDestination && !settings.fallbackCaptureDestination.heading) { + issues.push({ level: "error", path: "fallbackCaptureDestination.heading", message: "Fallback heading is required." }); + } + settings.displayOrder.forEach((id, index) => { + if (!areaById.has(id) && !groupById.has(id)) { + issues.push({ level: "warning", path: `displayOrder.${index}`, message: `Unknown area or group: ${id}` }); + } + }); + + const displayRank = new Map(settings.displayOrder.map((id, index) => [id, index])); + return { + settings, + issues, + configured: settings.sources.length > 0 && !issues.some((issue) => issue.level === "error"), + sources: settings.sources, + sourceById, + sourceByPath, + groupById, + areaById, + areasBySourceHeading, + routeByTag, + tagFilterByTag, + displayRank, + selfAliases: new Set(settings.ownerSelfAliases), + }; +} + +export function workspaceColor(workspace: RuntimeWorkspace, key: string): string | undefined { + const normalized = lower(key); + const area = workspace.areaById.get(key) + ?? workspace.settings.areas.find((item) => lower(item.label) === normalized || lower(item.heading) === normalized); + if (area?.color) return area.color; + const group = workspace.groupById.get(key) + ?? workspace.settings.sourceGroups.find((item) => lower(item.label) === normalized); + if (group?.color) return group.color; + const filterColor = workspace.tagFilterByTag.get(normalized)?.color; + if (filterColor) return filterColor; + const destination = workspace.routeByTag.get(normalized)?.destination; + return destination + ? workspace.areasBySourceHeading.get(`${destination.sourceId}\u0000${lower(destination.heading)}`)?.color + : undefined; +} diff --git a/src/settingsTab.ts b/src/settingsTab.ts new file mode 100644 index 0000000..de44309 --- /dev/null +++ b/src/settingsTab.ts @@ -0,0 +1,152 @@ +import { Notice, PluginSettingTab, Setting } from "obsidian"; +import type TasklinePlugin from "./main"; +import { compileWorkspace, createSettingsDraft } from "./settings"; + +type CollectionKey = "sources" | "sourceGroups" | "areas" | "captureRoutes" | "tagFilters" | "displayOrder" | "ownerSelfAliases"; + +const SECTIONS: Array<{ key: CollectionKey; name: string; description: string }> = [ + { key: "sources", name: "Task sources", description: "JSON array of sources: id, label, path, role, optional groupId, editPolicy, and proposals." }, + { key: "sourceGroups", name: "Source groups", description: "JSON array of groups: id, label, mode, ownerDisplay, and optional color." }, + { key: "areas", name: "Areas", description: "JSON array of areas: id, label, sourceId, heading, and optional color." }, + { key: "captureRoutes", name: "Capture routes", description: "Ordered JSON array of tag routes with aliases, destination, keywords, and showAsChip." }, + { key: "tagFilters", name: "Tag filters", description: "JSON array of generic tag filters: tag, label, and optional color." }, + { key: "displayOrder", name: "Display order", description: "JSON array of area and source-group IDs." }, + { key: "ownerSelfAliases", name: "Owner self aliases", description: "JSON array of owner names that should not render as owner chips." }, +]; + +export class TasklineSettingTab extends PluginSettingTab { + constructor(private readonly taskline: TasklinePlugin) { + super(taskline.app, taskline); + } + + display(): void { + const { containerEl } = this; + const draft = createSettingsDraft(this.taskline.settings, this.taskline.rejectedSettingsRaw); + const parseErrors = new Map(); + containerEl.empty(); + containerEl.createEl("h2", { text: "Taskline settings" }); + containerEl.createEl("p", { + text: "Changes remain a draft until Apply. Taskline never creates or changes task files from settings.", + }); + + this.renderIssues( + [...this.taskline.settingsLoadIssues, ...this.taskline.workspace.issues], + this.taskline.settingsLoadIssues.length > 0 ? "Saved configuration rejected" : "Active configuration" + ); + const draftIssues = containerEl.createDiv({ cls: "vt-settings-issues" }); + draftIssues.hide(); + const renderDraftIssues = (): void => { + const compiled = compileWorkspace(draft); + const messages = [...parseErrors.values(), ...compiled.issues + .filter((issue) => issue.level === "error") + .map((issue) => `${issue.path} - ${issue.message}`)]; + draftIssues.empty(); + if (messages.length === 0) { + draftIssues.hide(); + return; + } + draftIssues.show(); + draftIssues.setAttr("role", "alert"); + draftIssues.createEl("h3", { text: "Draft validation" }); + const list = draftIssues.createEl("ul"); + for (const message of messages) list.createEl("li", { text: message }); + }; + + new Setting(containerEl) + .setName("Schema version") + .setDesc("Taskline settings schema version.") + .addText((text) => { + text.setValue(String(draft.version)); + text.inputEl.setAttr("aria-label", "Schema version"); + text.onChange((value) => { + const version = Number(value); + if (!Number.isInteger(version)) { + parseErrors.set("version", "Schema version: expected an integer"); + } else { + draft.version = version; + parseErrors.delete("version"); + } + renderDraftIssues(); + }); + }); + + for (const section of SECTIONS) { + new Setting(containerEl) + .setName(section.name) + .setDesc(section.description) + .addTextArea((text) => { + text.inputEl.rows = 8; + text.inputEl.addClass("vt-settings-json"); + text.inputEl.setAttr("aria-label", `${section.name} JSON`); + text.setValue(JSON.stringify(draft[section.key], null, 2)); + text.onChange((value) => { + try { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) throw new Error("Expected an array"); + draft[section.key] = parsed; + parseErrors.delete(section.key); + } catch (error) { + parseErrors.set(section.key, `${section.name}: ${error instanceof Error ? error.message : "invalid JSON"}`); + } + renderDraftIssues(); + }); + }); + } + + new Setting(containerEl) + .setName("Fallback capture destination") + .setDesc("JSON object with sourceId and heading, or null.") + .addTextArea((text) => { + text.inputEl.rows = 3; + text.setValue(JSON.stringify(draft.fallbackCaptureDestination, null, 2)); + text.inputEl.setAttr("aria-label", "Fallback capture destination JSON"); + text.onChange((value) => { + try { + const parsed = JSON.parse(value) as unknown; + if (parsed !== null && (typeof parsed !== "object" || Array.isArray(parsed))) { + throw new Error("Expected an object or null"); + } + draft.fallbackCaptureDestination = parsed; + parseErrors.delete("fallbackCaptureDestination"); + } catch (error) { + parseErrors.set("fallbackCaptureDestination", `Fallback destination: ${error instanceof Error ? error.message : "invalid JSON"}`); + } + renderDraftIssues(); + }); + }); + + new Setting(containerEl) + .setName("Apply settings") + .setDesc("Validate, initialize the candidate workspace, then save and activate it.") + .addButton((button) => button.setButtonText("Apply").setCta().onClick(async () => { + renderDraftIssues(); + const compiled = compileWorkspace(draft); + if (parseErrors.size > 0 || compiled.issues.some((issue) => issue.level === "error")) { + new Notice("Fix the draft validation errors before applying."); + return; + } + button.setDisabled(true); + try { + await this.taskline.updateSettings(draft); + new Notice("Taskline settings applied."); + this.display(); + } catch (error) { + new Notice(error instanceof Error ? error.message : "Could not apply Taskline settings."); + renderDraftIssues(); + } finally { + button.setDisabled(false); + } + })); + } + + private renderIssues(issues: Array<{ level: "error" | "warning"; path: string; message: string }>, title: string): void { + if (issues.length === 0) return; + const block = this.containerEl.createDiv({ cls: "vt-settings-issues" }); + block.setAttr("role", "status"); + block.createEl("h3", { text: title }); + const list = block.createEl("ul"); + for (const issue of issues) { + list.createEl("li", { text: `${issue.level === "error" ? "Error" : "Warning"}: ${issue.path} - ${issue.message}` }); + } + } +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..d146ee4 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,175 @@ +import { App, TAbstractFile, TFile } from "obsidian"; +import { parseTrackerFile } from "./parser"; +import { VtProposal, VtTask } from "./model"; +import { + AreaGroup, + CompletedResult, + DayGroup, + allOpenGrouped, + completedRecent, + isTaskOverdue, + isTaskToday, + inboxTasks, + laterTasks, + undatedOpenTasks, + upcomingByDay, +} from "./query"; +import type { RuntimeWorkspace } from "./settings"; + +export type VtChangeListener = () => void; + +/** Reads and parses the tracked vault files, keeps them in sync via vault 'modify' events, + * and exposes simple query helpers for the Today view. This module imports 'obsidian' and + * is not unit-tested directly - the pure parsing/formatting logic it wraps lives in + * parser.ts / format.ts and IS unit-tested. */ +export class VtStore { + private app: App; + private workspace: RuntimeWorkspace; + private tasksByFile: Map = new Map(); + private proposalsByFile: Map = new Map(); + private listeners: Set = new Set(); + private eventRefs: Array> = []; + private reparseGeneration: Map = new Map(); + private disposed = false; + + constructor(app: App, workspace: RuntimeWorkspace) { + this.app = app; + this.workspace = workspace; + } + + async init(): Promise { + this.disposed = false; + for (const source of this.workspace.sources) { + await this.reparseFile(source.path); + } + + const reparseConfigured = (file: TAbstractFile): void => { + if (!(file instanceof TFile)) return; + const source = this.workspace.sourceByPath.get(file.path.toLowerCase()); + if (source) { + void this.reparseFile(source.path) + .then((applied) => applied && this.notify()) + .catch((error) => console.error(`taskline: could not reparse ${source.path}`, error)); + } + }; + this.eventRefs.push(this.app.vault.on("modify", reparseConfigured)); + this.eventRefs.push(this.app.vault.on("create", reparseConfigured)); + this.eventRefs.push(this.app.vault.on("delete", (file: TAbstractFile) => { + const source = this.workspace.sourceByPath.get(file.path.toLowerCase()); + if (!source) return; + this.invalidate(source.path); + this.tasksByFile.set(source.path, []); + this.proposalsByFile.set(source.path, []); + this.notify(); + })); + this.eventRefs.push(this.app.vault.on("rename", (file: TAbstractFile, oldPath: string) => { + const oldSource = this.workspace.sourceByPath.get(oldPath.toLowerCase()); + if (oldSource) { + this.invalidate(oldSource.path); + this.tasksByFile.set(oldSource.path, []); + this.proposalsByFile.set(oldSource.path, []); + this.notify(); + } + reparseConfigured(file); + })); + + // Consumers that subscribe before init still receive the first complete snapshot. + this.notify(); + } + + private invalidate(path: string): number { + const generation = (this.reparseGeneration.get(path) ?? 0) + 1; + this.reparseGeneration.set(path, generation); + return generation; + } + + private async reparseFile(path: string): Promise { + const generation = this.invalidate(path); + const file = this.app.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) { + if (this.disposed || this.reparseGeneration.get(path) !== generation) return false; + this.tasksByFile.set(path, []); + this.proposalsByFile.set(path, []); + return true; + } + const content = await this.app.vault.cachedRead(file); + if (this.disposed || this.reparseGeneration.get(path) !== generation) return false; + const source = this.workspace.sourceByPath.get(path.toLowerCase()); + if (!source) return false; + const { tasks, proposals } = parseTrackerFile(content, source, this.workspace); + this.tasksByFile.set(path, tasks); + this.proposalsByFile.set(path, proposals); + return true; + } + + getTasks(): VtTask[] { + const all: VtTask[] = []; + for (const source of this.workspace.sources) { + all.push(...(this.tasksByFile.get(source.path) ?? [])); + } + return all; + } + + getProposals(): VtProposal[] { + const all: VtProposal[] = []; + for (const source of this.workspace.sources) { + all.push(...(this.proposalsByFile.get(source.path) ?? [])); + } + return all; + } + + private isOpen(task: VtTask): boolean { + return task.status !== "done" && task.status !== "cancelled"; + } + + overdueTasks(today: Date): VtTask[] { + return this.getTasks().filter((task) => isTaskOverdue(task, today)); + } + + dueToday(today: Date): VtTask[] { + return this.getTasks().filter((task) => this.isOpen(task) && isTaskToday(task, today)); + } + + // ---- tracker-tab query helpers (delegate to the pure query layer) -------- + + upcomingByDay(today: Date, days = 7): DayGroup[] { + return upcomingByDay(this.getTasks(), today, days); + } + + laterTasks(today: Date, afterDays = 7): VtTask[] { + return laterTasks(this.getTasks(), today, afterDays); + } + + undatedOpenTasks(): VtTask[] { + return undatedOpenTasks(this.getTasks()); + } + + allOpenGrouped(): AreaGroup[] { + return allOpenGrouped(this.getTasks(), this.workspace); + } + + inboxTasks(): VtTask[] { + return inboxTasks(this.getTasks(), this.workspace); + } + + completedRecent(limit = 30): CompletedResult { + return completedRecent(this.getTasks(), limit); + } + + onChange(cb: VtChangeListener): () => void { + this.listeners.add(cb); + return () => this.listeners.delete(cb); + } + + private notify(): void { + for (const cb of this.listeners) cb(); + } + + dispose(): void { + this.disposed = true; + for (const ref of this.eventRefs) this.app.vault.offref(ref); + this.eventRefs = []; + for (const source of this.workspace.sources) this.invalidate(source.path); + this.listeners.clear(); + } +} diff --git a/src/todayView.ts b/src/todayView.ts new file mode 100644 index 0000000..9d69743 --- /dev/null +++ b/src/todayView.ts @@ -0,0 +1,955 @@ +import { ItemView, Menu, Notice, Platform, setIcon, WorkspaceLeaf } from "obsidian"; +import type VaultTasksPlugin from "./main"; +import { VtProposal, VtTask } from "./model"; +import { + completeTask, + applyProposal, + removeLine, + setTaskStatus, + VtRecurrenceUnavailableError, +} from "./writer"; +import { taskArea } from "./query"; +import { effectiveTaskIso } from "./query"; +import { findProposalTarget } from "./proposals"; +import { areaColor } from "./ui/areaColor"; +import { buildTaskRow, TaskRowHandlers } from "./ui/taskRow"; +import { buildProposedRow } from "./ui/proposedRow"; +import { + animateComplete, + animateConfirm, + animateReject, + AnimToken, + isCancellation, + revertRow, +} from "./ui/motion"; + +export const VIEW_TYPE_TODAY = "vault-tasks-today"; + +export type VtTabId = "today" | "upcoming" | "all"; + +const WEEKDAY_LONG = [ + "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", +]; +const MONTH_SHORT = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +const TAB_TITLES: Record = { + today: "Today", + upcoming: "Upcoming", + all: "All Tasks", +}; + +const TAB_ICONS: Record = { + today: "calendar-days", + upcoming: "calendar-range", + all: "list-todo", +}; + +const PRIORITY_RANK: Record = { p1: 0, p2: 1, p3: 2, p4: 3 }; + +function isOpen(t: VtTask): boolean { + return t.status !== "done" && t.status !== "cancelled"; +} + +function localIso(d: Date): string { + const pad = (n: number) => (n < 10 ? `0${n}` : String(n)); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +function faintDate(d: Date): string { + return `${MONTH_SHORT[d.getMonth()]} ${d.getDate()}`; +} + +export class TodayView extends ItemView { + private root!: HTMLElement; + private unsub: (() => void) | null = null; + private readyDisposer: (() => void) | null = null; + private settingsDisposer: (() => void) | null = null; + private debounceTimer: number | null = null; + + // Render coordination: while an optimistic animation is in flight we defer store-driven + // re-renders so the animating row is not yanked out from under the motion. + private animating = 0; + private renderQueued = false; + private closed = false; + private animationTokens = new Set(); + + // In-flight completion guard: shared by every trigger path (ring click, Space/Enter, + // status-menu Done) so a rapid second click during the ~680ms completion sequence can't + // re-fire completeTask against the same row. + private completingRows = new Set(); + + // Roving tabindex state + per-row keyboard actions (rebuilt each render). + private rows: HTMLElement[] = []; + private activeRow = 0; + private primaryAction: Map void> = new Map(); + private editAction: Map void> = new Map(); + private rejectAction: Map void> = new Map(); + + private taskHandlers: TaskRowHandlers; + + constructor( + leaf: WorkspaceLeaf, + private readonly plugin: VaultTasksPlugin + ) { + super(leaf); + this.taskHandlers = { + onComplete: (task, row) => this.onComplete(task, row), + onStatusMenu: (task, ring, ev) => this.onStatusMenu(task, ring, ev), + onEdit: (task) => this.plugin.openEdit(task), + }; + } + + getViewType() { + return VIEW_TYPE_TODAY; + } + + getDisplayText() { + return "Tasks"; + } + + getIcon() { + return "check-circle"; + } + + async onOpen() { + this.closed = false; + this.root = this.containerEl.children[1] as HTMLElement; + this.root.empty(); + this.root.addClass("vault-tasks-view"); + if (Platform.isMobile) { + this.root.addClass("vt-mobile"); + this.containerEl.addClass("vt-mobile-host"); + this.buildFab(); + } + this.root.addEventListener("keydown", this.onKeyDown); + this.root.addEventListener("focusin", this.onFocusIn); + this.settingsDisposer = this.plugin.onSettingsChange(() => this.bindStore()); + + const store = this.plugin.store; + if (store) { + this.unsub = store.onChange(() => this.requestRender()); + this.renderNow(); + } else { + this.renderLoading(); + this.readyDisposer = this.plugin.whenStoreReady(() => { + const s = this.plugin.store; + if (s) this.unsub = s.onChange(() => this.requestRender()); + this.renderNow(); + }); + } + } + + async onClose() { + this.closed = true; + for (const token of this.animationTokens) token.cancelled = true; + this.animationTokens.clear(); + if (this.debounceTimer !== null) window.clearTimeout(this.debounceTimer); + this.root?.removeEventListener("keydown", this.onKeyDown); + this.root?.removeEventListener("focusin", this.onFocusIn); + this.unsub?.(); + this.readyDisposer?.(); + this.unsub = null; + this.readyDisposer = null; + this.settingsDisposer?.(); + this.settingsDisposer = null; + this.containerEl.querySelector(".vt-fab")?.remove(); + this.containerEl.removeClass("vt-mobile-host"); + this.containerEl.children[1].empty(); + } + + // ---- render coordination ------------------------------------------------- + + private requestRender(): void { + if (this.closed) return; + if (this.debounceTimer !== null) window.clearTimeout(this.debounceTimer); + this.debounceTimer = window.setTimeout(() => { + this.debounceTimer = null; + if (this.animating > 0) { + this.renderQueued = true; + return; + } + this.renderNow(); + }, 150); + } + + private settle(): void { + if (this.closed) return; + if (this.animating > 0) this.animating--; + if (this.animating === 0 && this.renderQueued) { + this.renderQueued = false; + this.renderNow(); + } + } + + private renderLoading(): void { + this.root.empty(); + this.root.createDiv({ cls: "vt-loading", text: "Loading tasks" }); + } + + private bindStore(): void { + this.unsub?.(); + this.unsub = null; + if (this.plugin.store) this.unsub = this.plugin.store.onChange(() => this.requestRender()); + this.renderNow(); + } + + private renderNow(): void { + if (this.closed) return; + const store = this.plugin.store; + this.root.empty(); + this.primaryAction.clear(); + this.editAction.clear(); + this.rejectAction.clear(); + if (!this.plugin.workspace.configured) { + this.renderUnconfigured(); + return; + } + if (!store) { + this.renderLoading(); + return; + } + + const today = new Date(); + const tab = this.plugin.activeTab; + + this.buildHeader(today, tab); + this.buildTabBar(tab); + this.buildFilterPills(this.tabOpenTasks(store, today, tab)); + + const sections = this.root.createDiv({ cls: "vt-sections" }); + if (tab === "today") this.renderTodayTab(sections, store, today); + else if (tab === "upcoming") this.renderUpcomingTab(sections, store, today); + else this.renderAllTab(sections, store, today); + + this.buildFooter(); + this.refreshRoving(); + } + + private sortTasks(tasks: VtTask[]): VtTask[] { + return [...tasks].sort((a, b) => { + const pr = (PRIORITY_RANK[a.priority ?? "p4"] ?? 3) - (PRIORITY_RANK[b.priority ?? "p4"] ?? 3); + if (pr !== 0) return pr; + const ad = effectiveTaskIso(a) ?? ""; + const bd = effectiveTaskIso(b) ?? ""; + return ad.localeCompare(bd); + }); + } + + // ---- filtering ----------------------------------------------------------- + + /** The pre-filter set of open tasks a tab draws its filter pills from. */ + private tabOpenTasks(store: NonNullable, today: Date, tab: VtTabId): VtTask[] { + if (tab === "today") { + return [...store.overdueTasks(today), ...store.dueToday(today), ...store.undatedOpenTasks()]; + } + if (tab === "upcoming") { + const days: VtTask[] = []; + for (const g of store.upcomingByDay(today, 7)) days.push(...g.tasks); + return [...days, ...store.laterTasks(today, 7)]; + } + const grouped: VtTask[] = []; + for (const g of store.allOpenGrouped()) grouped.push(...g.tasks); + return [...grouped, ...store.inboxTasks()]; + } + + private passesFilter(task: VtTask): boolean { + const filters = this.plugin.activeFilters; + if (filters.size === 0) return true; + const area = taskArea(task, this.plugin.workspace); + if (area && filters.has(area)) return true; + for (const tag of task.tags) if (filters.has(`tag:${tag}`)) return true; + return false; + } + + private filtered(tasks: VtTask[]): VtTask[] { + return tasks.filter((t) => this.passesFilter(t)); + } + + private get filtersActive(): boolean { + return this.plugin.activeFilters.size > 0; + } + + // ---- header + tab bar + pills -------------------------------------------- + + private buildHeader(today: Date, tab: VtTabId): void { + const header = this.root.createDiv({ cls: "vt-header" }); + const titleBlock = header.createDiv({ cls: "vt-header-titles" }); + titleBlock.createEl("h1", { cls: "vt-title", text: TAB_TITLES[tab] }); + if (tab === "today") { + const subtitle = `${WEEKDAY_LONG[today.getDay()]}, ${MONTH_SHORT[today.getMonth()]} ${today.getDate()}`; + titleBlock.createDiv({ cls: "vt-subtitle", text: subtitle }); + } + + // Desktop capture affordance. On mobile the FAB (built once on the view root) stands in. + if (!Platform.isMobile) { + const add = header.createEl("button", { cls: "vt-header-add", text: "+" }); + add.setAttr("type", "button"); + add.setAttr("aria-label", "Add task"); + add.setAttr("title", "Add task"); + add.addEventListener("click", () => this.plugin.openCapture()); + } + } + + private buildTabBar(tab: VtTabId): void { + const bar = this.root.createDiv({ cls: "vt-tabs" }); + bar.setAttr("role", "tablist"); + bar.setAttr("aria-label", "Task views"); + const defs: Array<[VtTabId, string]> = [ + ["today", "Today"], + ["upcoming", "Upcoming"], + ["all", "All"], + ]; + for (const [id, label] of defs) { + const btn = bar.createEl("button", { cls: "vt-tab" }); + btn.setAttr("type", "button"); + btn.setAttr("role", "tab"); + btn.dataset.tab = id; + const icon = btn.createSpan({ cls: "vt-tab-icon" }); + setIcon(icon, TAB_ICONS[id]); + btn.createSpan({ cls: "vt-tab-label", text: label }); + const active = id === tab; + btn.setAttr("aria-selected", active ? "true" : "false"); + btn.setAttr("tabindex", active ? "0" : "-1"); + if (active) btn.addClass("vt-tab--active"); + btn.addEventListener("click", () => this.switchTab(id)); + } + bar.addEventListener("keydown", this.onTabKeydown); + } + + private switchTab(id: VtTabId): void { + if (this.plugin.activeTab === id) return; + this.plugin.activeTab = id; + this.renderNow(); + } + + private onTabKeydown = (ev: KeyboardEvent): void => { + if (ev.key !== "ArrowLeft" && ev.key !== "ArrowRight") return; + ev.preventDefault(); + const order: VtTabId[] = ["today", "upcoming", "all"]; + const idx = order.indexOf(this.plugin.activeTab); + const next = ev.key === "ArrowRight" + ? order[Math.min(order.length - 1, idx + 1)] + : order[Math.max(0, idx - 1)]; + if (next !== this.plugin.activeTab) { + this.switchTab(next); + const btn = this.root.querySelector(`.vt-tab[data-tab="${next}"]`); + btn?.focus(); + } + }; + + private buildFilterPills(openTasks: VtTask[]): void { + const areaSet = new Set(); + const tagSet = new Set(); + for (const t of openTasks) { + const a = taskArea(t, this.plugin.workspace); + if (a) areaSet.add(a); + for (const tag of t.tags) if (this.plugin.workspace.tagFilterByTag.has(tag)) tagSet.add(tag); + } + if (areaSet.size === 0 && tagSet.size === 0) return; + + const wrap = this.root.createDiv({ cls: "vt-filter-pills" }); + wrap.setAttr("role", "group"); + wrap.setAttr("aria-label", "Filter by area"); + + for (const key of this.orderAreas([...areaSet])) { + const label = this.plugin.workspace.areaById.get(key)?.label + ?? this.plugin.workspace.groupById.get(key)?.label + ?? key; + this.buildPill(wrap, key, label); + } + for (const tag of tagSet) { + const filter = this.plugin.workspace.tagFilterByTag.get(tag); + this.buildPill(wrap, `tag:${tag}`, filter?.label ?? tag); + } + + if (this.filtersActive) { + const clear = wrap.createEl("button", { cls: "vt-filter-clear", text: "Clear" }); + clear.setAttr("type", "button"); + clear.addEventListener("click", () => { + this.plugin.activeFilters.clear(); + this.renderNow(); + }); + } + } + + private orderAreas(keys: string[]): string[] { + const rank = (k: string): number => { + return this.plugin.workspace.displayRank.get(k) ?? this.plugin.workspace.displayRank.size + 1; + }; + return [...keys].sort((a, b) => { + const r = rank(a) - rank(b); + return r !== 0 ? r : a.localeCompare(b); + }); + } + + private buildPill(wrap: HTMLElement, key: string, label: string): void { + const pill = wrap.createEl("button", { cls: "vt-pill", text: label }); + pill.setAttr("type", "button"); + // Per-area accent: active pills tint their labels with the configured color. + pill.style.setProperty("--vt-area-color", areaColor(key.replace(/^tag:/, ""), this.plugin.workspace)); + const active = this.plugin.activeFilters.has(key); + pill.setAttr("aria-pressed", active ? "true" : "false"); + if (active) pill.addClass("vt-pill--active"); + pill.addEventListener("click", () => { + const filters = this.plugin.activeFilters; + if (filters.has(key)) filters.delete(key); + else filters.add(key); + this.renderNow(); + }); + } + + // ---- Today tab ----------------------------------------------------------- + + private renderTodayTab(parent: HTMLElement, store: NonNullable, today: Date): void { + const overdue = this.sortTasks(this.filtered(store.overdueTasks(today))); + const todayTasks = this.sortTasks(this.filtered(store.dueToday(today))); + const needsTriage = this.filtered(store.undatedOpenTasks()); + const proposals = store.getProposals(); + const openCount = store.getTasks().filter(isOpen).length; + + const empty = overdue.length === 0 && todayTasks.length === 0 && proposals.length === 0 && needsTriage.length === 0; + if (empty) { + if (this.filtersActive) this.buildFilterEmpty(parent); + else this.buildEmptyState(parent, openCount); + return; + } + + if (overdue.length > 0) this.buildTaskSection(parent, "Overdue", overdue, today); + if (todayTasks.length > 0) { + this.buildTaskSection(parent, "Today", todayTasks, today); + } else if (overdue.length > 0) { + this.buildTodayEmptyWithOverdue(parent); + } + if (proposals.length > 0) this.buildProposedSection(parent, proposals); + if (needsTriage.length > 0) this.buildNeedsTriage(parent, needsTriage.length); + } + + // ---- Upcoming tab -------------------------------------------------------- + + private renderUpcomingTab(parent: HTMLElement, store: NonNullable, today: Date): void { + const days = store.upcomingByDay(today, 7); + const later = this.sortTasks(this.filtered(store.laterTasks(today, 7))); + + let anyDay = false; + for (let i = 0; i < days.length; i++) { + const group = days[i]; + const tasks = this.sortTasks(this.filtered(group.tasks)); + if (tasks.length === 0) continue; + anyDay = true; + const label = i === 0 ? "Tomorrow" : WEEKDAY_LONG[group.date.getDay()]; + this.buildTaskSection(parent, label, tasks, today, { faint: faintDate(group.date) }); + } + + if (!anyDay && later.length === 0) { + if (this.filtersActive) this.buildFilterEmpty(parent); + else this.buildUpcomingEmpty(parent); + return; + } + + if (later.length > 0) { + this.buildCollapsibleSection(parent, "later", "Later", later, today); + } + } + + // ---- All tab ------------------------------------------------------------- + + private renderAllTab(parent: HTMLElement, store: NonNullable, today: Date): void { + const groups = store.allOpenGrouped(); + const inbox = this.sortTasks(this.filtered(store.inboxTasks())); + const completed = store.completedRecent(30); + const completedVisible = this.filtered(completed.tasks); + + let anyVisible = false; + + for (const group of groups) { + const tasks = this.filtered(group.tasks); + if (tasks.length === 0) continue; + anyVisible = true; + if (group.mode === "by-heading") this.buildGroupedSourceSection(parent, group.label, tasks, today, group.color); + else this.buildTaskSection(parent, group.label, this.sortTasks(tasks), today, { dot: areaColor(group.key, this.plugin.workspace) }); + } + + if (inbox.length > 0) { + anyVisible = true; + this.buildInboxSection(parent, inbox, today); + } + + if (completedVisible.length > 0) { + anyVisible = true; + const note = completed.truncated ? "showing recent 30" : undefined; + this.buildCollapsibleSection(parent, "completed", "Completed", completedVisible, today, { + note, + done: true, + }); + } + + if (!anyVisible) { + if (this.filtersActive) this.buildFilterEmpty(parent); + else this.buildAllEmpty(parent); + } + } + + // ---- section builders ---------------------------------------------------- + + /** Builds the mobile FAB once on the view container (survives list re-renders, which only + * empty the inner scroll root). Accent-filled circle, bottom-right, safe-area aware. */ + private buildFab(): void { + const fab = this.containerEl.createEl("button", { cls: "vt-fab" }); + fab.setAttr("type", "button"); + fab.setAttr("aria-label", "Add task"); + fab.createSpan({ cls: "vt-fab-plus", text: "+", attr: { "aria-hidden": "true" } }); + fab.addEventListener("click", () => this.plugin.openCapture()); + } + + private buildSectionHead( + section: HTMLElement, + label: string, + count: number, + opts?: { faint?: string; dot?: string } + ): void { + const head = section.createDiv({ cls: "vt-section-head" }); + // A small area-color dot precedes All-tab group labels only (Inbox/Completed/Today pass none). + if (opts?.dot) { + const dot = head.createSpan({ cls: "vt-group-dot", attr: { "aria-hidden": "true" } }); + dot.style.setProperty("--vt-area-color", opts.dot); + } + head.createSpan({ cls: "vt-section-label", text: label }); + head.createSpan({ cls: "vt-section-count", text: `· ${count}` }); + if (opts?.faint) head.createSpan({ cls: "vt-section-faint", text: opts.faint }); + } + + private buildTaskSection( + parent: HTMLElement, + label: string, + tasks: VtTask[], + today: Date, + opts?: { faint?: string; dot?: string } + ): void { + const section = parent.createDiv({ cls: "vt-section" }); + this.buildSectionHead(section, label, tasks.length, { faint: opts?.faint, dot: opts?.dot }); + const list = section.createDiv({ cls: "vt-list" }); + list.setAttr("role", "list"); + for (const task of tasks) this.appendTaskRow(list, task, today); + } + + /** A by-heading source group renders one group header with source headings as sub-labels. */ + private buildGroupedSourceSection(parent: HTMLElement, label: string, tasks: VtTask[], today: Date, color?: string): void { + const section = parent.createDiv({ cls: "vt-section" }); + this.buildSectionHead(section, label, tasks.length, { dot: areaColor(label, this.plugin.workspace) }); + + const byHeading = new Map(); + for (const t of tasks) { + const key = t.heading || "Other"; + if (!byHeading.has(key)) byHeading.set(key, []); + byHeading.get(key)!.push(t); + } + for (const [heading, group] of byHeading) { + section.createDiv({ cls: "vt-sublabel", text: heading }); + const list = section.createDiv({ cls: "vt-list" }); + list.setAttr("role", "list"); + for (const task of this.sortTasks(group)) this.appendTaskRow(list, task, today); + } + } + + /** Inbox captures: plain rows carrying a faint "unfiled" hint. Editing routes through the same + * edit modal (the rows are fully editable); completing works. */ + private buildInboxSection(parent: HTMLElement, tasks: VtTask[], today: Date): void { + const section = parent.createDiv({ cls: "vt-section" }); + this.buildSectionHead(section, "Inbox", tasks.length, { faint: "unfiled" }); + const list = section.createDiv({ cls: "vt-list" }); + list.setAttr("role", "list"); + for (const task of tasks) { + const row = this.appendTaskRow(list, task, today); + row.querySelector(".vt-row-meta")?.createSpan({ cls: "vt-unfiled-hint", text: "unfiled" }); + } + } + + private buildCollapsibleSection( + parent: HTMLElement, + key: string, + label: string, + tasks: VtTask[], + today: Date, + opts?: { note?: string; done?: boolean } + ): void { + const expanded = this.plugin.expandedGroups.has(key); + const section = parent.createDiv({ cls: "vt-section vt-section--collapsible" }); + + const head = section.createEl("button", { cls: "vt-section-head vt-group-toggle" }); + head.setAttr("type", "button"); + head.setAttr("aria-expanded", expanded ? "true" : "false"); + const chevron = head.createSpan({ cls: "vt-chevron", text: "›" }); + chevron.setAttr("aria-hidden", "true"); + if (expanded) chevron.addClass("vt-chevron--open"); + head.createSpan({ cls: "vt-section-label vt-section-label--faint", text: label }); + head.createSpan({ cls: "vt-section-count", text: `· ${tasks.length}` }); + if (opts?.note) head.createSpan({ cls: "vt-section-faint", text: opts.note }); + head.addEventListener("click", () => { + if (expanded) this.plugin.expandedGroups.delete(key); + else this.plugin.expandedGroups.add(key); + this.renderNow(); + }); + + if (!expanded) return; + const list = section.createDiv({ cls: "vt-list" }); + list.setAttr("role", "list"); + if (opts?.done) { + for (const task of tasks) this.appendDoneRow(list, task, today); + } else { + for (const task of tasks) this.appendTaskRow(list, task, today); + } + } + + /** Live, interactive task row wired to completion. */ + private appendTaskRow(list: HTMLElement, task: VtTask, today: Date): HTMLElement { + const row = buildTaskRow(list, task, today, this.taskHandlers, this.plugin.workspace); + this.primaryAction.set(row, () => this.onComplete(task, row)); + this.editAction.set(row, () => this.plugin.openEdit(task)); + return row; + } + + /** History row: rendered for visual continuity but non-interactive (no completion, no focus, + * no edit). Done/cancelled tasks are history, not actions. */ + private appendDoneRow(list: HTMLElement, task: VtTask, today: Date): void { + const row = buildTaskRow(list, task, today, this.taskHandlers, this.plugin.workspace); + row.removeClass("vt-focusable"); + row.addClass("vt-row--done"); + row.removeAttribute("tabindex"); + const ring = row.querySelector(".vt-ring"); + if (ring) { + ring.style.pointerEvents = "none"; + ring.dataset.status = task.status; + ring.removeAttribute("role"); + ring.setAttr("aria-hidden", "true"); + } + } + + private buildTodayEmptyWithOverdue(parent: HTMLElement): void { + const section = parent.createDiv({ cls: "vt-section" }); + this.buildSectionHead(section, "Today", 0); + const empty = section.createDiv({ cls: "vt-section-empty" }); + empty.createDiv({ cls: "vt-empty-line1", text: "Nothing scheduled for today" }); + empty.createDiv({ cls: "vt-empty-line2", text: "You still have overdue items above." }); + } + + private buildProposedSection(parent: HTMLElement, proposals: VtProposal[]): void { + const section = parent.createDiv({ cls: "vt-section vt-section--proposed" }); + this.buildSectionHead(section, "Proposed", proposals.length); + const list = section.createDiv({ cls: "vt-list" }); + list.setAttr("role", "list"); + for (const proposal of proposals) { + const row = buildProposedRow(list, proposal, { + onConfirm: (p, r) => this.onConfirm(p, r), + onReject: (p, r) => this.onReject(p, r), + }); + this.primaryAction.set(row, () => this.onConfirm(proposal, row)); + this.rejectAction.set(row, () => this.onReject(proposal, row)); + } + } + + /** A quiet backlog signal, not a second task list. Active filters are already + * applied to the count and remain active when the user moves to All. */ + private buildNeedsTriage(parent: HTMLElement, count: number): void { + const section = parent.createDiv({ cls: "vt-section vt-section--triage" }); + const button = section.createEl("button", { cls: "vt-triage-link" }); + button.setAttr("type", "button"); + button.setAttr( + "aria-label", + `View ${count} open undated ${count === 1 ? "task" : "tasks"} in All Tasks` + ); + const icon = button.createSpan({ cls: "vt-triage-icon", attr: { "aria-hidden": "true" } }); + setIcon(icon, "list-filter"); + button.createSpan({ cls: "vt-triage-label", text: "Needs triage" }); + button.createSpan({ cls: "vt-section-count", text: `· ${count}` }); + button.createSpan({ cls: "vt-triage-hint", text: "Undated" }); + button.createSpan({ cls: "vt-triage-arrow", text: "›", attr: { "aria-hidden": "true" } }); + button.addEventListener("click", () => { + this.switchTab("all"); + window.setTimeout(() => this.root.querySelector('.vt-tab[data-tab="all"]')?.focus(), 0); + }); + } + + private buildEmptyState(parent: HTMLElement, openCount: number): void { + const panel = parent.createDiv({ cls: "vt-empty-state" }); + panel.createDiv({ cls: "vt-empty-glyph", text: "○", attr: { "aria-hidden": "true" } }); + const firstRun = openCount === 0; + panel.createDiv({ + cls: "vt-empty-title", + text: firstRun ? "No tasks yet" : "Nothing due today", + }); + panel.createDiv({ + cls: "vt-empty-sub", + text: firstRun + ? "Add your first with the command or the plus." + : "Enjoy the quiet, or add something.", + }); + const add = panel.createEl("button", { cls: "vt-empty-add", text: "Add task" }); + add.setAttr("type", "button"); + add.addEventListener("click", () => this.plugin.openCapture()); + } + + private renderUnconfigured(): void { + this.root.empty(); + const panel = this.root.createDiv({ cls: "vt-empty-state" }); + panel.createDiv({ cls: "vt-empty-glyph", text: "○", attr: { "aria-hidden": "true" } }); + panel.createDiv({ cls: "vt-empty-title", text: "Set up Taskline" }); + panel.createDiv({ cls: "vt-empty-sub", text: "Choose the notes and headings Taskline should use. No files will be created." }); + const open = panel.createEl("button", { cls: "vt-empty-add", text: "Open settings" }); + open.type = "button"; + open.addEventListener("click", () => this.plugin.openSettings()); + } + + private buildUpcomingEmpty(parent: HTMLElement): void { + const panel = parent.createDiv({ cls: "vt-empty-state" }); + panel.createDiv({ cls: "vt-empty-glyph", text: "○", attr: { "aria-hidden": "true" } }); + panel.createDiv({ cls: "vt-empty-title", text: "Nothing scheduled this week" }); + panel.createDiv({ cls: "vt-empty-sub", text: "The next 7 days are clear." }); + } + + private buildAllEmpty(parent: HTMLElement): void { + const panel = parent.createDiv({ cls: "vt-empty-state" }); + panel.createDiv({ cls: "vt-empty-glyph", text: "○", attr: { "aria-hidden": "true" } }); + panel.createDiv({ cls: "vt-empty-title", text: "No tasks yet" }); + panel.createDiv({ cls: "vt-empty-sub", text: "Add something to get started." }); + const add = panel.createEl("button", { cls: "vt-empty-add", text: "Add task" }); + add.setAttr("type", "button"); + add.addEventListener("click", () => this.plugin.openCapture()); + } + + private buildFilterEmpty(parent: HTMLElement): void { + const panel = parent.createDiv({ cls: "vt-empty-state" }); + panel.createDiv({ cls: "vt-empty-glyph", text: "○", attr: { "aria-hidden": "true" } }); + panel.createDiv({ cls: "vt-empty-title", text: "No tasks match" }); + panel.createDiv({ cls: "vt-empty-sub", text: "Clear the filters to see everything." }); + const clear = panel.createEl("button", { cls: "vt-empty-add", text: "Clear filters" }); + clear.setAttr("type", "button"); + clear.addEventListener("click", () => { + this.plugin.activeFilters.clear(); + this.renderNow(); + }); + } + + private buildFooter(): void { + const platform = Platform.isMobile ? "mobile" : "desktop"; + this.root.createDiv({ + cls: "vt-footer", + text: `Taskline ${this.plugin.manifest.version} · ${platform}`, + }); + } + + // ---- task actions -------------------------------------------------------- + + private onComplete(task: VtTask, row: HTMLElement): void { + if (this.completingRows.has(row)) return; // already in flight - ignore the re-fire + this.completingRows.add(row); + + this.animating++; + const token: AnimToken = { cancelled: false }; + this.animationTokens.add(token); + + animateComplete(row, token) + .then(() => { + this.animationTokens.delete(token); + this.completingRows.delete(row); + this.settle(); + }) + .catch((err) => { + if (!isCancellation(err)) { + console.error("taskline: completion animation failed", err); + this.completingRows.delete(row); + this.animationTokens.delete(token); + this.settle(); + } + // Cancellation is the write-failure path; the write catch handles revert + settle. + }); + + completeTask(this.plugin.app, task).catch((err) => { + console.error("taskline: completeTask failed", err); + token.cancelled = true; + this.animationTokens.delete(token); + revertRow(row); + this.completingRows.delete(row); + const message = err instanceof VtRecurrenceUnavailableError + ? err.message + : "Could not complete - the file changed. Try again."; + new Notice(message); + this.showRowNote(row, message); + this.settle(); + }); + } + + private onStatusMenu(task: VtTask, ring: HTMLElement, ev: MouseEvent | TouchEvent): void { + const menu = new Menu(); + const options: Array<[VtTask["status"], string, string]> = [ + ["todo", " ", "To do"], + ["in-progress", "/", "In progress"], + ["blocked", "!", "Blocked"], + ["planning", "?", "Planning"], + ]; + const annotation = `(↻ ${localIso(new Date())} from plugin)`; + for (const [status, char, label] of options) { + menu.addItem((item) => { + item + .setTitle(label) + .setChecked(task.status === status) + .onClick(() => { + setTaskStatus(this.plugin.app, task, char, annotation).catch((err) => { + console.error("taskline: setTaskStatus failed", err); + new Notice("Could not update status - the file may have changed."); + }); + }); + }); + } + menu.addSeparator(); + // Edit is offered on every open row and withheld from history rows. + if (isOpen(task)) { + menu.addItem((item) => { + item + .setTitle("Edit") + .setIcon("pencil") + .onClick(() => this.plugin.openEdit(task)); + }); + } + menu.addItem((item) => { + item + .setTitle("Done") + .setIcon("check") + .onClick(() => { + const row = ring.closest(".vt-task-row"); + if (row) this.onComplete(task, row); + }); + }); + menu.addSeparator(); + menu.addItem((item) => { + item + .setTitle("Open source note") + .setIcon("file") + .onClick(() => this.onOpenSource(task)); + }); + if ("clientX" in ev && "clientY" in ev) { + menu.showAtPosition({ x: ev.clientX, y: ev.clientY }); + } else { + const rect = ring.getBoundingClientRect(); + menu.showAtPosition({ x: rect.left, y: rect.bottom }); + } + } + + private onOpenSource(task: VtTask): void { + void this.plugin.app.workspace.openLinkText(task.filePath, task.filePath, false); + } + + // ---- proposal actions ---------------------------------------------------- + + private locateForProposal(proposal: VtProposal): VtTask | null { + const store = this.plugin.store; + return store ? findProposalTarget(proposal, store.getTasks()) : null; + } + + private onConfirm(proposal: VtProposal, row: HTMLElement): void { + const match = this.locateForProposal(proposal); + if (!match) { + new Notice( + "Could not locate the task for this proposal. Review the source note instead." + ); + return; + } + + // Write first, then play the ghost->solid transition, so a store-driven re-render never + // yanks the row before the confirm animation runs (animating>0 defers it). + this.animating++; + applyProposal(this.plugin.app, proposal, match) + .then(() => this.closed ? undefined : animateConfirm(row)) + .then(() => this.settle()) + .catch((err) => { + console.error("taskline: confirm failed", err); + this.showRowNote(row, "Could not confirm - the file changed. Try again."); + this.settle(); + }); + } + + private onReject(proposal: VtProposal, row: HTMLElement): void { + this.animating++; + removeLine(this.plugin.app, proposal) + .then(() => animateReject(row)) + .then(() => this.settle()) + .catch((err) => { + console.error("taskline: reject failed", err); + this.showRowNote(row, "Could not reject - the file changed. Try again."); + this.settle(); + }); + } + + // ---- shared UI helpers --------------------------------------------------- + + private showRowNote(row: HTMLElement, message: string): void { + row.querySelector(".vt-row-note")?.remove(); + row.createDiv({ cls: "vt-row-note", text: message }); + } + + // ---- keyboard / roving tabindex ------------------------------------------ + + private refreshRoving(): void { + this.rows = Array.from(this.root.querySelectorAll(".vt-focusable")); + this.activeRow = 0; + this.rows.forEach((r, i) => r.setAttribute("tabindex", i === 0 ? "0" : "-1")); + } + + private onFocusIn = (e: FocusEvent): void => { + const row = (e.target as HTMLElement)?.closest(".vt-focusable"); + if (!row) return; + const idx = this.rows.indexOf(row); + if (idx < 0) return; + this.rows[this.activeRow]?.setAttribute("tabindex", "-1"); + this.activeRow = idx; + row.setAttribute("tabindex", "0"); + }; + + private moveRoving(dir: 1 | -1): void { + if (this.rows.length === 0) return; + this.rows[this.activeRow]?.setAttribute("tabindex", "-1"); + this.activeRow = Math.max(0, Math.min(this.rows.length - 1, this.activeRow + dir)); + const el = this.rows[this.activeRow]; + el.setAttribute("tabindex", "0"); + el.focus(); + } + + private onKeyDown = (ev: KeyboardEvent): void => { + const active = this.root.ownerDocument.activeElement as HTMLElement | null; + + if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { + // Arrow roving is for the row list only; leave the tab bar / pills to their own handlers. + if (active && (active.closest(".vt-tabs") || active.closest(".vt-filter-pills"))) return; + if (this.rows.length === 0) return; + ev.preventDefault(); + this.moveRoving(ev.key === "ArrowDown" ? 1 : -1); + return; + } + + if (!active || !active.classList.contains("vt-focusable")) return; + const kind = active.dataset.kind; + + if (kind === "task") { + if (ev.key === " ") { + ev.preventDefault(); + this.primaryAction.get(active)?.(); + } else if (ev.key === "Enter" || ev.key === "e" || ev.key === "E") { + ev.preventDefault(); + this.editAction.get(active)?.(); + } + return; + } + + if (kind === "proposal") { + if (ev.key === "y" || ev.key === "Y" || ev.key === "Enter") { + ev.preventDefault(); + this.primaryAction.get(active)?.(); + } else if (ev.key === "n" || ev.key === "N" || ev.key === "Backspace") { + ev.preventDefault(); + this.rejectAction.get(active)?.(); + } + } + }; +} diff --git a/src/ui/areaColor.ts b/src/ui/areaColor.ts new file mode 100644 index 0000000..56dbac8 --- /dev/null +++ b/src/ui/areaColor.ts @@ -0,0 +1,31 @@ +import type { RuntimeWorkspace } from "../settings"; +import { workspaceColor } from "../settings"; + +const HASH_PALETTE = [ + "--color-orange", + "--color-blue", + "--color-green", + "--color-purple", + "--color-pink", + "--color-cyan", + "--color-yellow", + "--color-red", +]; + +function hashString(value: string): number { + let hash = 5381; + for (let i = 0; i < value.length; i++) hash = ((hash << 5) + hash + value.charCodeAt(i)) | 0; + return Math.abs(hash); +} + +function cssColor(value: string): string { + if (value.startsWith("var(") || value.startsWith("#") || value.startsWith("rgb") || value.startsWith("hsl")) return value; + return `var(${value.startsWith("--") ? value : `--${value}`})`; +} + +export function areaColor(area: string, workspace?: RuntimeWorkspace): string { + const configured = workspace ? workspaceColor(workspace, area) : undefined; + if (configured) return cssColor(configured); + const picked = HASH_PALETTE[hashString(area.trim().toLowerCase()) % HASH_PALETTE.length]; + return `var(${picked})`; +} diff --git a/src/ui/captureModal.ts b/src/ui/captureModal.ts new file mode 100644 index 0000000..91c538f --- /dev/null +++ b/src/ui/captureModal.ts @@ -0,0 +1,711 @@ +import { App, Modal, Platform } from "obsidian"; +import type VaultTasksPlugin from "../main"; +import { VtTask } from "../model"; +import { appendUnderHeading, VtStaleError } from "../writer"; +import { CaptureToken, knownCaptureTags, ParsedCapture, parseCapture, serializeCapturedTask, suggestArea } from "../captureRules"; +import { buildTaskRow } from "./taskRow"; +import { areaColor } from "./areaColor"; +import { prefersReducedMotion } from "./motion"; + +const PREVIEW_DEBOUNCE_MS = 90; +const PREVIEW_XFADE_MS = 120; + +function todayIso(d: Date): string { + const pad = (n: number) => (n < 10 ? `0${n}` : String(n)); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** CSS class for a token span in the highlight backdrop. Priority level comes from the parse + * result (the token itself is level-agnostic) so the tint matches the resolved priority. */ +function tokenClass(tok: CaptureToken, parsed: ParsedCapture): string { + switch (tok.type) { + case "date": + return "vt-tok vt-tok--date"; + case "scheduled": + return "vt-tok vt-tok--scheduled"; + case "area": + return "vt-tok vt-tok--area"; + case "recurrence": + return "vt-tok vt-tok--recurrence"; + case "owner": + return "vt-tok vt-tok--owner"; + case "priority": + return `vt-tok vt-tok--priority-${parsed.priority ?? "p3"}`; + } +} + +/** Builds a display-only VtTask from a parse result so the preview reuses the real row + * component. Never written anywhere - filePath/lineNo are empty sentinels. */ +function previewTask(parsed: ParsedCapture): VtTask { + return { + sourceId: parsed.destination?.sourceId ?? "", + filePath: "", + lineNo: 0, + rawLine: "", + status: "todo", + statusChar: " ", + title: parsed.title, + tags: parsed.tags, + priority: parsed.priority, + due: parsed.due, + scheduled: parsed.scheduled, + recurrence: parsed.recurrence, + owner: parsed.owner, + heading: "", + subNotes: [], + }; +} + +/** Shared machinery for the capture-grammar modals (quick-add + edit). Owns the live-highlight + * input, the parsed-preview row and the honest destination line; subclasses supply the seed text + * and the commit behavior. See DESIGN sections 3.4 / 5 / 7 / 9. */ +export abstract class CaptureModalBase extends Modal { + protected plugin: VaultTasksPlugin; + private field!: HTMLElement; + private backdrop!: HTMLElement; + protected input!: HTMLInputElement | HTMLTextAreaElement; + private previewWrap!: HTMLElement; + protected destLine!: HTMLElement; + private errorLine!: HTMLElement; + private previewTimer: number | null = null; + private cancelButton: HTMLButtonElement | null = null; + private saveButton: HTMLButtonElement | null = null; + private committing = false; + private opened = false; + private transientTimers = new Set(); + + // ---- '#' autocomplete menu (DESIGN §9.6) ----------------------------------- + private suggestMenu!: HTMLElement; + private suggestIdPrefix = `vt-suggest-${Math.random().toString(36).slice(2)}`; + private suggestOpen = false; + private suggestContext: { start: number; end: number } | null = null; + private suggestFiltered: string[] = []; + private suggestOptionEls: HTMLElement[] = []; + private suggestActiveIndex = -1; + + constructor(app: App, plugin: VaultTasksPlugin) { + super(app); + this.plugin = plugin; + } + + /** Text the input is seeded with on open ("" for capture, the reconstructed grammar for edit). */ + protected abstract initialValue(): string; + + /** Placeholder for the empty input. */ + protected placeholder(): string { + return "Add a task…"; + } + + /** Edit is a review-and-save task, not quick capture, so it gets explicit chrome. */ + protected modalTitle(): string | null { + return null; + } + + protected usesExplicitActions(): boolean { + return false; + } + + protected wrapsInput(): boolean { + return false; + } + + /** Optional controls inserted between the grammar field and preview. Edit uses this for + * structured date/priority controls; capture intentionally leaves the slot empty. */ + protected buildSupplementalControls(_parent: HTMLElement): void {} + + /** Lets a subclass mirror the latest parse into structured controls without creating a + * second source of truth. Called for initial state and every live refresh. */ + protected onParsedChange(_parsed: ParsedCapture): void {} + + protected inputValue(): string { + return this.input.value; + } + + /** Applies a structured-control change through the exact same highlight/preview pipeline as + * typing. Keeping this in the base prevents date/priority controls from drifting from grammar. */ + protected replaceInputValue(value: string): void { + this.input.value = value; + const end = value.length; + this.input.setSelectionRange(end, end); + this.clearError(); + this.closeSuggestMenu(); + this.syncInputHeight(); + this.paintHighlight(); + this.refreshLive(true); + } + + /** Shift+Enter keeps the modal open for rapid multi-add (capture only). */ + protected allowKeepOpen(): boolean { + return false; + } + + /** Persists the parse. Throws on write failure so the base keeps the modal open with the + * muted inline error. Return value ignored. */ + protected abstract persist(parsed: ParsedCapture, raw: string): Promise; + + close(): void { + // A modal disappearing mid-write discards the user's only copy of a failed edit. + if (this.committing) return; + super.close(); + } + + onOpen(): void { + this.opened = true; + this.modalEl.addClass("vt-capture-modal"); + if (this.usesExplicitActions()) this.modalEl.addClass("vt-edit-modal"); + if (Platform.isMobile) this.modalEl.addClass("vt-capture-modal--sheet"); + if (prefersReducedMotion()) this.modalEl.addClass("vt-reduced"); + + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("vt-capture-content"); + + const modalTitle = this.modalTitle(); + if (modalTitle) { + const header = contentEl.createDiv({ cls: "vt-edit-header" }); + header.createEl("h2", { cls: "vt-edit-title", text: modalTitle }); + header.createDiv({ cls: "vt-edit-subtitle", text: "Update the task and review where it will be saved." }); + } + + // ---- input + highlight backdrop (mirror-overlay technique) ---- + this.field = contentEl.createDiv({ cls: "vt-capture-field" }); + this.backdrop = this.field.createDiv({ cls: "vt-capture-backdrop" }); + this.backdrop.setAttr("aria-hidden", "true"); + + if (this.wrapsInput()) { + const textarea = this.field.createEl("textarea", { cls: "vt-capture-input" }); + textarea.rows = 1; + textarea.setAttr("aria-multiline", "false"); + this.input = textarea; + } else { + const input = this.field.createEl("input", { cls: "vt-capture-input" }); + input.type = "text"; + this.input = input; + } + this.input.placeholder = this.placeholder(); + this.input.setAttr("aria-label", this.placeholder()); + this.input.setAttr("autocomplete", "off"); + this.input.setAttr("autocapitalize", "off"); + this.input.setAttr("spellcheck", "false"); + + if (!this.usesExplicitActions()) { + const commitHint = this.field.createSpan({ cls: "vt-capture-hint" }); + commitHint.setText(Platform.isMobile ? "return" : "enter"); + } + + // ---- '#' autocomplete menu, anchored under the input (DESIGN §9.6) ---- + this.suggestMenu = this.field.createDiv({ cls: "vt-suggest-menu" }); + this.suggestMenu.id = `${this.suggestIdPrefix}-listbox`; + this.suggestMenu.setAttr("role", "listbox"); + this.suggestMenu.hide(); + this.input.setAttr("aria-controls", this.suggestMenu.id); + this.input.setAttr("aria-expanded", "false"); + + this.buildSupplementalControls(contentEl); + + // ---- parsed-preview row (hidden until there is input) ---- + this.previewWrap = contentEl.createDiv({ cls: "vt-capture-preview" }); + this.previewWrap.setAttr("role", "list"); + this.previewWrap.hide(); + + // ---- destination indicator (aria-live so routing changes are announced) ---- + this.destLine = contentEl.createDiv({ cls: "vt-capture-dest" }); + this.destLine.setAttr("role", "status"); + this.destLine.setAttr("aria-live", "polite"); + this.destLine.hide(); + + // ---- inline error slot (muted, never a modal alarm) ---- + this.errorLine = contentEl.createDiv({ cls: "vt-capture-error" }); + this.errorLine.hide(); + + if (this.usesExplicitActions()) { + const footer = contentEl.createDiv({ cls: "vt-edit-footer" }); + footer.createSpan({ cls: "vt-edit-shortcuts", text: "Enter to save · Esc to cancel" }); + const actions = footer.createDiv({ cls: "vt-edit-actions" }); + this.cancelButton = actions.createEl("button", { cls: "vt-edit-cancel", text: "Cancel" }); + this.cancelButton.type = "button"; + this.cancelButton.addEventListener("click", () => this.close()); + this.saveButton = actions.createEl("button", { cls: "vt-edit-save", text: "Save" }); + this.saveButton.type = "button"; + this.saveButton.addEventListener("click", () => void this.commit(false)); + } + + this.input.addEventListener("input", this.onInput); + this.input.addEventListener("keyup", this.onCaretMove); + this.input.addEventListener("click", this.onCaretMove); + this.input.addEventListener("scroll", this.syncScroll); + this.input.addEventListener("keydown", this.onKeyDown); + + // Seed value, then place the caret at the end so the user can extend the phrase. + this.input.value = this.initialValue(); + this.syncInputHeight(); + + // Autofocus. Obsidian focuses the modal container on open; defer so ours wins. + this.defer(() => { + this.input.focus(); + const end = this.input.value.length; + this.input.setSelectionRange(end, end); + }, 0); + this.paintHighlight(); + this.refreshLive(true); + } + + onClose(): void { + this.opened = false; + if (this.previewTimer !== null) window.clearTimeout(this.previewTimer); + for (const timer of this.transientTimers) window.clearTimeout(timer); + this.transientTimers.clear(); + this.contentEl.empty(); + } + + protected defer(callback: () => void, delay = 0): void { + const timer = window.setTimeout(() => { + this.transientTimers.delete(timer); + if (this.opened) callback(); + }, delay); + this.transientTimers.add(timer); + } + + // ---- live update pipeline ------------------------------------------------- + + private onInput = (): void => { + const caret = this.input.selectionStart ?? this.input.value.length; + const singleLine = this.input.value.replace(/\s*[\r\n]+\s*/g, " "); + if (singleLine !== this.input.value) { + this.input.value = singleLine; + this.input.setSelectionRange(Math.min(caret, singleLine.length), Math.min(caret, singleLine.length)); + } + this.clearError(); + this.syncInputHeight(); + this.paintHighlight(); + this.schedulePreview(); + this.refreshHashSuggest(true); + }; + + private onCaretMove = (): void => { + // Caret moved without editing: only the active-token tint changes, cheap to repaint. + this.paintHighlight(); + this.refreshHashSuggest(false); + }; + + private syncScroll = (): void => { + this.backdrop.scrollLeft = this.input.scrollLeft; + this.backdrop.scrollTop = this.input.scrollTop; + }; + + private syncInputHeight(): void { + if (!this.wrapsInput()) return; + this.input.style.height = "auto"; + this.input.style.height = `${Math.min(this.input.scrollHeight, 160)}px`; + } + + /** Repaints the highlight backdrop from the current parse. Synchronous and cheap so typing + * is never gated on the debounced preview (DESIGN anti-pattern 5). */ + private paintHighlight(): void { + const text = this.input.value; + const caret = this.input.selectionStart ?? text.length; + const parsed = parseCapture(text, new Date(), this.plugin.workspace); + + this.backdrop.empty(); + let pos = 0; + for (const tok of parsed.tokens) { + if (tok.start > pos) this.backdrop.appendText(text.slice(pos, tok.start)); + const span = this.backdrop.createSpan({ + cls: tokenClass(tok, parsed), + text: text.slice(tok.start, tok.end), + }); + if (caret >= tok.start && caret <= tok.end) span.addClass("vt-tok--active"); + pos = tok.end; + } + if (pos < text.length) this.backdrop.appendText(text.slice(pos)); + this.syncScroll(); + } + + private schedulePreview(): void { + if (this.previewTimer !== null) window.clearTimeout(this.previewTimer); + this.previewTimer = window.setTimeout(() => { + this.previewTimer = null; + this.refreshLive(false); + }, PREVIEW_DEBOUNCE_MS); + } + + /** Rebuilds the preview row + destination line from the current parse. `immediate` skips the + * crossfade (used on open). Crossfades the whole preview block (a lightweight stand-in for + * the ideal metadata-only xfade). */ + private refreshLive(immediate: boolean): void { + const text = this.input.value; + const parsed = parseCapture(text, new Date(), this.plugin.workspace); + const empty = text.trim().length === 0; + this.onParsedChange(parsed); + + if (empty) { + this.previewWrap.hide(); + this.destLine.hide(); + return; + } + + // preview row + this.previewWrap.empty(); + this.previewWrap.show(); + const previewRow = buildTaskRow(this.previewWrap, previewTask(parsed), new Date(), { + onComplete: () => {}, + onStatusMenu: () => {}, + }, this.plugin.workspace); + this.stripPreviewInteractivity(previewRow); + if (!immediate && !prefersReducedMotion()) { + this.previewWrap.addClass("vt-xfade"); + this.defer(() => this.previewWrap.removeClass("vt-xfade"), PREVIEW_XFADE_MS); + } + + // destination line + this.destLine.empty(); + this.destLine.show(); + this.renderDestination(parsed); + } + + /** The preview row reuses buildTaskRow's real markup for visual fidelity, but it is + * display-only: strip the interactive semantics (role/aria-checked/aria-label/click) so + * assistive tech and pointer input never treat it as a live control. Does not touch + * buildTaskRow itself - the live Today-view row keeps its full interactivity. */ + private stripPreviewInteractivity(row: HTMLElement): void { + row.removeAttribute("tabindex"); + row.setAttr("aria-hidden", "true"); + const ring = row.querySelector(".vt-ring"); + if (ring) { + ring.removeAttribute("role"); + ring.removeAttribute("aria-checked"); + ring.removeAttribute("aria-label"); + ring.removeAttribute("tabindex"); + ring.style.pointerEvents = "none"; + } + } + + protected renderDestination(parsed: ParsedCapture): void { + const verb = this.destinationVerb(); + const arrow = this.destLine.createSpan({ cls: "vt-dest-arrow", text: "→" }); + arrow.setAttr("aria-hidden", "true"); + + if (!parsed.destination) { + this.destLine.addClass("vt-dest--inbox"); + const suggestion = suggestArea(this.input.value, this.plugin.workspace); + if (suggestion) { + this.destLine.createSpan({ cls: "vt-dest-label", text: `${verb} requires a destination · Tab routes as` }); + const tag = this.destLine.createSpan({ + cls: "vt-dest-suggest-tag", + text: `#${suggestion.tag}`, + }); + tag.style.color = areaColor(suggestion.tag, this.plugin.workspace); + } else { + this.destLine.createSpan({ cls: "vt-dest-label", text: "No capture destination configured" }); + this.destLine.createSpan({ cls: "vt-dest-hint", text: "Open Taskline settings." }); + } + return; + } + + const source = this.plugin.workspace.sourceById.get(parsed.destination.sourceId); + this.destLine.removeClass("vt-dest--inbox"); + this.destLine.createSpan({ cls: "vt-dest-label", text: `${verb} to ${source?.label ?? parsed.destination.sourceId}` }); + this.destLine.createSpan({ cls: "vt-dest-sep", text: "›" }); + this.destLine.createSpan({ cls: "vt-dest-section", text: parsed.destination.heading }); + } + + /** Routing verb in the destination line ("filing" for capture, "saving" for edit). */ + protected destinationVerb(): string { + return "filing"; + } + + // ---- '#' autocomplete menu (DESIGN §9.6) ----------------------------------- + + /** Finds the '#area' token the caret is in or immediately after, if any. `start`/`end` bound + * the whole tag (including the '#') so an accepted suggestion can replace it wholesale; + * `prefix` is only the typed characters between '#' and the caret, used to filter. */ + private activeHashQuery(text: string, caret: number): { start: number; end: number; prefix: string } | null { + let wordStart = caret; + while (wordStart > 0 && /[\w/-]/.test(text[wordStart - 1])) wordStart--; + if (wordStart === 0 || text[wordStart - 1] !== "#") return null; + + let wordEnd = caret; + while (wordEnd < text.length && /[\w/-]/.test(text[wordEnd])) wordEnd++; + + return { start: wordStart - 1, end: wordEnd, prefix: text.slice(wordStart, caret) }; + } + + /** Recomputes the autocomplete menu from the current text + caret. `forceReset` (true from + * onInput) always re-filters and re-selects the first option; false (from onCaretMove) skips + * the rebuild when the hash-token span hasn't actually changed, so our own ArrowUp/Down + * handling (which keeps the caret in place and relies on the subsequent keyup no-op) never + * stomps the user's menu selection. */ + private refreshHashSuggest(forceReset: boolean): void { + const text = this.input.value; + const caret = this.input.selectionStart ?? text.length; + const ctx = this.activeHashQuery(text, caret); + + if (!ctx) { + this.closeSuggestMenu(); + return; + } + + const unchanged = + !forceReset && + this.suggestContext !== null && + this.suggestContext.start === ctx.start && + this.suggestContext.end === ctx.end; + if (unchanged) return; + + const prefix = ctx.prefix.toLowerCase(); + const filtered = knownCaptureTags(this.plugin.workspace).filter((a) => a.startsWith(prefix)).slice(0, 6); + if (filtered.length === 0) { + this.closeSuggestMenu(); + return; + } + + this.suggestContext = { start: ctx.start, end: ctx.end }; + this.suggestFiltered = filtered; + this.suggestActiveIndex = 0; + this.renderSuggestOptions(filtered); + this.openSuggestMenu(); + } + + private renderSuggestOptions(areas: readonly string[]): void { + this.suggestMenu.empty(); + this.suggestOptionEls = []; + areas.forEach((area, idx) => { + const opt = this.suggestMenu.createDiv({ cls: "vt-suggest-option" }); + opt.id = `${this.suggestIdPrefix}-opt-${idx}`; + opt.setAttr("role", "option"); + const hash = opt.createSpan({ cls: "vt-suggest-hash", text: "#" }); + hash.style.color = areaColor(area, this.plugin.workspace); + opt.createSpan({ cls: "vt-suggest-area", text: area }); + opt.addEventListener("mousedown", (ev) => { + // Prevent the input from losing focus before we can act on the click. + ev.preventDefault(); + this.acceptSuggestion(area); + }); + this.suggestOptionEls.push(opt); + }); + this.updateActiveOption(); + } + + private updateActiveOption(): void { + this.suggestOptionEls.forEach((opt, idx) => { + const active = idx === this.suggestActiveIndex; + opt.toggleClass("vt-suggest-option--active", active); + opt.setAttr("aria-selected", active ? "true" : "false"); + }); + const active = this.suggestOptionEls[this.suggestActiveIndex]; + if (active) this.input.setAttr("aria-activedescendant", active.id); + else this.input.removeAttribute("aria-activedescendant"); + } + + private openSuggestMenu(): void { + this.suggestOpen = true; + this.suggestMenu.show(); + this.input.setAttr("aria-expanded", "true"); + } + + private closeSuggestMenu(): void { + if (!this.suggestOpen && this.suggestContext === null) return; + this.suggestOpen = false; + this.suggestContext = null; + this.suggestFiltered = []; + this.suggestOptionEls = []; + this.suggestActiveIndex = -1; + this.suggestMenu.hide(); + this.suggestMenu.empty(); + this.input.setAttr("aria-expanded", "false"); + this.input.removeAttribute("aria-activedescendant"); + } + + private moveSuggestActive(delta: number): void { + const len = this.suggestOptionEls.length; + if (len === 0) return; + if (this.suggestActiveIndex === -1) this.suggestActiveIndex = delta > 0 ? 0 : len - 1; + else this.suggestActiveIndex = (this.suggestActiveIndex + delta + len) % len; + this.updateActiveOption(); + } + + private acceptActiveSuggestion(): void { + const idx = this.suggestActiveIndex === -1 ? 0 : this.suggestActiveIndex; + const area = this.suggestFiltered[idx]; + if (area) this.acceptSuggestion(area); + else this.closeSuggestMenu(); + } + + /** Replaces the in-progress '#tag' span with the full tag + a trailing space, then re-runs + * the same live pipeline a normal keystroke would (highlight + preview + destination). */ + private acceptSuggestion(area: string): void { + const ctx = this.suggestContext; + this.closeSuggestMenu(); + if (!ctx) return; + + const text = this.input.value; + const insertion = `#${area} `; + const newValue = text.slice(0, ctx.start) + insertion + text.slice(ctx.end); + const caretPos = ctx.start + insertion.length; + + this.input.value = newValue; + this.input.setSelectionRange(caretPos, caretPos); + this.input.focus(); + this.clearError(); + this.paintHighlight(); + this.refreshLive(true); + } + + // ---- keyword area suggestion (Tab to file) - DESIGN §9.6 ------------------- + + /** Applies the keyword-derived suggestArea hit (if any) by appending ' #' to the input + * and re-running the live pipeline immediately - the same routing pipeline capture already + * uses picks the new destination up on its own. No-op when there is nothing to apply. */ + private applyKeywordSuggestion(): boolean { + const text = this.input.value; + const parsed = parseCapture(text, new Date(), this.plugin.workspace); + const fallback = this.plugin.settings.fallbackCaptureDestination; + if (parsed.destination && JSON.stringify(parsed.destination) !== JSON.stringify(fallback)) return false; + const suggestion = suggestArea(text, this.plugin.workspace); + if (!suggestion) return false; + + const newValue = `${text} #${suggestion.tag}`; + this.input.value = newValue; + const end = newValue.length; + this.input.setSelectionRange(end, end); + this.input.focus(); + this.clearError(); + this.paintHighlight(); + this.refreshLive(true); + return true; + } + + // ---- commit --------------------------------------------------------------- + + private onKeyDown = (ev: KeyboardEvent): void => { + if (ev.isComposing) return; + + if (this.suggestOpen) { + if (ev.key === "ArrowDown") { + ev.preventDefault(); + this.moveSuggestActive(1); + return; + } + if (ev.key === "ArrowUp") { + ev.preventDefault(); + this.moveSuggestActive(-1); + return; + } + if (ev.key === "Tab" && !ev.shiftKey) { + ev.preventDefault(); + this.acceptActiveSuggestion(); + return; + } + if (ev.key === "Enter") { + // Menu is open: Enter accepts the highlighted option and must NOT also commit the capture. + ev.preventDefault(); + this.acceptActiveSuggestion(); + return; + } + if (ev.key === "Escape") { + // Closes the menu only; stop the event reaching Obsidian's modal-level Escape handler. + ev.preventDefault(); + ev.stopPropagation(); + this.closeSuggestMenu(); + return; + } + } + + if (ev.key === "Tab" && !ev.shiftKey) { + const applied = this.applyKeywordSuggestion(); + if (applied || !this.usesExplicitActions()) ev.preventDefault(); + return; + } + + if (ev.key !== "Enter") return; + ev.preventDefault(); + void this.commit(this.allowKeepOpen() && ev.shiftKey); + }; + + protected async commit(keepOpen: boolean): Promise { + if (this.committing) return; + const raw = this.input.value; + if (raw.trim().length === 0) return; // empty + Enter = no-op + + const parsed = parseCapture(raw, new Date(), this.plugin.workspace); + this.setCommitting(true); + try { + await this.persist(parsed, raw); + } catch (err) { + this.setCommitting(false); + this.showError(err); + this.input.focus(); + return; // keep the modal open, keep the text + } + + if (keepOpen) { + this.setCommitting(false); + this.input.value = ""; + this.clearError(); + this.closeSuggestMenu(); + this.paintHighlight(); + this.refreshLive(true); + this.input.focus(); + } else { + this.setCommitting(false); + this.close(); + } + } + + private setCommitting(committing: boolean): void { + this.committing = committing; + this.modalEl.toggleClass("vt-edit-modal--saving", committing); + const closeButton = this.modalEl.querySelector(".modal-close-button"); + if (closeButton) closeButton.setAttr("aria-disabled", committing ? "true" : "false"); + this.input.disabled = committing; + if (this.cancelButton) this.cancelButton.disabled = committing; + if (this.saveButton) { + this.saveButton.disabled = committing; + this.saveButton.setText(committing ? "Saving…" : "Save"); + } + } + + // ---- inline error --------------------------------------------------------- + + protected showError(err: unknown): void { + const reason = + err instanceof VtStaleError || err instanceof Error + ? err.message.replace(/^taskline:\s*/, "") + : "unknown reason"; + this.errorLine.empty(); + this.errorLine.show(); + this.errorLine.setText(`${this.errorVerb()} - ${reason}. Kept your text.`); + } + + protected errorVerb(): string { + return "Could not file"; + } + + protected clearError(): void { + if (this.errorLine) { + this.errorLine.hide(); + this.errorLine.empty(); + } + } +} + +/** Quick-capture modal: single-line input with live token highlighting, a real parsed-preview + * row, and an honest destination line. Desktop = centered card; mobile = bottom sheet + the + * FAB in the Today view invokes it. See DESIGN section 3.4 / 5 / 6 / 7. */ +export class CaptureModal extends CaptureModalBase { + protected initialValue(): string { + return ""; + } + + protected allowKeepOpen(): boolean { + return true; + } + + protected async persist(parsed: ParsedCapture, _raw: string): Promise { + if (!parsed.destination) throw new VtStaleError("taskline: no capture destination configured"); + const source = this.plugin.workspace.sourceById.get(parsed.destination.sourceId); + if (!source) throw new VtStaleError(`taskline: source not found: ${parsed.destination.sourceId}`); + const line = serializeCapturedTask(parsed, todayIso(new Date())); + await appendUnderHeading(this.app, source.path, parsed.destination.heading, line); + } +} diff --git a/src/ui/editModal.ts b/src/ui/editModal.ts new file mode 100644 index 0000000..f7ad754 --- /dev/null +++ b/src/ui/editModal.ts @@ -0,0 +1,402 @@ +import { App, setIcon } from "obsidian"; +import type VaultTasksPlugin from "../main"; +import { VtTask } from "../model"; +import { relativeDateLabel, serializeTask } from "../format"; +import { mergeEditDates, ParsedCapture, resolveEditDestination, taskToCaptureString } from "../captureRules"; +import { + calendarDates, + EditablePriority, + localIso, + parseLocalIso, + quickDates, + setCaptureDate, + setCapturePriority, +} from "../editProperties"; +import { moveTaskLine, replaceTaskLine } from "../writer"; +import { CaptureModalBase } from "./captureModal"; + +const PRIORITIES: Array<{ value: EditablePriority; label: string; detail: string }> = [ + { value: "p1", label: "Urgent", detail: "Priority 1" }, + { value: "p2", label: "High", detail: "Priority 2" }, + { value: "p3", label: "Medium", detail: "Priority 3" }, + { value: "p4", label: "Low", detail: "Priority 4" }, + { value: null, label: "None", detail: "No priority" }, +]; + +const PRIORITY_LABEL: Record = { + p1: "Urgent", + p2: "High", + p3: "Medium", + p4: "Low", +}; + +function menuDateLabel(iso: string): string { + return parseLocalIso(iso).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }); +} + +/** Edit modal: reuses the whole capture-grammar machinery (live highlight, preview row, + * destination line) but seeds the input from the existing task and commits by REPLACING the + * task's line in place - or MOVING its block if the area changed the destination file/heading. + * Provenance, owner, stale flag, status char and any '📝' sub-bullets are preserved. Opened for + * any open row. Sources with editPolicy "stay" are pinned to their own file + heading by + * resolveEditDestination. Not opened for done/cancelled tasks + * (the view withholds the affordance). See DESIGN §9. */ +export class VtEditModal extends CaptureModalBase { + private task: VtTask; + private dateTrigger: HTMLButtonElement | null = null; + private priorityTrigger: HTMLButtonElement | null = null; + private dateValue: HTMLElement | null = null; + private priorityValue: HTMLElement | null = null; + private datePanel: HTMLElement | null = null; + private priorityPanel: HTMLElement | null = null; + private calendarWrap: HTMLElement | null = null; + private calendarMonth = new Date(); + private selectedDate: string | undefined; + private selectedPriority: EditablePriority = null; + private dateButtons = new Map(); + private priorityButtons: Array<{ value: EditablePriority; button: HTMLButtonElement }> = []; + private controlId = `vt-edit-property-${Math.random().toString(36).slice(2)}`; + + constructor(app: App, plugin: VaultTasksPlugin, task: VtTask) { + super(app, plugin); + this.task = task; + } + + onOpen(): void { + super.onOpen(); + this.modalEl.addEventListener("keydown", this.onModalKeyDown); + } + + onClose(): void { + this.modalEl.removeEventListener("keydown", this.onModalKeyDown); + super.onClose(); + } + + protected initialValue(): string { + return taskToCaptureString(this.task); + } + + protected placeholder(): string { + return "Edit task…"; + } + + protected modalTitle(): string { + return "Edit task"; + } + + protected usesExplicitActions(): boolean { + return true; + } + + protected wrapsInput(): boolean { + return true; + } + + protected destinationVerb(): string { + return "saving"; + } + + protected buildSupplementalControls(parent: HTMLElement): void { + const properties = parent.createDiv({ cls: "vt-edit-properties" }); + properties.setAttr("aria-label", "Task properties"); + const toolbar = properties.createDiv({ cls: "vt-edit-property-bar" }); + + this.dateTrigger = this.buildPropertyTrigger(toolbar, "calendar-days", "Date"); + this.dateValue = this.dateTrigger.querySelector(".vt-edit-property-value"); + this.dateTrigger.setAttr("aria-controls", `${this.controlId}-date`); + this.dateTrigger.addEventListener("click", () => this.togglePanel("date")); + + this.priorityTrigger = this.buildPropertyTrigger(toolbar, "flag", "Priority"); + this.priorityValue = this.priorityTrigger.querySelector(".vt-edit-property-value"); + this.priorityTrigger.setAttr("aria-controls", `${this.controlId}-priority`); + this.priorityTrigger.addEventListener("click", () => this.togglePanel("priority")); + + this.datePanel = properties.createDiv({ cls: "vt-edit-property-panel vt-edit-property-panel--date" }); + this.datePanel.id = `${this.controlId}-date`; + this.datePanel.setAttr("aria-label", "Choose a date"); + this.datePanel.hide(); + this.buildDatePanel(this.datePanel); + + this.priorityPanel = properties.createDiv({ cls: "vt-edit-property-panel vt-edit-property-panel--priority" }); + this.priorityPanel.id = `${this.controlId}-priority`; + this.priorityPanel.setAttr("aria-label", "Choose a priority"); + this.priorityPanel.hide(); + this.buildPriorityPanel(this.priorityPanel); + + properties.addEventListener("keydown", (event) => this.onPropertyKeyDown(event)); + } + + protected onParsedChange(parsed: ParsedCapture): void { + this.selectedDate = parsed.due ?? parsed.scheduled; + this.selectedPriority = parsed.priority; + + if (this.dateValue) { + this.dateValue.setText(this.selectedDate ? relativeDateLabel(this.selectedDate, new Date()) : "Set date"); + } + if (this.priorityValue) { + this.priorityValue.setText(this.selectedPriority ? PRIORITY_LABEL[this.selectedPriority] : "None"); + } + this.dateButtons.forEach((button, iso) => { + const selected = iso === this.selectedDate; + button.toggleClass("vt-edit-menu-item--selected", selected); + button.setAttr("aria-pressed", selected ? "true" : "false"); + }); + this.priorityButtons.forEach(({ value, button }) => { + const selected = value === this.selectedPriority; + button.toggleClass("vt-edit-menu-item--selected", selected); + button.setAttr("aria-pressed", selected ? "true" : "false"); + }); + } + + protected renderDestination(parsed: ParsedCapture): void { + const source = this.plugin.workspace.sourceById.get(this.task.sourceId); + if (source?.editPolicy !== "stay") { + super.renderDestination(parsed); + return; + } + + this.destLine.removeClass("vt-dest--inbox"); + const arrow = this.destLine.createSpan({ cls: "vt-dest-arrow", text: "→" }); + arrow.setAttr("aria-hidden", "true"); + this.destLine.createSpan({ cls: "vt-dest-label", text: `saving to ${source.label}` }); + this.destLine.createSpan({ cls: "vt-dest-sep", text: "›" }); + this.destLine.createSpan({ cls: "vt-dest-section", text: this.task.heading }); + } + + private buildPropertyTrigger(parent: HTMLElement, iconName: string, label: string): HTMLButtonElement { + const button = parent.createEl("button", { cls: "vt-edit-property-trigger" }); + button.type = "button"; + button.setAttr("aria-expanded", "false"); + const icon = button.createSpan({ cls: "vt-edit-property-icon", attr: { "aria-hidden": "true" } }); + setIcon(icon, iconName); + const copy = button.createSpan({ cls: "vt-edit-property-copy" }); + copy.createSpan({ cls: "vt-edit-property-label", text: label }); + copy.createSpan({ cls: "vt-edit-property-value", text: label === "Date" ? "Set date" : "None" }); + const chevron = button.createSpan({ cls: "vt-edit-property-chevron", attr: { "aria-hidden": "true" } }); + setIcon(chevron, "chevron-down"); + return button; + } + + private buildDatePanel(panel: HTMLElement): void { + const shortcuts = panel.createDiv({ cls: "vt-edit-menu-list" }); + for (const option of quickDates(new Date())) { + const button = this.buildMenuItem(shortcuts, option.label, menuDateLabel(option.iso), "calendar"); + button.addEventListener("click", () => this.applyDate(option.iso)); + this.dateButtons.set(option.iso, button); + } + + const custom = this.buildMenuItem(shortcuts, "Choose date", "Open calendar", "ellipsis"); + custom.addEventListener("click", () => { + const base = this.selectedDate ? parseLocalIso(this.selectedDate) : new Date(); + this.calendarMonth = new Date(base.getFullYear(), base.getMonth(), 1); + this.renderCalendar(); + this.calendarWrap?.show(); + this.calendarWrap?.querySelector('.vt-calendar-day[tabindex="0"]')?.focus(); + }); + + const clear = this.buildMenuItem(shortcuts, "Clear date", "Remove date", "x"); + clear.addClass("vt-edit-menu-item--clear"); + clear.addEventListener("click", () => this.applyDate(null)); + + this.calendarWrap = panel.createDiv({ cls: "vt-edit-calendar" }); + this.calendarWrap.hide(); + } + + private buildPriorityPanel(panel: HTMLElement): void { + const list = panel.createDiv({ cls: "vt-edit-menu-list vt-edit-priority-list" }); + for (const option of PRIORITIES) { + const button = this.buildMenuItem(list, option.label, option.detail, "flag"); + button.dataset.priority = option.value ?? "none"; + button.addEventListener("click", () => this.applyPriority(option.value)); + this.priorityButtons.push({ value: option.value, button }); + } + } + + private buildMenuItem(parent: HTMLElement, label: string, detail: string, iconName: string): HTMLButtonElement { + const button = parent.createEl("button", { cls: "vt-edit-menu-item" }); + button.type = "button"; + const icon = button.createSpan({ cls: "vt-edit-menu-icon", attr: { "aria-hidden": "true" } }); + setIcon(icon, iconName); + button.createSpan({ cls: "vt-edit-menu-label", text: label }); + button.createSpan({ cls: "vt-edit-menu-detail", text: detail }); + return button; + } + + private togglePanel(kind: "date" | "priority"): void { + const panel = kind === "date" ? this.datePanel : this.priorityPanel; + const trigger = kind === "date" ? this.dateTrigger : this.priorityTrigger; + const opening = panel?.style.display === "none"; + this.closePanels(); + if (!opening || !panel || !trigger) return; + panel.show(); + trigger.setAttr("aria-expanded", "true"); + this.defer(() => panel.querySelector("button")?.focus()); + } + + private closePanels(returnFocus?: HTMLButtonElement | null): void { + this.datePanel?.hide(); + this.priorityPanel?.hide(); + this.calendarWrap?.hide(); + this.dateTrigger?.setAttr("aria-expanded", "false"); + this.priorityTrigger?.setAttr("aria-expanded", "false"); + returnFocus?.focus(); + } + + private applyDate(iso: string | null): void { + this.replaceInputValue(setCaptureDate(this.inputValue(), iso, new Date(), this.plugin.workspace)); + this.closePanels(this.dateTrigger); + } + + private applyPriority(priority: EditablePriority): void { + this.replaceInputValue(setCapturePriority(this.inputValue(), priority, new Date(), this.plugin.workspace)); + this.closePanels(this.priorityTrigger); + } + + private renderCalendar(): void { + const wrap = this.calendarWrap; + if (!wrap) return; + wrap.empty(); + + const header = wrap.createDiv({ cls: "vt-calendar-header" }); + const previous = header.createEl("button", { cls: "vt-calendar-nav" }); + previous.type = "button"; + previous.setAttr("aria-label", "Previous month"); + setIcon(previous, "chevron-left"); + const monthLabel = header.createSpan({ + cls: "vt-calendar-month", + text: this.calendarMonth.toLocaleDateString(undefined, { month: "long", year: "numeric" }), + }); + monthLabel.setAttr("aria-live", "polite"); + const next = header.createEl("button", { cls: "vt-calendar-nav" }); + next.type = "button"; + next.setAttr("aria-label", "Next month"); + setIcon(next, "chevron-right"); + previous.addEventListener("click", () => this.changeMonth(-1, "previous")); + next.addEventListener("click", () => this.changeMonth(1, "next")); + + const weekdays = wrap.createDiv({ cls: "vt-calendar-weekdays", attr: { "aria-hidden": "true" } }); + for (const day of ["S", "M", "T", "W", "T", "F", "S"]) weekdays.createSpan({ text: day }); + + const grid = wrap.createDiv({ cls: "vt-calendar-grid" }); + grid.setAttr("role", "group"); + grid.setAttr("aria-label", "Calendar dates"); + const dates = calendarDates(this.calendarMonth); + const visible = new Set(dates.map(localIso)); + const today = localIso(new Date()); + const focusIso = this.selectedDate && visible.has(this.selectedDate) + ? this.selectedDate + : visible.has(today) + ? today + : localIso(new Date(this.calendarMonth.getFullYear(), this.calendarMonth.getMonth(), 1)); + for (const date of dates) { + const iso = localIso(date); + const button = grid.createEl("button", { cls: "vt-calendar-day", text: String(date.getDate()) }); + button.type = "button"; + button.dataset.date = iso; + button.tabIndex = iso === focusIso ? 0 : -1; + button.setAttr("aria-label", date.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" })); + button.setAttr("aria-pressed", iso === this.selectedDate ? "true" : "false"); + if (date.getMonth() !== this.calendarMonth.getMonth()) button.addClass("vt-calendar-day--outside"); + if (iso === today) button.addClass("vt-calendar-day--today"); + if (iso === this.selectedDate) button.addClass("vt-calendar-day--selected"); + button.addEventListener("click", () => this.applyDate(iso)); + } + } + + private changeMonth(delta: number, focus: "previous" | "next"): void { + this.calendarMonth = new Date(this.calendarMonth.getFullYear(), this.calendarMonth.getMonth() + delta, 1); + this.renderCalendar(); + const label = focus === "previous" ? "Previous month" : "Next month"; + this.calendarWrap?.querySelector(`button[aria-label="${label}"]`)?.focus(); + } + + private onPropertyKeyDown(event: KeyboardEvent): void { + const target = event.target as HTMLElement; + if (target.classList.contains("vt-calendar-day")) { + const delta = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : event.key === "ArrowUp" ? -7 : event.key === "ArrowDown" ? 7 : 0; + if (delta === 0) return; + event.preventDefault(); + const current = parseLocalIso(target.dataset.date ?? localIso(new Date())); + current.setDate(current.getDate() + delta); + const targetIso = localIso(current); + let next = this.calendarWrap?.querySelector(`.vt-calendar-day[data-date="${targetIso}"]`); + if (!next) { + this.calendarMonth = new Date(current.getFullYear(), current.getMonth(), 1); + this.renderCalendar(); + next = this.calendarWrap?.querySelector(`.vt-calendar-day[data-date="${targetIso}"]`); + } + this.calendarWrap?.querySelectorAll(".vt-calendar-day").forEach((button) => { + button.tabIndex = button === next ? 0 : -1; + }); + next?.focus(); + return; + } + + if (!target.classList.contains("vt-edit-menu-item")) return; + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + const buttons = Array.from(target.parentElement?.querySelectorAll(".vt-edit-menu-item") ?? []); + const index = buttons.indexOf(target as HTMLButtonElement); + const next = event.key === "ArrowDown" ? Math.min(buttons.length - 1, index + 1) : Math.max(0, index - 1); + event.preventDefault(); + buttons[next]?.focus(); + } + + private onModalKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + const dateOpen = this.datePanel?.style.display !== "none"; + const priorityOpen = this.priorityPanel?.style.display !== "none"; + if (!dateOpen && !priorityOpen) return; + event.preventDefault(); + event.stopPropagation(); + this.closePanels(dateOpen ? this.dateTrigger : this.priorityTrigger); + }; + + protected errorVerb(): string { + return "Could not save"; + } + + protected async persist(parsed: ParsedCapture): Promise { + const dest = resolveEditDestination(this.task, parsed, this.plugin.workspace); + const destSource = this.plugin.workspace.sourceById.get(dest.sourceId); + if (!destSource) throw new Error(`Taskline source not found: ${dest.sourceId}`); + + const newLine = this.buildEditedLine(parsed, dest.sourceId); + const moved = destSource.path !== this.task.filePath || dest.heading !== this.task.heading; + + if (moved) { + await moveTaskLine(this.app, this.task, newLine, { file: destSource.path, heading: dest.heading }); + } else { + await replaceTaskLine(this.app, this.task, newLine); + } + } + + /** Rebuilds the task line from the parse while preserving everything the capture grammar can't + * express: existing provenance, owner, stale flag, status char, and done/cancelled dates. + * The destination source identity is updated when an edit routes across sources. */ + private buildEditedLine(parsed: ParsedCapture, destSourceId: string): string { + const dates = mergeEditDates(this.task, parsed); + const edited: VtTask = { + sourceId: destSourceId, + filePath: this.task.filePath, + lineNo: this.task.lineNo, + rawLine: this.task.rawLine, + indent: this.task.indent, + status: this.task.status, + statusChar: this.task.statusChar, + title: parsed.title, + tags: parsed.tags, + priority: parsed.priority, + due: dates.due, + scheduled: dates.scheduled, + recurrence: parsed.recurrence, + provenance: this.task.provenance, + owner: parsed.owner, + stale: this.task.stale, + doneDate: this.task.doneDate, + cancelledDate: this.task.cancelledDate, + heading: this.task.heading, + subNotes: [], + }; + return serializeTask(edited); + } +} diff --git a/src/ui/longPress.ts b/src/ui/longPress.ts new file mode 100644 index 0000000..22b0281 --- /dev/null +++ b/src/ui/longPress.ts @@ -0,0 +1,13 @@ +export class LongPressClickGuard { + private suppressNextClick = false; + + fired(): void { + this.suppressNextClick = true; + } + + consumeClick(): boolean { + if (!this.suppressNextClick) return false; + this.suppressNextClick = false; + return true; + } +} diff --git a/src/ui/motion.ts b/src/ui/motion.ts new file mode 100644 index 0000000..fdc752a --- /dev/null +++ b/src/ui/motion.ts @@ -0,0 +1,120 @@ +// UI motion sequences. No 'obsidian' import - pure DOM + timers so the timings in DESIGN +// section 4 live in exactly one place. Every sequence has a prefers-reduced-motion branch +// that preserves the state change while dropping the travel. + +export interface AnimToken { + cancelled: boolean; +} + +const CANCELLED = Symbol("vt-anim-cancelled"); + +export function prefersReducedMotion(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function checkpoint(token?: AnimToken): void { + if (token?.cancelled) throw CANCELLED; +} + +export function isCancellation(err: unknown): boolean { + return err === CANCELLED; +} + +/** Collapses a row to zero height then resolves. One-shot max-height animation (allowed off + * the typing path per DESIGN section 4). Reduced motion -> instant. */ +async function collapseRow(row: HTMLElement, reduced: boolean): Promise { + if (reduced) { + row.style.display = "none"; + return; + } + const start = row.scrollHeight; + row.style.maxHeight = `${start}px`; + row.style.overflow = "hidden"; + // Force reflow so the transition has a start value to animate from. + void row.offsetHeight; + row.classList.add("vt-collapsing"); + row.style.maxHeight = "0px"; + row.style.opacity = "0"; + await sleep(160); +} + +/** Completion path: check draw -> flash -> fade+strike -> collapse (~680ms). Throws the + * cancellation sentinel if the token is tripped mid-sequence (write failed -> caller reverts). */ +export async function animateComplete(row: HTMLElement, token: AnimToken): Promise { + const ring = row.querySelector(".vt-ring"); + ring?.setAttribute("aria-checked", "true"); + + if (prefersReducedMotion()) { + row.classList.add("vt-completing", "vt-reduced"); + await sleep(400); + checkpoint(token); + await collapseRow(row, true); + return; + } + + row.classList.add("vt-completing"); + await sleep(180); + checkpoint(token); + + row.classList.add("vt-check-flash"); + await sleep(140); + checkpoint(token); + + row.classList.add("vt-fading"); + await sleep(200); + checkpoint(token); + + await collapseRow(row, false); +} + +/** Proposed reject: fade + collapse, no strike (160ms). */ +export async function animateReject(row: HTMLElement): Promise { + const reduced = prefersReducedMotion(); + row.classList.add("vt-rejecting"); + if (reduced) { + await collapseRow(row, true); + return; + } + await collapseRow(row, false); +} + +/** Proposed confirm: ghost -> solid (~200ms) then collapse out, since confirming completes + * the matched task and removes the proposal line. */ +export async function animateConfirm(row: HTMLElement): Promise { + const reduced = prefersReducedMotion(); + row.classList.add("vt-confirming"); + if (reduced) { + await collapseRow(row, true); + return; + } + await sleep(200); + await collapseRow(row, false); +} + +/** Undoes the visual side effects of a failed optimistic action, restoring the row so the + * user can retry. Idempotent. */ +export function revertRow(row: HTMLElement): void { + row.classList.remove( + "vt-completing", + "vt-check-flash", + "vt-fading", + "vt-collapsing", + "vt-rejecting", + "vt-confirming", + "vt-reduced" + ); + row.style.removeProperty("max-height"); + row.style.removeProperty("overflow"); + row.style.removeProperty("opacity"); + row.style.removeProperty("display"); + const ring = row.querySelector(".vt-ring"); + ring?.setAttribute("aria-checked", "false"); +} diff --git a/src/ui/proposedRow.ts b/src/ui/proposedRow.ts new file mode 100644 index 0000000..b74975a --- /dev/null +++ b/src/ui/proposedRow.ts @@ -0,0 +1,62 @@ +import { VtProposal } from "../model"; + +export interface ProposedRowHandlers { + onConfirm(proposal: VtProposal, row: HTMLElement): void; + onReject(proposal: VtProposal, row: HTMLElement): void; +} + +/** Machine-suggested task awaiting human confirm. Reads as provisional per DESIGN section + * 3.3: dashed ghost ring, reduced opacity, an evidence receipt line, inline confirm/reject. */ +export function buildProposedRow( + parent: HTMLElement, + proposal: VtProposal, + handlers: ProposedRowHandlers +): HTMLElement { + const row = parent.createDiv({ cls: "vt-proposed-row vt-focusable" }); + row.dataset.kind = "proposal"; + row.setAttr("role", "listitem"); + + const top = row.createDiv({ cls: "vt-proposed-top" }); + + const ring = top.createDiv({ cls: "vt-ring vt-ring--ghost" }); + ring.setAttr("aria-hidden", "true"); + + top.createSpan({ cls: "vt-proposed-title", text: proposal.text }); + + const actions = top.createDiv({ cls: "vt-proposed-actions" }); + const confirm = actions.createEl("button", { cls: "vt-action vt-action--confirm", text: "✓" }); + confirm.setAttr("aria-label", `Confirm proposal: ${proposal.text}`); + confirm.setAttr("type", "button"); + // Row-level Y/N keyboard already drives confirm/reject; these are not extra Tab stops. + confirm.setAttr("tabindex", "-1"); + const reject = actions.createEl("button", { cls: "vt-action vt-action--reject", text: "✗" }); + reject.setAttr("aria-label", `Reject proposal: ${proposal.text}`); + reject.setAttr("type", "button"); + reject.setAttr("tabindex", "-1"); + + // Evidence receipt always renders: quote when present, else source-only attribution, + // else an explicit "no evidence" note - never silently absent. + const ev = row.createDiv({ cls: "vt-proposed-evidence" }); + ev.createSpan({ cls: "vt-evidence-arrow", text: "↳", attr: { "aria-hidden": "true" } }); + if (proposal.evidence) { + ev.createSpan({ cls: "vt-evidence-quote", text: `"${proposal.evidence}"` }); + if (proposal.source) { + ev.createSpan({ cls: "vt-evidence-source", text: `- ${proposal.source}` }); + } + } else if (proposal.source) { + ev.createSpan({ cls: "vt-evidence-source", text: proposal.source }); + } else { + ev.createSpan({ cls: "vt-evidence-quote", text: "No evidence recorded" }); + } + + confirm.addEventListener("click", (e) => { + e.stopPropagation(); + handlers.onConfirm(proposal, row); + }); + reject.addEventListener("click", (e) => { + e.stopPropagation(); + handlers.onReject(proposal, row); + }); + + return row; +} diff --git a/src/ui/taskRow.ts b/src/ui/taskRow.ts new file mode 100644 index 0000000..8315d42 --- /dev/null +++ b/src/ui/taskRow.ts @@ -0,0 +1,211 @@ +import { setIcon } from "obsidian"; +import { VtTask } from "../model"; +import { relativeDateLabel } from "../format"; +import { areaColor } from "./areaColor"; +import type { RuntimeWorkspace } from "../settings"; +import { effectiveTaskIso } from "../query"; +import { LongPressClickGuard } from "./longPress"; + +export interface TaskRowHandlers { + onComplete(task: VtTask, row: HTMLElement): void; + onStatusMenu(task: VtTask, ring: HTMLElement, ev: MouseEvent | TouchEvent): void; + /** Opens the edit modal. Omitted in read-only contexts (capture preview). */ + onEdit?(task: VtTask): void; +} + +/** A task is editable when it is open and an edit handler is wired. History rows show no pencil. */ +function isEditable(task: VtTask, handlers: TaskRowHandlers): boolean { + return ( + !!handlers.onEdit && + task.status !== "done" && + task.status !== "cancelled" + ); +} + +function priorityToken(task: VtTask): "p1" | "p2" | "p3" | "p4" { + return task.priority ?? "p4"; +} + +/** The temporal bucket of a task date relative to today. Drives the date-pill color per + * DESIGN §3.2: overdue = red, today = green, tomorrow = orange, further out = muted. */ +function dayBucket(iso: string, today: Date): "overdue" | "today" | "tomorrow" | "future" { + const [y, m, d] = iso.split("T")[0].split("-").map((n) => parseInt(n, 10)); + const dt = new Date(y, m - 1, d); + const todayMid = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const diff = Math.round((dt.getTime() - todayMid.getTime()) / 86400000); + if (diff < 0) return "overdue"; + if (diff === 0) return "today"; + if (diff === 1) return "tomorrow"; + return "future"; +} + +/** Builds the priority-ring checkbox. A hollow ring plus an SVG check that draws in on + * completion. The ring is the completion control; the row is the roving-tabindex stop. */ +function buildRing(parent: HTMLElement, task: VtTask): HTMLElement { + const ring = parent.createDiv({ cls: "vt-ring" }); + ring.dataset.priority = priorityToken(task); + ring.dataset.status = task.status; + ring.setAttr("role", "checkbox"); + ring.setAttr("aria-checked", "false"); + ring.setAttr("aria-label", `Complete task: ${task.title}`); + + const svg = ring.createSvg("svg", { + cls: "vt-check", + attr: { viewBox: "0 0 24 24", "aria-hidden": "true" }, + }); + svg.createSvg("path", { + attr: { + d: "M5 12.5 L10 17.5 L19 6.5", + fill: "none", + "stroke-linecap": "round", + "stroke-linejoin": "round", + pathLength: "1", + }, + }); + return ring; +} + +function buildDatePill(meta: HTMLElement, task: VtTask, today: Date): void { + // Due is the default weight; scheduled-only renders fainter/italic. Never both raw emoji. + const iso = effectiveTaskIso(task); + const usingScheduled = !!iso && iso === task.scheduled && iso !== task.due; + if (!iso) return; + + const pill = meta.createSpan({ cls: "vt-date-pill", text: relativeDateLabel(iso, today) }); + if (usingScheduled) pill.addClass("vt-date-pill--scheduled"); + const bucket = dayBucket(iso, today); + if (bucket === "overdue") pill.addClass("vt-date-pill--overdue"); + else if (bucket === "today") pill.addClass("vt-date-pill--today"); + else if (bucket === "tomorrow") pill.addClass("vt-date-pill--tomorrow"); +} + +function buildAreaMetadata(meta: HTMLElement, task: VtTask, workspace: RuntimeWorkspace): void { + const source = workspace.sourceById.get(task.sourceId); + const group = source?.groupId ? workspace.groupById.get(source.groupId) : undefined; + if (group?.ownerDisplay) { + if (task.owner && !workspace.selfAliases.has(task.owner.trim().toLowerCase())) { + const initials = task.owner + .split(/[\s_]+/) + .map((p) => p[0]) + .filter(Boolean) + .slice(0, 2) + .join("") + .toUpperCase(); + const chip = meta.createSpan({ cls: "vt-owner-chip", text: initials }); + chip.setAttr("aria-label", `Owner ${task.owner}`); + } + return; + } + + for (const area of task.tags) { + const route = workspace.routeByTag.get(area); + if (route && !route.showAsChip) continue; + const chip = meta.createSpan({ cls: "vt-area-chip" }); + // The '#' glyph carries the area's full accent color (Todoist-style); the label stays muted. + chip.style.setProperty("--vt-area-color", areaColor(area, workspace)); + chip.createSpan({ cls: "vt-area-hash", text: "#" }); + chip.createSpan({ cls: "vt-area-label", text: area }); + } +} + +export function buildTaskRow( + parent: HTMLElement, + task: VtTask, + today: Date, + handlers: TaskRowHandlers, + workspace: RuntimeWorkspace +): HTMLElement { + const row = parent.createDiv({ cls: "vt-task-row vt-focusable" }); + row.dataset.kind = "task"; + row.setAttr("role", "listitem"); + + const ring = buildRing(row, task); + + const main = row.createDiv({ cls: "vt-row-main" }); + + if (task.status === "blocked") { + main.createSpan({ cls: "vt-blocked-dot", attr: { "aria-hidden": "true" } }); + } + + const title = main.createSpan({ cls: "vt-task-title", text: task.title }); + if (task.status === "blocked" || task.status === "planning") { + title.addClass("vt-task-title--muted"); + } + + const meta = row.createDiv({ cls: "vt-row-meta" }); + buildDatePill(meta, task, today); + buildAreaMetadata(meta, task, workspace); + + if (task.recurrence) { + const rec = meta.createSpan({ cls: "vt-recur", text: "↻" }); + rec.setAttr("aria-label", "Repeats"); + rec.setAttr("title", `Repeats ${task.recurrence}`); + } + + if (task.stale) { + const dot = meta.createSpan({ cls: "vt-stale-dot" }); + dot.dataset.level = task.stale.level; + const tip = `Stale · ${task.stale.days}d`; + dot.setAttr("aria-label", tip); + dot.setAttr("title", tip); + } + + const editable = isEditable(task, handlers); + if (editable) { + const edit = meta.createEl("button", { cls: "vt-edit-btn" }); + edit.setAttr("type", "button"); + edit.setAttr("aria-label", `Edit task: ${task.title}`); + edit.setAttr("title", "Edit"); + setIcon(edit, "pencil"); + edit.addEventListener("click", (ev) => { + ev.stopPropagation(); + handlers.onEdit?.(task); + }); + } + + // Ring completes. The title zone opens edit on every platform, matching Todoist's split between + // completion and task details without making users hunt for a hover-only control. + const longPressGuard = new LongPressClickGuard(); + ring.addEventListener("click", (ev) => { + ev.stopPropagation(); + if (longPressGuard.consumeClick()) { + ev.preventDefault(); + return; + } + handlers.onComplete(task, row); + }); + ring.addEventListener("contextmenu", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + handlers.onStatusMenu(task, ring, ev); + }); + + let longPress: number | null = null; + ring.addEventListener("touchstart", (ev) => { + longPress = window.setTimeout(() => { + longPress = null; + longPressGuard.fired(); + handlers.onStatusMenu(task, ring, ev); + }, 500); + }, { passive: true }); + const clearLongPress = () => { + if (longPress !== null) { + window.clearTimeout(longPress); + longPress = null; + } + }; + ring.addEventListener("touchend", clearLongPress); + ring.addEventListener("touchmove", clearLongPress); + ring.addEventListener("touchcancel", clearLongPress); + + if (editable) { + main.addClass("vt-row-main--editable"); + main.setAttr("title", "Edit task"); + main.addEventListener("click", (ev) => { + ev.stopPropagation(); + handlers.onEdit?.(task); + }); + } + + return row; +} diff --git a/src/writer.ts b/src/writer.ts new file mode 100644 index 0000000..c6002ac --- /dev/null +++ b/src/writer.ts @@ -0,0 +1,126 @@ +import { App, TFile } from "obsidian"; +import { VtTask } from "./model"; +import { insertAnnotation, setStatusChar } from "./format"; +import { + appendUnderHeadingText, + appendBlockText, + applyProposalText, + assertSameFileProposal, + blockLength, + completeLine, + cancelLine, + editLineText, + joinText, + locateLine, + moveTaskWithinText, + rollbackInsertedBlockText, + removeTaskBlockText, + runCrossFileMove, + splitText, + TasksPluginApiV1, + VtRecurrenceUnavailableError, + InsertedBlockReceipt, + VtPartialMoveError, + VtStaleError, +} from "./writerCore"; + +export { VtCrossFileProposalError, VtPartialMoveError, VtRecurrenceUnavailableError, VtStaleError } from "./writerCore"; + +interface LineRef { + filePath: string; + rawLine: string; + lineNo?: number; +} + +async function getFileOrThrow(app: App, filePath: string): Promise { + const file = app.vault.getAbstractFileByPath(filePath); + if (!(file instanceof TFile)) throw new VtStaleError(`taskline: file not found: ${filePath}`); + return file; +} + +async function processFile(app: App, filePath: string, callback: (content: string) => string): Promise { + const file = await getFileOrThrow(app, filePath); + await app.vault.process(file, callback); +} + +async function editLine(app: App, ref: LineRef, edit: (line: string) => string | string[]): Promise { + await processFile(app, ref.filePath, (content) => editLineText(content, ref, edit)); +} + +function getTasksPluginApi(app: App): TasksPluginApiV1 | null { + const anyApp = app as unknown as { plugins?: { plugins?: Record } }; + return anyApp.plugins?.plugins?.["obsidian-tasks-plugin"]?.apiV1 ?? null; +} + +export async function completeTask(app: App, task: VtTask): Promise { + const api = getTasksPluginApi(app); + if (task.recurrence && !api) throw new VtRecurrenceUnavailableError(); + await editLine(app, task, (line) => completeLine(api, task, line)); +} + +export async function reconcileAndComplete(app: App, task: VtTask, source: string): Promise { + const api = getTasksPluginApi(app); + if (task.recurrence && !api) throw new VtRecurrenceUnavailableError(); + await editLine(app, task, (line) => completeLine(api, task, insertAnnotation(line, `(reconciled from ${source})`))); +} + +export async function applyProposal(app: App, proposal: import("./model").VtProposal, task: VtTask): Promise { + assertSameFileProposal(proposal, task); + const api = getTasksPluginApi(app); + if (proposal.action === "complete" && task.recurrence && !api) throw new VtRecurrenceUnavailableError(); + await processFile(app, task.filePath, (content) => applyProposalText(content, proposal, task, api)); +} + +export async function setTaskStatus(app: App, task: VtTask, char: string, annotation?: string): Promise { + await editLine(app, task, (line) => annotation ? insertAnnotation(setStatusChar(line, char), annotation) : setStatusChar(line, char)); +} + +export async function removeLine(app: App, ref: LineRef): Promise { + await editLine(app, ref, () => []); +} + +export async function appendUnderHeading(app: App, filePath: string, heading: string, line: string | string[]): Promise { + const block = Array.isArray(line) ? line : [line]; + await processFile(app, filePath, (content) => appendUnderHeadingText(content, heading, block)); +} + +export async function replaceTaskLine(app: App, task: VtTask, newLine: string): Promise { + await editLine(app, task, () => newLine); +} + +export async function moveTaskLine( + app: App, + task: VtTask, + newLine: string, + dest: { file: string; heading: string } +): Promise { + if (dest.file === task.filePath) { + await processFile(app, task.filePath, (content) => moveTaskWithinText(content, task, newLine, dest.heading)); + return; + } + + let sourceBlock: string[] = []; + let movedBlock: string[] = []; + await processFile(app, task.filePath, (content) => { + const parts = splitText(content); + const idx = locateLine(parts.lines, task.rawLine, task.lineNo); + const length = blockLength(parts.lines, idx); + sourceBlock = parts.lines.slice(idx, idx + length); + movedBlock = [newLine, ...sourceBlock.slice(1)]; + return content; + }); + await runCrossFileMove({ + appendDestination: async () => { + let receipt: InsertedBlockReceipt | null = null; + await processFile(app, dest.file, (content) => { + const result = appendBlockText(content, dest.heading, movedBlock); + receipt = result.receipt; + return result.content; + }); + if (!receipt) throw new VtStaleError("taskline: destination append did not return a receipt"); + return receipt; + }, + removeSource: () => processFile(app, task.filePath, (content) => removeTaskBlockText(content, task, sourceBlock)), + rollbackDestination: (receipt) => processFile(app, dest.file, (content) => rollbackInsertedBlockText(content, receipt)), + }); +} diff --git a/src/writerCore.ts b/src/writerCore.ts new file mode 100644 index 0000000..dd5cc06 --- /dev/null +++ b/src/writerCore.ts @@ -0,0 +1,226 @@ +import { insertAnnotation, setStatusChar } from "./format"; +import { VtProposal, VtTask } from "./model"; + +export class VtStaleError extends Error { + constructor(message: string) { + super(message); + this.name = "VtStaleError"; + } +} + +export class VtRecurrenceUnavailableError extends Error { + constructor() { + super("Recurring tasks require the Obsidian Tasks plugin. Enable it, then try again."); + this.name = "VtRecurrenceUnavailableError"; + } +} + +export class VtPartialMoveError extends Error { + readonly recoveryInstructions = "The task may now exist in both files. Remove the destination copy only after confirming the source copy is still present."; + + constructor(message: string, readonly cause?: unknown) { + super(message); + this.name = "VtPartialMoveError"; + } +} + +export class VtCrossFileProposalError extends Error { + constructor(readonly proposalFile: string, readonly taskFile: string) { + super(`Taskline can only confirm a proposal when it is in the same file as its target. Move the proposal to ${taskFile}, then confirm it again.`); + this.name = "VtCrossFileProposalError"; + } +} + +interface TextParts { + lines: string[]; + eol: "\r\n" | "\n"; +} + +export interface TasksPluginApiV1 { + executeToggleTaskDoneCommand: (line: string, filePath: string) => string | string[]; +} + +export function splitText(content: string): TextParts { + return { lines: content.split(/\r?\n/), eol: content.includes("\r\n") ? "\r\n" : "\n" }; +} + +export function joinText(parts: TextParts): string { + return parts.lines.join(parts.eol); +} + +export function locateLine(lines: string[], rawLine: string, lineNoHint?: number): number { + if (lineNoHint !== undefined) { + const idx = lineNoHint - 1; + if (idx >= 0 && idx < lines.length && lines[idx] === rawLine) return idx; + } + const matches: number[] = []; + for (let i = 0; i < lines.length; i++) if (lines[i] === rawLine) matches.push(i); + if (matches.length !== 1) throw new VtStaleError(`taskline: expected exactly one matching line, found ${matches.length}`); + return matches[0]; +} + +export function headingInsertIndex(lines: string[], heading: string): number { + const headingIdx = lines.findIndex((line) => line.match(/^##\s+(.+?)\s*$/)?.[1] === heading); + if (headingIdx === -1) throw new VtStaleError(`taskline: heading not found: ${heading}`); + for (let i = headingIdx + 1; i < lines.length; i++) if (/^#{1,2}\s+/.test(lines[i])) return i; + return lines.length; +} + +export function editLineText( + content: string, + ref: { rawLine: string; lineNo?: number }, + edit: (line: string) => string | string[] +): string { + const parts = splitText(content); + const idx = locateLine(parts.lines, ref.rawLine, ref.lineNo); + const result = edit(parts.lines[idx]); + parts.lines.splice(idx, 1, ...(Array.isArray(result) ? result : [result])); + return joinText(parts); +} + +export function appendUnderHeadingText(content: string, heading: string, block: string[]): string { + const parts = splitText(content); + parts.lines.splice(headingInsertIndex(parts.lines, heading), 0, ...block); + return joinText(parts); +} + +export interface InsertedBlockReceipt { + index: number; + block: string[]; +} + +export function appendBlockText( + content: string, + heading: string, + block: string[] +): { content: string; receipt: InsertedBlockReceipt } { + const parts = splitText(content); + const index = headingInsertIndex(parts.lines, heading); + parts.lines.splice(index, 0, ...block); + return { content: joinText(parts), receipt: { index, block: [...block] } }; +} + +export function rollbackInsertedBlockText(content: string, receipt: InsertedBlockReceipt): string { + const parts = splitText(content); + const exact = receipt.block.every((line, offset) => parts.lines[receipt.index + offset] === line); + if (!exact) { + throw new VtStaleError("taskline: the inserted destination block changed before rollback"); + } + parts.lines.splice(receipt.index, receipt.block.length); + return joinText(parts); +} + +export async function runCrossFileMove(operations: { + appendDestination(): Promise; + removeSource(): Promise; + rollbackDestination(receipt: T): Promise; +}): Promise { + const receipt = await operations.appendDestination(); + try { + await operations.removeSource(); + } catch (sourceError) { + try { + await operations.rollbackDestination(receipt); + } catch (rollbackError) { + throw new VtPartialMoveError( + "taskline: source removal failed and the exact destination insertion could not be rolled back. The task may now exist in both files; verify both files and remove only the extra destination copy.", + { sourceError, rollbackError } + ); + } + throw sourceError; + } +} + +export function assertSameFileProposal(proposal: Pick, task: Pick): void { + if (proposal.filePath !== task.filePath) { + throw new VtCrossFileProposalError(proposal.filePath, task.filePath); + } +} + +function indentationWidth(line: string): number { + const whitespace = line.match(/^[\t ]*/)?.[0] ?? ""; + let width = 0; + for (const char of whitespace) width += char === "\t" ? 4 : 1; + return width; +} + +export function blockLength(lines: string[], idx: number): number { + const taskIndent = indentationWidth(lines[idx] ?? ""); + let length = 1; + // A block is the task plus consecutive, nonblank lines indented deeper than it. A blank, + // heading, or same/shallower-indented line starts the next top-level block. + for (let i = idx + 1; i < lines.length; i++) { + const line = lines[i]; + if (line.trim().length === 0 || indentationWidth(line) <= taskIndent) break; + length++; + } + return length; +} + +export function removeTaskBlockText(content: string, task: Pick, expectedBlock: string[]): string { + const parts = splitText(content); + const idx = locateLine(parts.lines, task.rawLine, task.lineNo); + const currentBlock = parts.lines.slice(idx, idx + blockLength(parts.lines, idx)); + if (currentBlock.length !== expectedBlock.length || currentBlock.some((line, offset) => line !== expectedBlock[offset])) { + throw new VtStaleError("taskline: source task block changed before removal"); + } + parts.lines.splice(idx, expectedBlock.length); + return joinText(parts); +} + +export function moveTaskWithinText(content: string, task: VtTask, newLine: string, heading: string): string { + const parts = splitText(content); + const sourceIdx = locateLine(parts.lines, task.rawLine, task.lineNo); + const length = blockLength(parts.lines, sourceIdx); + const notes = parts.lines.slice(sourceIdx + 1, sourceIdx + length); + parts.lines.splice(sourceIdx, length); + parts.lines.splice(headingInsertIndex(parts.lines, heading), 0, newLine, ...notes); + return joinText(parts); +} + +const DONE_LINE_RE = /^\s*-\s*\[[xX]\]/; + +export function toggleUntilDone(api: TasksPluginApiV1, line: string, filePath: string): string[] | null { + let current = line; + for (let i = 0; i < 8; i++) { + const result = api.executeToggleTaskDoneCommand(current, filePath); + const lines = Array.isArray(result) ? result : result.split(/\r?\n/); + if (lines.some((item) => DONE_LINE_RE.test(item))) return lines; + if (lines.length !== 1 || lines[0] === current) return null; + current = lines[0]; + } + return null; +} + +function todayIso(): string { + const d = new Date(); + const pad = (n: number) => (n < 10 ? `0${n}` : String(n)); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +export function cancelLine(task: VtTask, line: string, source?: string): string { + const annotated = source ? insertAnnotation(line, `(reconciled from ${source})`) : line; + return insertAnnotation(setStatusChar(annotated, "-"), `❌ ${todayIso()}`); +} + +export function applyProposalText( + content: string, + proposal: VtProposal, + task: VtTask, + api: TasksPluginApiV1 | null +): string { + const source = proposal.source ?? "proposal"; + const mutated = editLineText(content, task, (line) => proposal.action === "cancel" + ? cancelLine(task, line, source) + : completeLine(api, task, insertAnnotation(line, `(reconciled from ${source})`))); + return editLineText(mutated, { rawLine: proposal.rawLine }, () => []); +} + +export function completeLine(api: TasksPluginApiV1 | null, task: VtTask, line: string): string | string[] { + if (api) { + const done = toggleUntilDone(api, line, task.filePath); + if (done) return done; + } + if (task.recurrence) throw new VtRecurrenceUnavailableError(); + return insertAnnotation(setStatusChar(line, "x"), `✅ ${todayIso()}`); +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..ff60625 --- /dev/null +++ b/styles.css @@ -0,0 +1,1823 @@ +/* Taskline styles use Obsidian theme variables through --vt-* tokens. Tints use + color-mix so they work across themes without assuming RGB channel variables. */ + +.vt-settings-json { + width: min(100%, 42rem); + min-height: 8rem; + font-family: var(--font-monospace); + resize: vertical; +} + +.vt-settings-issues { + margin: var(--size-4-3) 0; + padding: var(--size-4-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-secondary); +} + +.vault-tasks-view, +.vt-capture-modal { + /* color */ + --vt-bg: var(--background-primary); + --vt-bg-alt: var(--background-secondary); + --vt-bg-hover: var(--background-modifier-hover); + --vt-bg-active: var(--background-modifier-active-hover); + --vt-border: var(--background-modifier-border); + --vt-text: var(--text-normal); + --vt-text-muted: var(--text-muted); + --vt-text-faint: var(--text-faint); + --vt-accent: var(--interactive-accent); + --vt-accent-text: var(--text-on-accent); + --vt-p1: var(--color-red); + --vt-p2: var(--color-orange); + --vt-p3: var(--color-blue); + --vt-p4: var(--text-faint); + --vt-stale-warn: var(--color-yellow); + --vt-stale-alert: var(--color-red); + --vt-recur: var(--text-muted); + --vt-due-today: var(--color-green); /* due/scheduled today, Todoist-green */ + --vt-due-tomorrow: var(--color-orange); /* due/scheduled tomorrow */ + + /* capture token tint bases (section 5); priority reuses --vt-p1..p3 */ + --vt-tok-date: var(--color-purple); + --vt-tok-area: var(--color-green); + --vt-tok-recur: var(--color-cyan); + + /* tint strength (alpha percentages, lifted slightly on dark themes) */ + --vt-tint: 14%; + --vt-tint-strong: 22%; + + /* spacing */ + --vt-space-0: 2px; + --vt-space-1: 4px; + --vt-space-2: 8px; + --vt-space-3: 12px; + --vt-space-4: 16px; + --vt-space-5: 24px; + --vt-space-6: 32px; + + /* radius */ + --vt-radius-pill: 999px; + --vt-radius-s: var(--radius-s); + --vt-radius-m: var(--radius-m); + + /* type */ + --vt-header-size: 28px; + --vt-fab-glyph: 28px; + + /* sizing */ + --vt-ring-size: 18px; + --vt-ring-stroke: 2px; + --vt-row-min-h: 40px; + --vt-hit: 44px; + --vt-focus-ring: 0 0 0 2px var(--vt-accent); + + /* easing */ + --vt-ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --vt-ease-in: cubic-bezier(0.4, 0, 1, 1); + --vt-ease-std: cubic-bezier(0.4, 0, 0.2, 1); + + color: var(--vt-text); +} + +.vault-tasks-view { + min-height: 100%; + container-type: inline-size; + padding: var(--vt-space-5) var(--vt-space-4); + background: var(--vt-bg); + overflow-y: auto; +} + +.theme-dark .vault-tasks-view, +.theme-dark .vt-capture-modal { + --vt-tint: 18%; + --vt-tint-strong: 28%; +} + +.vault-tasks-view.vt-mobile { + --vt-ring-size: 22px; + --vt-row-min-h: 56px; + --vt-header-size: 34px; +} + +/* ---- header ------------------------------------------------------------- */ + +/* Constrain the reading column on wide panes so the right-aligned metadata cluster + * stays within one eye-span of the title (Todoist-style list width). */ +.vt-header, +.vt-sections, +.vt-footer { + width: 100%; + max-width: 760px; + margin-left: auto; + margin-right: auto; +} + +.vt-header { + /* Steps down in even space-3/4 increments: header -> tabs (16) -> pills (12) -> section (16). */ + margin-bottom: var(--vt-space-4); +} + +.vt-title { + margin: 0; + font-size: var(--vt-header-size); + font-weight: 700; + line-height: 1.15; + color: var(--vt-text); +} + +.vt-subtitle { + margin-top: var(--vt-space-1); + font-size: var(--font-ui-small); + color: var(--vt-text-muted); +} + +/* ---- sections ----------------------------------------------------------- */ + +.vt-sections { + display: flex; + flex-direction: column; + gap: var(--vt-space-5); +} + +.vt-section { + display: flex; + flex-direction: column; +} + +.vt-section-head { + display: flex; + align-items: baseline; + gap: var(--vt-space-2); + padding: 0 var(--vt-space-4) var(--vt-space-1); +} + +.vt-section-label { + font-size: var(--font-ui-smaller); + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--vt-text-muted); +} + +.vt-section-count { + font-size: var(--font-ui-smaller); + color: var(--vt-text-faint); +} + +.vt-section-empty { + padding: var(--vt-space-2) var(--vt-space-4) var(--vt-space-3); +} + +/* Today acknowledges the undated backlog without rendering it into the daily plan. */ +.vault-tasks-view button.vt-triage-link { + display: flex; + align-items: center; + width: 100%; + min-height: 36px; + gap: var(--vt-space-2); + padding: var(--vt-space-2) var(--vt-space-4); + color: var(--vt-text-muted); + background: transparent; + border: 1px solid var(--vt-border); + box-shadow: none; + border-radius: var(--vt-radius-s); + cursor: pointer; + text-align: left; +} + +.vault-tasks-view button.vt-triage-link:hover { + color: var(--vt-text); + background: var(--vt-bg-hover); +} + +.vt-triage-link:focus-visible { + outline: none; + box-shadow: var(--vt-focus-ring); +} + +.vt-triage-icon { + display: inline-flex; + color: var(--vt-text-faint); +} + +.vt-triage-icon svg { + width: 15px; + height: 15px; +} + +.vt-triage-label { + font-size: var(--font-ui-small); + font-weight: 600; +} + +.vt-triage-hint { + margin-left: auto; + font-size: var(--font-ui-smaller); + color: var(--vt-text-faint); +} + +.vt-triage-arrow { + color: var(--vt-text-faint); +} + +.vt-mobile .vt-triage-link { + min-height: var(--vt-hit); +} + +.vt-empty-line1 { + font-size: var(--font-ui-small); + color: var(--vt-text); +} + +.vt-empty-line2 { + font-size: var(--font-ui-small); + color: var(--vt-text-muted); +} + +/* ---- lists + inset dividers --------------------------------------------- */ + +.vt-list > .vt-focusable { + position: relative; +} + +.vt-list > .vt-focusable:not(:first-child)::before { + content: ""; + position: absolute; + top: 0; + left: calc(var(--vt-space-4) + var(--vt-ring-size) + var(--vt-space-3)); + right: var(--vt-space-4); + border-top: 1px solid var(--vt-border); +} + +/* ---- task row ----------------------------------------------------------- */ + +.vt-task-row { + display: flex; + align-items: center; + gap: var(--vt-space-3); + min-height: var(--vt-row-min-h); + padding: var(--vt-space-2) var(--vt-space-4); + border-radius: var(--vt-radius-s); + transition: background-color 100ms var(--vt-ease-out); + cursor: default; +} + +.vt-task-row:hover { + background: var(--vt-bg-hover); +} + +.vt-focusable:focus-visible { + outline: none; + background: var(--vt-bg-hover); +} + +.vt-focusable:focus-visible .vt-ring { + box-shadow: var(--vt-focus-ring); +} + +.vt-row-main { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: var(--vt-space-2); +} + +.vt-row-main--editable { + cursor: pointer; +} + +.vt-row-main--editable:hover .vt-task-title { + color: var(--vt-accent); +} + +.vt-task-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--font-ui-medium); + color: var(--vt-text); +} + +.vt-task-title--muted { + color: var(--vt-text-muted); +} + +.vt-blocked-dot { + flex: 0 0 auto; + width: 6px; + height: 6px; + border-radius: 50%; + background: color-mix(in srgb, var(--vt-p1) 55%, transparent); +} + +.vt-row-meta { + display: flex; + align-items: center; + gap: var(--vt-space-2); + flex-shrink: 0; +} + +/* ---- priority ring ------------------------------------------------------ */ + +.vt-ring { + --vt-ring-color: var(--vt-p4); + position: relative; + flex: 0 0 auto; + width: var(--vt-ring-size); + height: var(--vt-ring-size); + border: var(--vt-ring-stroke) solid var(--vt-ring-color); + border-radius: 50%; + background: transparent; + cursor: pointer; + transition: transform 80ms var(--vt-ease-std), background-color 120ms var(--vt-ease-out); +} + +.vt-ring[data-priority="p1"] { --vt-ring-color: var(--vt-p1); } +.vt-ring[data-priority="p2"] { --vt-ring-color: var(--vt-p2); } +.vt-ring[data-priority="p3"] { --vt-ring-color: var(--vt-p3); } +.vt-ring[data-priority="p4"] { --vt-ring-color: var(--vt-p4); } + +/* in-progress: interior half filled to read as "started" without a badge */ +.vt-ring[data-status="in-progress"] { + background: linear-gradient( + to top, + color-mix(in srgb, var(--vt-ring-color) 50%, transparent) 50%, + transparent 50% + ); +} + +/* planning: dashed ring */ +.vt-ring[data-status="planning"] { + border-style: dashed; +} + +.vt-task-row:hover .vt-ring:not([data-status="in-progress"]) { + background: color-mix(in srgb, var(--vt-ring-color) var(--vt-tint), transparent); +} + +.vt-ring:active { + transform: scale(0.92); + background: color-mix(in srgb, var(--vt-ring-color) var(--vt-tint-strong), transparent); +} + +/* check glyph inside the ring, hidden until completion */ +.vt-check { + position: absolute; + inset: -2px; + width: calc(100% + 4px); + height: calc(100% + 4px); + opacity: 0; +} + +.vt-check path { + stroke: var(--vt-accent-text); + stroke-width: 3; + stroke-dasharray: 1; + stroke-dashoffset: 1; +} + +/* completion sequence (DESIGN section 4) */ +.vt-completing .vt-ring { + background: var(--vt-ring-color); + border-color: var(--vt-ring-color); +} + +.vt-completing .vt-check { + opacity: 1; +} + +.vt-completing .vt-check path { + stroke-dashoffset: 0; + transition: stroke-dashoffset 180ms var(--vt-ease-out); +} + +.vt-check-flash .vt-check { + animation: vt-check-pop 140ms var(--vt-ease-out); +} + +@keyframes vt-check-pop { + 0% { transform: scale(1); } + 50% { transform: scale(1.12); } + 100% { transform: scale(1); } +} + +.vt-fading { + opacity: 0.5; + transition: opacity 200ms var(--vt-ease-in); +} + +.vt-fading .vt-task-title { + text-decoration: line-through; + color: var(--vt-text-faint); +} + +.vt-collapsing { + transition: max-height 160ms var(--vt-ease-in), opacity 160ms var(--vt-ease-in); +} + +/* ---- date pill ---------------------------------------------------------- */ + +.vt-date-pill { + padding: var(--vt-space-1) var(--vt-space-2); + border-radius: var(--vt-radius-pill); + font-size: var(--font-ui-small); + color: var(--vt-text-muted); + white-space: nowrap; +} + +/* State modifiers, low-to-high precedence via source order (equal specificity): a scheduled-only + pill is faint italic, but a today/tomorrow/overdue date recolors it while keeping the italic. */ +.vt-date-pill--scheduled { + color: var(--vt-text-faint); + font-style: italic; +} + +.vt-date-pill--today { + color: var(--vt-due-today); +} + +.vt-date-pill--tomorrow { + color: var(--vt-due-tomorrow); +} + +.vt-date-pill--overdue { + color: var(--vt-p1); + background: color-mix(in srgb, var(--vt-p1) 12%, transparent); +} + +/* ---- area chip ---------------------------------------------------------- */ + +.vt-area-chip { + display: inline-flex; + align-items: center; + padding: var(--vt-space-1) var(--vt-space-2); + border-radius: var(--vt-radius-pill); + font-size: var(--font-ui-small); + white-space: nowrap; + transition: background-color 100ms var(--vt-ease-out); +} + +.vt-area-chip:hover { + background: color-mix(in srgb, var(--vt-area-color, var(--vt-text-muted)) var(--vt-tint), transparent); +} + +/* The '#' glyph carries the full area accent (set per chip as --vt-area-color); label stays muted. */ +.vt-area-hash { + color: var(--vt-area-color, var(--vt-text-faint)); +} + +.vt-area-label { + color: var(--vt-text-muted); +} + +/* ---- owner chips -------------------------------------------------------- */ + +.vt-owner-chip { + display: inline-flex; + align-items: center; + padding: var(--vt-space-1) var(--vt-space-2); + border-radius: var(--vt-radius-pill); + font-size: var(--font-ui-smaller); + color: var(--vt-text-muted); + background: var(--vt-bg-hover); +} + +/* ---- recurrence + stale ------------------------------------------------- */ + +.vt-recur { + font-size: var(--font-ui-small); + color: var(--vt-recur); +} + +.vt-stale-dot { + width: 6px; + height: 6px; + border-radius: 50%; + margin-left: var(--vt-space-0); +} + +.vt-stale-dot[data-level="warn"] { background: var(--vt-stale-warn); } +.vt-stale-dot[data-level="alert"] { background: var(--vt-stale-alert); } + +/* ---- proposed row ------------------------------------------------------- */ + +.vt-proposed-row { + padding: var(--vt-space-2) var(--vt-space-4); + border-radius: var(--vt-radius-s); + opacity: 0.85; + transition: opacity 200ms var(--vt-ease-out), background-color 100ms var(--vt-ease-out); +} + +.vt-proposed-row:hover { + opacity: 0.92; + background: var(--vt-bg-hover); +} + +.vt-proposed-top { + display: flex; + align-items: center; + gap: var(--vt-space-3); +} + +.vt-ring--ghost { + border: var(--vt-ring-stroke) dashed var(--vt-border); + cursor: default; +} + +.vt-proposed-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--font-ui-medium); + font-weight: 400; + color: var(--vt-text); +} + +.vt-proposed-actions { + display: flex; + gap: var(--vt-space-1); + flex-shrink: 0; +} + +.vt-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 26px; + height: 26px; + padding: 0 var(--vt-space-2); + border: 1px solid var(--vt-border); + border-radius: var(--vt-radius-s); + background: transparent; + color: var(--vt-text-muted); + cursor: pointer; + transition: background-color 100ms var(--vt-ease-out), color 100ms var(--vt-ease-out); +} + +.vt-action--confirm:hover { + color: var(--vt-accent); + background: color-mix(in srgb, var(--vt-accent) var(--vt-tint), transparent); + border-color: color-mix(in srgb, var(--vt-accent) 40%, transparent); +} + +.vt-action--reject:hover { + color: var(--vt-p1); + background: color-mix(in srgb, var(--vt-p1) var(--vt-tint), transparent); + border-color: color-mix(in srgb, var(--vt-p1) 40%, transparent); +} + +.vt-proposed-evidence { + display: flex; + align-items: baseline; + gap: var(--vt-space-1); + padding-left: calc(var(--vt-ring-size) + var(--vt-space-3)); + margin-top: var(--vt-space-1); + font-size: var(--font-ui-small); + color: var(--vt-text-faint); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.vt-evidence-arrow { + flex-shrink: 0; +} + +.vt-evidence-quote { + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; +} + +.vt-evidence-source { + flex-shrink: 0; +} + +/* confirm: ghost resolves toward solid before collapsing out */ +.vt-confirming { + opacity: 1; +} + +.vt-confirming .vt-ring--ghost { + border-style: solid; + border-color: var(--vt-accent); +} + +.vt-rejecting { + opacity: 0; +} + +/* ---- empty state -------------------------------------------------------- */ + +.vt-empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--vt-space-2); + padding: var(--vt-space-6) var(--vt-space-4); + margin-top: var(--vt-space-5); + border-radius: var(--vt-radius-m); + background: var(--vt-bg-alt); + text-align: center; +} + +.vt-empty-glyph { + font-size: var(--vt-header-size); + color: var(--vt-text-faint); + line-height: 1; +} + +.vt-empty-title { + font-size: var(--font-ui-medium); + color: var(--vt-text); +} + +.vt-empty-sub { + font-size: var(--font-ui-small); + color: var(--vt-text-muted); +} + +.vt-empty-add { + margin-top: var(--vt-space-2); + padding: var(--vt-space-1) var(--vt-space-4); + border: 1px solid var(--vt-border); + border-radius: var(--vt-radius-s); + background: transparent; + color: var(--vt-text-muted); + cursor: pointer; +} + +.vt-empty-add:hover { + background: var(--vt-bg-hover); + color: var(--vt-text); +} + +/* ---- loading + row note + footer ---------------------------------------- */ + +.vt-loading { + padding: var(--vt-space-6) var(--vt-space-4); + color: var(--vt-text-faint); + font-size: var(--font-ui-small); +} + +.vt-row-note { + padding: var(--vt-space-1) var(--vt-space-4); + font-size: var(--font-ui-small); + color: var(--vt-text-muted); +} + +.vt-footer { + margin-top: var(--vt-space-6); + padding-top: var(--vt-space-3); + border-top: 1px solid var(--vt-border); + color: var(--vt-text-faint); + font-size: var(--font-ui-smaller); +} + +/* ---- mobile ------------------------------------------------------------- */ + +.vt-mobile .vt-ring::before { + content: ""; + position: absolute; + inset: calc((var(--vt-hit) - var(--vt-ring-size)) / -2); +} + +.vt-mobile .vt-task-row { + align-items: flex-start; +} + +.vt-mobile .vt-row-main { + flex-direction: column; + align-items: flex-start; + gap: var(--vt-space-1); + padding-top: var(--vt-space-1); +} + +/* metadata wraps to a second line below the title on mobile */ +.vt-mobile .vt-task-row { + flex-wrap: wrap; +} + +.vt-mobile .vt-row-meta { + flex-basis: 100%; + padding-left: calc(var(--vt-ring-size) + var(--vt-space-3)); + flex-wrap: wrap; +} + +.vt-mobile .vt-action { + min-width: var(--vt-hit); + height: var(--vt-hit); +} + +/* ---- header add affordance ---------------------------------------------- */ + +.vt-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--vt-space-3); +} + +.vt-header-titles { + min-width: 0; +} + +.vt-header-add { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + font-size: var(--font-ui-medium); + line-height: 1; + border: 1px solid var(--vt-border); + border-radius: var(--vt-radius-s); + background: transparent; + color: var(--vt-text-muted); + cursor: pointer; + transition: background-color 100ms var(--vt-ease-out), color 100ms var(--vt-ease-out); +} + +.vt-header-add:hover { + background: var(--vt-bg-hover); + color: var(--vt-text); +} + +.vt-header-add:focus-visible { + outline: none; + box-shadow: var(--vt-focus-ring); +} + +.vt-action:focus-visible, +.vt-empty-add:focus-visible { + outline: none; + box-shadow: var(--vt-focus-ring); +} + +/* ---- mobile FAB --------------------------------------------------------- */ + +.vt-mobile-host { + position: relative; +} + +.vt-fab { + position: fixed; + right: calc(var(--vt-space-5) + env(safe-area-inset-right, 0px)); + bottom: calc(var(--vt-space-5) + env(safe-area-inset-bottom, 0px)); + z-index: var(--layer-modal, 100); + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--vt-accent); + color: var(--vt-accent-text); + box-shadow: var(--shadow-s, 0 2px 8px color-mix(in srgb, var(--vt-text) 25%, transparent)); + cursor: pointer; + transition: transform 80ms var(--vt-ease-out); +} + +.vt-fab:active { + transform: scale(0.94); +} + +.vt-fab:focus-visible { + outline: none; + box-shadow: var(--vt-focus-ring); +} + +.vt-fab-plus { + font-size: var(--vt-fab-glyph); + line-height: 1; + font-weight: 400; +} + +/* ---- capture modal ------------------------------------------------------ */ + +.vt-capture-modal { + width: 560px; + max-width: 92vw; + background: var(--vt-bg-alt); + border-radius: var(--vt-radius-m); + animation: vt-modal-open 140ms var(--vt-ease-out); +} + +@keyframes vt-modal-open { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* mobile bottom sheet: full-width, slides up, keyboard-aware via safe-area inset */ +.vt-capture-modal--sheet { + width: 100vw; + max-width: 100vw; + margin: 0; + position: fixed; + left: 0; + right: 0; + bottom: 0; + border-radius: var(--vt-radius-m) var(--vt-radius-m) 0 0; + padding-bottom: env(safe-area-inset-bottom, 0px); + animation: vt-sheet-up 140ms var(--vt-ease-out); +} + +@keyframes vt-sheet-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +.vt-capture-content { + display: flex; + flex-direction: column; + gap: var(--vt-space-3); + padding: var(--vt-space-2) 0; +} + +/* input + highlight backdrop share identical box metrics so the overlay aligns */ +.vt-capture-field { + position: relative; + display: flex; + align-items: center; +} + +.vt-capture-backdrop, +.vt-capture-input { + box-sizing: border-box; + width: 100%; + padding: var(--vt-space-2) var(--vt-space-3); + font-family: var(--font-interface); + font-size: var(--font-ui-medium); + line-height: 1.5; + letter-spacing: 0; + border: 1px solid transparent; + white-space: pre; + overflow-x: hidden; + overflow-y: hidden; +} + +.vt-capture-backdrop { + position: absolute; + inset: 0; + color: var(--vt-text); + pointer-events: none; + z-index: 0; +} + +.vt-capture-input { + position: relative; + z-index: 1; + display: block; + resize: none; + color: transparent; + caret-color: var(--vt-accent); + background: transparent; + border-color: var(--vt-border); + border-radius: var(--vt-radius-s); +} + +/* Theme button/input hover rules must never paint over the mirror backdrop. The native control + carries the caret and selection only; visible text belongs to .vt-capture-backdrop. */ +.vt-capture-modal .vt-capture-input, +.vt-capture-modal .vt-capture-input:hover, +.vt-capture-modal .vt-capture-input:focus, +.vt-capture-modal .vt-capture-input:active { + color: transparent !important; + background: transparent !important; + -webkit-text-fill-color: transparent; +} + +/* Editing existing text is a review task, not a one-line command. It wraps naturally and + reserves no overlay space, so long titles remain visible from first word to last. */ +.vt-edit-modal { + width: 680px; + max-width: calc(100vw - 32px); +} + +.vt-edit-modal .modal-close-button { + top: var(--vt-space-3); + right: var(--vt-space-3); + width: 34px; + height: 34px; +} + +.vt-edit-modal--saving .modal-close-button { + opacity: 0.35; + pointer-events: none; +} + +.vt-edit-header { + padding-right: 48px; +} + +.vt-edit-title { + margin: 0; + font-size: var(--font-ui-large); + line-height: 1.25; + color: var(--vt-text); +} + +.vt-edit-subtitle { + margin-top: var(--vt-space-1); + font-size: var(--font-ui-small); + color: var(--vt-text-muted); +} + +.vt-edit-modal .vt-capture-field { + align-items: flex-start; +} + +.vt-edit-modal .vt-capture-backdrop, +.vt-edit-modal .vt-capture-input { + min-height: 72px; + max-height: 160px; + white-space: pre-wrap; + overflow-wrap: anywhere; + overflow-x: hidden; + overflow-y: auto; +} + +.vt-edit-properties { + margin-top: var(--vt-space-2); +} + +.vt-edit-property-bar { + display: flex; + gap: var(--vt-space-2); +} + +.vt-edit-property-trigger { + display: flex; + align-items: center; + min-width: 0; + min-height: 40px; + flex: 1; + gap: var(--vt-space-2); + padding: var(--vt-space-1) var(--vt-space-2); + color: var(--vt-text); + background: transparent; + border: 1px solid var(--vt-border); + border-radius: var(--vt-radius-s); + box-shadow: none; + text-align: left; + cursor: pointer; +} + +.vt-capture-modal button.vt-edit-property-trigger, +.vt-capture-modal button.vt-edit-menu-item, +.vt-capture-modal button.vt-calendar-nav, +.vt-capture-modal button.vt-calendar-day { + box-shadow: none; +} + +.vt-capture-modal button.vt-edit-property-trigger:focus-visible, +.vt-capture-modal button.vt-edit-menu-item:focus-visible, +.vt-capture-modal button.vt-calendar-nav:focus-visible, +.vt-capture-modal button.vt-calendar-day:focus-visible { + box-shadow: var(--vt-focus-ring); +} + +.vt-edit-property-trigger:hover, +.vt-edit-property-trigger[aria-expanded="true"] { + background: var(--vt-bg-hover); +} + +.vt-edit-property-trigger:focus-visible, +.vt-edit-menu-item:focus-visible, +.vt-calendar-nav:focus-visible, +.vt-calendar-day:focus-visible { + outline: none; + box-shadow: var(--vt-focus-ring); +} + +.vt-edit-property-icon, +.vt-edit-property-chevron, +.vt-edit-menu-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + color: var(--vt-text-muted); +} + +.vt-edit-property-icon svg, +.vt-edit-property-chevron svg, +.vt-edit-menu-icon svg { + width: 16px; + height: 16px; +} + +.vt-edit-property-trigger:first-child .vt-edit-property-icon { + color: var(--vt-tok-date); +} + +.vt-edit-property-copy { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + line-height: 1.2; +} + +.vt-edit-property-label { + font-size: var(--font-ui-smaller); + color: var(--vt-text-faint); +} + +.vt-edit-property-value { + overflow: hidden; + font-size: var(--font-ui-small); + color: var(--vt-text); + text-overflow: ellipsis; + white-space: nowrap; +} + +.vt-edit-property-chevron { + transition: transform 120ms var(--vt-ease-out); +} + +.vt-edit-property-trigger[aria-expanded="true"] .vt-edit-property-chevron { + transform: rotate(180deg); +} + +.vt-edit-property-panel { + width: calc(50% - var(--vt-space-1)); + margin-top: var(--vt-space-2); + padding: var(--vt-space-1); + background: var(--vt-bg-alt); + border: 1px solid var(--vt-border); + border-radius: var(--vt-radius-m); +} + +.vt-edit-property-panel--priority { + margin-left: calc(50% + var(--vt-space-1)); +} + +.vt-edit-menu-list { + display: flex; + flex-direction: column; +} + +.vt-edit-menu-item { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + min-height: 40px; + gap: var(--vt-space-2); + padding: var(--vt-space-1) var(--vt-space-2); + color: var(--vt-text); + background: transparent; + border: none; + border-radius: var(--vt-radius-s); + box-shadow: none; + text-align: left; + cursor: pointer; +} + +.vt-capture-modal button.vt-edit-menu-item, +.vt-capture-modal button.vt-calendar-nav, +.vt-capture-modal button.vt-calendar-day { + background: transparent; + border: none; +} + +.vt-edit-menu-item:hover, +.vt-edit-menu-item--selected { + background: var(--vt-bg-hover); +} + +.vt-capture-modal button.vt-edit-menu-item:hover, +.vt-capture-modal button.vt-edit-menu-item.vt-edit-menu-item--selected, +.vt-capture-modal button.vt-calendar-nav:hover, +.vt-capture-modal button.vt-calendar-day:hover { + background: var(--vt-bg-hover); +} + +.vt-edit-menu-label { + font-size: var(--font-ui-small); +} + +.vt-edit-menu-detail { + font-size: var(--font-ui-smaller); + color: var(--vt-text-faint); +} + +.vt-edit-menu-item--selected .vt-edit-menu-label { + font-weight: 600; +} + +.vt-edit-menu-item--clear { + color: var(--vt-text-muted); +} + +.vt-edit-priority-list .vt-edit-menu-item[data-priority="p1"] .vt-edit-menu-icon { + color: var(--vt-p1); +} + +.vt-edit-priority-list .vt-edit-menu-item[data-priority="p2"] .vt-edit-menu-icon { + color: var(--vt-p2); +} + +.vt-edit-priority-list .vt-edit-menu-item[data-priority="p3"] .vt-edit-menu-icon { + color: var(--vt-p3); +} + +.vt-edit-priority-list .vt-edit-menu-item[data-priority="p4"] .vt-edit-menu-icon, +.vt-edit-priority-list .vt-edit-menu-item[data-priority="none"] .vt-edit-menu-icon { + color: var(--vt-p4); +} + +.vt-edit-calendar { + margin-top: var(--vt-space-1); + padding: var(--vt-space-2); + border-top: 1px solid var(--vt-border); +} + +.vt-calendar-header { + display: grid; + grid-template-columns: var(--vt-hit) minmax(0, 1fr) var(--vt-hit); + align-items: center; + margin-bottom: var(--vt-space-1); +} + +.vt-calendar-month { + font-size: var(--font-ui-small); + font-weight: 600; + text-align: center; +} + +.vt-calendar-nav, +.vt-calendar-day { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + color: var(--vt-text); + background: transparent; + border: none; + box-shadow: none; + cursor: pointer; +} + +.vt-calendar-nav { + width: var(--vt-hit); + height: var(--vt-hit); + border-radius: var(--vt-radius-s); +} + +.vt-calendar-nav:hover, +.vt-calendar-day:hover { + background: var(--vt-bg-hover); +} + +.vt-calendar-nav svg { + width: 16px; + height: 16px; +} + +.vt-calendar-weekdays, +.vt-calendar-grid { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: var(--vt-space-0); +} + +.vt-calendar-weekdays span { + padding: var(--vt-space-1) 0; + font-size: var(--font-ui-smaller); + color: var(--vt-text-faint); + text-align: center; +} + +.vt-calendar-day { + min-height: 40px; + border-radius: var(--vt-radius-s); + font-size: var(--font-ui-small); +} + +.vt-calendar-day--outside { + color: var(--vt-text-faint); +} + +.vt-calendar-day--today { + color: var(--vt-accent); + font-weight: 600; +} + +.vt-calendar-day--selected { + color: var(--vt-accent-text); + background: var(--vt-accent); + font-weight: 600; +} + +.vt-capture-modal button.vt-calendar-day--selected, +.vt-capture-modal button.vt-calendar-day--selected:hover { + color: var(--vt-accent-text); + background: var(--vt-accent); +} + +.vt-edit-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--vt-space-3); + padding-top: var(--vt-space-1); +} + +.vt-edit-shortcuts { + font-size: var(--font-ui-smaller); + color: var(--vt-text-faint); +} + +.vt-edit-actions { + display: flex; + align-items: center; + gap: var(--vt-space-2); +} + +.vt-edit-cancel, +.vt-edit-save { + min-height: 34px; + padding: var(--vt-space-1) var(--vt-space-3); + border-radius: var(--vt-radius-s); + font-size: var(--font-ui-small); + font-weight: 600; + cursor: pointer; +} + +.vt-edit-cancel { + color: var(--vt-text-muted); + background: transparent; + border: 1px solid var(--vt-border); +} + +.vt-edit-cancel:hover { + color: var(--vt-text); + background: var(--vt-bg-hover); +} + +.vt-edit-save { + color: var(--vt-accent-text); + background: var(--vt-accent); + border: 1px solid var(--vt-accent); +} + +.vt-edit-cancel:focus-visible, +.vt-edit-save:focus-visible { + outline: none; + box-shadow: var(--vt-focus-ring); +} + +.vt-edit-cancel:disabled, +.vt-edit-save:disabled { + opacity: 0.55; + cursor: wait; +} + +.vt-capture-modal--sheet.vt-edit-modal .vt-edit-footer { + align-items: stretch; + flex-direction: column; +} + +.vt-capture-modal--sheet.vt-edit-modal .vt-edit-property-trigger, +.vt-capture-modal--sheet.vt-edit-modal .vt-edit-menu-item, +.vt-capture-modal--sheet.vt-edit-modal .vt-calendar-day { + min-height: var(--vt-hit); +} + +.vt-capture-modal--sheet.vt-edit-modal .vt-edit-property-panel { + width: 100%; + margin-left: 0; +} + +@media (max-width: 560px) { + .vt-edit-property-panel { + width: 100%; + margin-left: 0; + } +} + +.vt-capture-modal--sheet.vt-edit-modal .vt-edit-calendar { + overflow-x: auto; +} + +.vt-capture-modal--sheet.vt-edit-modal .vt-calendar-weekdays, +.vt-capture-modal--sheet.vt-edit-modal .vt-calendar-grid { + min-width: calc(var(--vt-hit) * 7); + grid-template-columns: repeat(7, var(--vt-hit)); + gap: 0; +} + +.vt-capture-modal--sheet.vt-edit-modal .vt-edit-actions { + width: 100%; +} + +.vt-capture-modal--sheet.vt-edit-modal .vt-edit-actions button { + min-height: var(--vt-hit); + flex: 1; +} + +.vt-capture-input:focus { + outline: none; + border-color: var(--vt-accent); + box-shadow: none; +} + +.vt-capture-input::placeholder { + color: var(--vt-text-faint); +} + +.vt-capture-hint { + position: absolute; + right: var(--vt-space-3); + z-index: 2; + padding: var(--vt-space-0) var(--vt-space-1); + font-size: var(--font-ui-smaller); + color: var(--vt-text-faint); + background: var(--vt-bg-alt); + border: 1px solid var(--vt-border); + border-radius: var(--vt-radius-s); + pointer-events: none; +} + +/* '#' autocomplete menu - anchored under the input, DESIGN §9.6 */ +.vt-suggest-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 3; + margin-top: var(--vt-space-1); + padding: var(--vt-space-1); + background: var(--vt-bg-alt); + border: 1px solid var(--vt-border); + border-radius: var(--vt-radius-s); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + max-height: 220px; + overflow-y: auto; +} + +.vt-suggest-option { + display: flex; + align-items: center; + gap: var(--vt-space-1); + padding: var(--vt-space-1) var(--vt-space-2); + border-radius: var(--vt-radius-s); + font-size: var(--font-ui-small); + color: var(--vt-text); + cursor: pointer; +} + +.vt-suggest-option--active, +.vt-suggest-option:hover { + background: var(--vt-bg-hover); +} + +.vt-suggest-hash { + font-weight: 600; +} + +.vt-suggest-area { + color: var(--vt-text); +} + +/* keyword-suggestion tag in the destination line - Tab files as # */ +.vt-dest-suggest-tag { + font-weight: 600; +} + +/* token pills painted behind the live text */ +.vt-tok { + border-radius: var(--vt-radius-pill); + background: color-mix(in srgb, var(--vt-tok-base, var(--vt-accent)) var(--vt-tint), transparent); + transition: background-color 100ms var(--vt-ease-out); +} + +.vt-tok--active { + background: color-mix(in srgb, var(--vt-tok-base, var(--vt-accent)) var(--vt-tint-strong), transparent); +} + +.vt-tok--date { --vt-tok-base: var(--vt-tok-date); } +.vt-tok--scheduled { + --vt-tok-base: var(--vt-tok-date); + text-decoration: underline dotted var(--vt-tok-date); + text-underline-offset: 3px; +} +.vt-tok--area { --vt-tok-base: var(--vt-tok-area); } +.vt-tok--recurrence { --vt-tok-base: var(--vt-tok-recur); } +.vt-tok--priority-p1 { --vt-tok-base: var(--vt-p1); } +.vt-tok--priority-p2 { --vt-tok-base: var(--vt-p2); } +.vt-tok--priority-p3 { --vt-tok-base: var(--vt-p3); } +.vt-tok--priority-p4 { --vt-tok-base: var(--vt-p4); } +.vt-tok--owner { + --vt-tok-base: transparent; + background: var(--vt-bg-hover); +} + +/* parsed-preview row (real row component) under a hairline */ +.vt-capture-preview { + padding-top: var(--vt-space-2); + border-top: 1px solid var(--vt-border); +} + +.vt-capture-preview.vt-xfade { + animation: vt-preview-xfade 120ms var(--vt-ease-std); +} + +@keyframes vt-preview-xfade { + from { opacity: 0.4; } + to { opacity: 1; } +} + +/* the preview ring is display-only; drop the pointer affordance */ +.vt-capture-preview .vt-ring { + cursor: default; +} + +/* destination indicator */ +.vt-capture-dest { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--vt-space-1); + font-size: var(--font-ui-small); + color: var(--vt-text-muted); +} + +.vt-dest-arrow { + color: var(--vt-text-faint); +} + +.vt-dest-sep { + color: var(--vt-text-faint); +} + + +.vt-capture-dest.vt-dest--inbox { + color: var(--vt-text-faint); +} + +.vt-dest-hint { + color: var(--vt-text-faint); +} + +.vt-capture-error { + font-size: var(--font-ui-small); + color: var(--vt-text-muted); +} + +/* ---- v2: segmented tab control (§9) ------------------------------------- */ + +/* Three equal-width pills separated by real space (not a sliced single bar): no enclosing track + fill, so inactive segments read transparent and the active one lifts on an accent tint. */ +.vt-tabs { + display: flex; + gap: var(--vt-space-2); + width: 100%; + max-width: 760px; + margin: 0 auto var(--vt-space-3); +} + +.vault-tasks-view button.vt-tab { + flex: 1 1 0; + min-height: 30px; + padding: var(--vt-space-1) var(--vt-space-3); + font-size: var(--font-ui-small); + font-weight: 500; + color: var(--vt-text-muted); + background: transparent; + border: 1px solid transparent; + box-shadow: none; + border-radius: var(--vt-radius-s); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--vt-space-2); + transition: background-color 120ms var(--vt-ease-out), color 120ms var(--vt-ease-out), box-shadow 120ms var(--vt-ease-out); +} + +.vt-tab[data-tab="today"] { --vt-tab-color: var(--color-green); } +.vt-tab[data-tab="upcoming"] { --vt-tab-color: var(--color-purple); } +.vt-tab[data-tab="all"] { --vt-tab-color: var(--color-blue); } + +.vt-tab-icon { + display: inline-flex; + color: var(--vt-tab-color); +} + +.vt-tab-icon svg { + width: 15px; + height: 15px; +} + +.vault-tasks-view button.vt-tab:not(.vt-tab--active):hover { + color: var(--vt-text); + background: var(--vt-bg-hover); +} + +.vault-tasks-view button.vt-tab--active { + color: var(--vt-text); + background: color-mix(in srgb, var(--vt-tab-color) var(--vt-tint), transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vt-tab-color) 35%, transparent); + font-weight: 600; +} + +.vt-tab:focus-visible { + outline: none; + box-shadow: var(--vt-focus-ring); +} + +.vt-mobile .vt-tab { + min-height: var(--vt-hit); +} + +/* ---- v2: filter pills (§9) ---------------------------------------------- */ + +.vt-filter-pills { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--vt-space-1); + width: 100%; + max-width: 760px; + margin: 0 auto var(--vt-space-4); +} + +.vt-pill { + padding: var(--vt-space-0) var(--vt-space-2); + font-size: var(--font-ui-smaller); + color: var(--vt-text-muted); + background: var(--vt-bg-alt); + border: 1px solid var(--vt-border); + border-radius: var(--vt-radius-pill); + cursor: pointer; + transition: background-color 100ms var(--vt-ease-out), color 100ms var(--vt-ease-out); +} + +.vt-pill:hover { + color: var(--vt-text); + background: var(--vt-bg-hover); +} + +/* Active pill labels and tints use --vt-area-color set per pill, + giving the row a legible-but-colorful "this filter is on" cue that matches the chip/dot hue. */ +.vt-pill--active { + color: var(--vt-area-color, var(--vt-accent)); + background: color-mix(in srgb, var(--vt-area-color, var(--vt-accent)) var(--vt-tint-strong), transparent); + border-color: color-mix(in srgb, var(--vt-area-color, var(--vt-accent)) 40%, transparent); +} + +.vt-pill:focus-visible, +.vt-filter-clear:focus-visible { + outline: none; + box-shadow: var(--vt-focus-ring); +} + +.vt-filter-clear { + margin-left: var(--vt-space-1); + padding: var(--vt-space-0) var(--vt-space-1); + font-size: var(--font-ui-smaller); + color: var(--vt-text-faint); + background: transparent; + border: none; + border-radius: var(--vt-radius-s); + cursor: pointer; +} + +.vt-filter-clear:hover { + color: var(--vt-text-muted); +} + +.vt-mobile .vt-pill { + min-height: var(--vt-hit); + padding: var(--vt-space-1) var(--vt-space-3); +} + +.vt-mobile .vt-filter-clear { + min-height: var(--vt-hit); +} + +/* ---- v2: group heads, sub-labels, collapsibles (§9) --------------------- */ + +.vt-section-faint { + font-size: var(--font-ui-smaller); + color: var(--vt-text-faint); +} + +/* Small area-color dot before an All-tab group label (matches the stale-dot scale). */ +.vt-group-dot { + flex: 0 0 auto; + align-self: center; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--vt-area-color, var(--vt-text-faint)); +} + +.vt-section-label--faint { + color: var(--vt-text-faint); +} + +.vt-sublabel { + padding: var(--vt-space-2) var(--vt-space-4) var(--vt-space-0); + font-size: var(--font-ui-smaller); + color: var(--vt-text-faint); +} + +/* Collapsed Later / Completed toggle bar: chevron + label + count centered on both axes so the + bar's content sits dead-center (fixes the off-center Later bar). */ +.vt-group-toggle { + width: 100%; + min-height: 36px; + display: flex; + align-items: center; + justify-content: center; + gap: var(--vt-space-2); + padding: 0 var(--vt-space-4); + background: transparent; + border: none; + cursor: pointer; + text-align: center; +} + +.vt-group-toggle:focus-visible { + outline: none; + box-shadow: var(--vt-focus-ring); + border-radius: var(--vt-radius-s); +} + +.vt-chevron { + display: inline-block; + color: var(--vt-text-faint); + transition: transform 120ms var(--vt-ease-out); +} + +.vt-chevron--open { + transform: rotate(90deg); +} + +.vt-mobile .vt-group-toggle { + min-height: var(--vt-hit); + align-items: center; +} + +/* ---- v2: unfiled inbox hint --------------------------------------------- */ + +.vt-unfiled-hint { + font-size: var(--font-ui-smaller); + font-style: italic; + color: var(--vt-text-faint); +} + +/* ---- v2: completed history rows ----------------------------------------- */ + +.vt-row--done .vt-task-title { + text-decoration: line-through; + color: var(--vt-text-faint); +} + +.vt-row--done .vt-ring { + --vt-ring-color: var(--vt-text-faint); + background: color-mix(in srgb, var(--vt-text-faint) 30%, transparent); +} + +.vt-row--done:hover { + background: transparent; +} + +/* ---- v2: edit affordance ------------------------------------------------ */ + +.vault-tasks-view button.vt-edit-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + font-size: var(--font-ui-smaller); + line-height: 1; + color: var(--vt-text-faint); + background: transparent; + border: none; + box-shadow: none; + border-radius: var(--vt-radius-s); + cursor: pointer; + opacity: 0.68; + transition: opacity 100ms var(--vt-ease-out), color 100ms var(--vt-ease-out), background-color 100ms var(--vt-ease-out); +} + +.vt-edit-btn svg { + width: 15px; + height: 15px; +} + +.vt-task-row:hover .vt-edit-btn, +.vt-focusable:focus-visible .vt-edit-btn, +.vt-task-row:focus-within .vt-edit-btn { + opacity: 1; +} + +.vault-tasks-view button.vt-edit-btn:hover { + color: var(--vt-text); + background: var(--vt-bg-hover); +} + +.vt-edit-btn:focus-visible { + outline: none; + opacity: 1; + box-shadow: var(--vt-focus-ring); +} + +/* Mobile has no hover; the row-text tap zone opens edit, so keep the pencil hidden. */ +.vt-mobile .vt-edit-btn { + display: none; +} + +/* Obsidian panes can be phone-width on desktop. Reflow metadata under the title based on the + component's own width, not Platform.isMobile, so long provenance never crushes the task name. */ +@container (max-width: 540px) { + .vt-task-row { + display: grid; + grid-template-columns: var(--vt-ring-size) minmax(0, 1fr); + column-gap: var(--vt-space-3); + row-gap: var(--vt-space-1); + } + + .vt-ring { + grid-column: 1; + grid-row: 1; + margin-top: var(--vt-space-0); + } + + .vt-row-main { + grid-column: 2; + grid-row: 1; + } + + .vt-row-meta { + grid-column: 2; + grid-row: 2; + min-width: 0; + flex-wrap: wrap; + justify-content: flex-start; + } + + .vt-mobile .vt-row-meta { + flex-basis: auto; + padding-left: 0; + } +} + +/* ---- reduced motion ----------------------------------------------------- */ + +@media (prefers-reduced-motion: reduce) { + .vt-capture-modal, + .vt-capture-modal--sheet, + .vt-capture-preview.vt-xfade, + .vt-tok, + .vt-fab, + .vt-tab, + .vt-pill, + .vt-chevron, + .vt-edit-btn, + .vt-edit-property-chevron { + animation: none; + transition: none; + } + + .vt-task-row, + .vt-ring, + .vt-proposed-row, + .vt-area-chip, + .vt-action { + transition: none; + } + + .vt-completing .vt-check path { + transition: none; + stroke-dashoffset: 0; + } + + .vt-check-flash .vt-check { + animation: none; + } + + .vt-collapsing { + transition: none; + } + + .vt-reduced { + opacity: 0.5; + } +} diff --git a/tests/areaColor.test.ts b/tests/areaColor.test.ts new file mode 100644 index 0000000..1bfdce7 --- /dev/null +++ b/tests/areaColor.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { areaColor } from "../src/ui/areaColor"; +import { WORKSPACE } from "./fixtures"; + +describe("areaColor", () => { + it("uses configured area, group, route, and filter colors", () => { + expect(areaColor("writing", WORKSPACE)).toBe("var(--color-pink)"); + expect(areaColor("Shared work", WORKSPACE)).toBe("var(--color-purple)"); + expect(areaColor("automated", WORKSPACE)).toBe("var(--color-green)"); + }); + + it("gives unknown values a stable theme color", () => { + expect(areaColor("Unknown")).toBe(areaColor("Unknown")); + expect(areaColor("Unknown")).toMatch(/^var\(--color-[a-z]+\)$/); + }); +}); diff --git a/tests/captureRules.test.ts b/tests/captureRules.test.ts new file mode 100644 index 0000000..a8ea1de --- /dev/null +++ b/tests/captureRules.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { + knownCaptureTags, + mergeEditDates, + parseCapture, + resolveEditDestination, + serializeCapturedTask, + suggestArea, + taskToCaptureString, +} from "../src/captureRules"; +import { makeTask, WORKSPACE } from "./fixtures"; + +const TODAY = new Date(2026, 6, 2); + +describe("parseCapture", () => { + it("parses priority, date, title, and exact token spans", () => { + const input = "send the report by tomorrow !!"; + const result = parseCapture(input, TODAY, WORKSPACE); + expect(result).toMatchObject({ title: "send the report", priority: "p2", due: "2026-07-03" }); + expect(input.slice(result.tokens[0].start, result.tokens[0].end)).toBe("by tomorrow"); + }); + + it("uses date-only storage and leaves unsupported time text in the title", () => { + const parsed = parseCapture("pack today at 2pm", TODAY, WORKSPACE); + expect(parsed.due).toBe("2026-07-02"); + expect(parsed.title).toBe("pack at 2pm"); + }); + + it("parses weekdays, relative windows, and calendar forms", () => { + expect(parseCapture("gym monday", TODAY, WORKSPACE).due).toBe("2026-07-06"); + expect(parseCapture("gym next thursday", TODAY, WORKSPACE).due).toBe("2026-07-09"); + expect(parseCapture("plan next week", TODAY, WORKSPACE).due).toBe("2026-07-06"); + expect(parseCapture("follow up in 2 weeks", TODAY, WORKSPACE).due).toBe("2026-07-16"); + expect(parseCapture("event Jul 4", TODAY, WORKSPACE).due).toBe("2026-07-04"); + expect(parseCapture("event 4 July", TODAY, WORKSPACE).due).toBe("2026-07-04"); + expect(parseCapture("event 7/4", TODAY, WORKSPACE).due).toBe("2026-07-04"); + }); + + it.each([ + ["gym thursday", "2026-07-02"], + ["event Apr 15", "2027-04-15"], + ["follow up in 3 days", "2026-07-05"], + ["start starting tomorrow", "2026-07-03"], + ])("resolves date grammar in %s", (input, expected) => { + const result = parseCapture(input, TODAY, WORKSPACE); + expect(result.due ?? result.scheduled).toBe(expected); + }); + + it.each([ + ["ship !!!", "p1"], + ["ship !!", "p2"], + ["ship !", "p3"], + ["ship priority:low", "p4"], + ] as const)("resolves priority grammar in %s", (input, expected) => { + expect(parseCapture(input, TODAY, WORKSPACE).priority).toBe(expected); + }); + + it("routes by the first configured tag while preserving tag order", () => { + const result = parseCapture("draft article #write #automated #learning", TODAY, WORKSPACE); + expect(result.tags).toEqual(["write", "automated", "learning"]); + expect(result.destination).toEqual({ sourceId: "tasks", heading: "Writing" }); + }); + + it("retains owners and tags containing task-compatible separators", () => { + const result = parseCapture("review #client/work-stream #follow-up @alex-smith", TODAY, WORKSPACE); + expect(result).toMatchObject({ + title: "review", + owner: "alex-smith", + tags: ["client/work-stream", "follow-up"], + }); + }); + + it("serializes routed owners and generic tags without dropping either", () => { + const parsed = parseCapture("review #writing #client/work-stream @alex-smith", TODAY, WORKSPACE); + expect(serializeCapturedTask(parsed, "2026-07-02")).toBe( + "- [ ] review #writing #client/work-stream (from inbox 2026-07-02) — @alex-smith" + ); + }); + + it("persists the complete preview metadata even when the destination is inbox", () => { + const parsed = parseCapture("sync records #automated @alex every week by 2026-07-04 !!", TODAY, WORKSPACE); + expect(parsed.destination).toEqual({ sourceId: "inbox", heading: "Captured" }); + expect(serializeCapturedTask(parsed, "2026-07-02")).toBe( + "- [ ] sync records #automated (from inbox 2026-07-02) — @alex ⏫ 🔁 every week 📅 2026-07-04" + ); + }); + + it("marks only configured route tags as explicit routes", () => { + expect(parseCapture("draft #writing", TODAY, WORKSPACE).explicitRouteMatched).toBe(true); + expect(parseCapture("draft #automated", TODAY, WORKSPACE).explicitRouteMatched).toBe(false); + expect(parseCapture("draft", TODAY, WORKSPACE).explicitRouteMatched).toBe(false); + }); + + it("uses the configured fallback for an untagged capture", () => { + expect(parseCapture("buy groceries", TODAY, WORKSPACE).destination).toEqual({ sourceId: "inbox", heading: "Captured" }); + }); + + it("anchors bare recurrence and preserves explicit anchors", () => { + expect(parseCapture("publish every Sunday #writing", TODAY, WORKSPACE).due).toBe("2026-07-02"); + expect(parseCapture("publish every Sunday by Jul 5 #writing", TODAY, WORKSPACE).due).toBe("2026-07-05"); + expect(parseCapture("stretch daily", TODAY, WORKSPACE).recurrence).toBe("every day"); + expect(parseCapture("review weekly", TODAY, WORKSPACE).recurrence).toBe("every week"); + }); + + it("protects edit titles that resemble grammar", () => { + const task = makeTask({ title: "Explain why this says due Aug 21 #writing", tags: ["learning"], due: "2026-06-30", priority: "p2" }); + const parsed = parseCapture(taskToCaptureString(task), TODAY, WORKSPACE); + expect(parsed.title).toBe(task.title); + expect(parsed.tags).toEqual(task.tags); + expect(parsed.due).toBe(task.due); + expect(parsed.priority).toBe(task.priority); + }); + + it("round-trips owner metadata through the edit grammar", () => { + const task = makeTask({ title: "Review", owner: "alex-smith" }); + expect(parseCapture(taskToCaptureString(task), TODAY, WORKSPACE).owner).toBe("alex-smith"); + }); + + it.each([ + [{ due: "2026-07-04", scheduled: "2026-07-03" }, { due: "2026-07-04" }, { due: "2026-07-04", scheduled: "2026-07-03" }], + [{ due: "2026-07-04", scheduled: "2026-07-03" }, { due: "2026-07-05" }, { due: "2026-07-05", scheduled: "2026-07-03" }], + [{ due: "2026-07-04" }, { due: "2026-07-05" }, { due: "2026-07-05", scheduled: undefined }], + [{ scheduled: "2026-07-03" }, { scheduled: "2026-07-05" }, { due: undefined, scheduled: "2026-07-05" }], + ])("merges the represented edit date without dropping its counterpart", (original, parsedDates, expected) => { + const task = makeTask({ title: "Dated", ...original }); + const parsed = { ...parseCapture("Dated", TODAY, WORKSPACE), ...parsedDates }; + expect(mergeEditDates(task, parsed)).toEqual(expected); + }); +}); + +describe("capture suggestions and edit policy", () => { + it("offers configured route aliases and filters", () => { + expect(knownCaptureTags(WORKSPACE)).toEqual(["writing", "write", "learning", "study", "shared", "automated"]); + }); + + it("suggests the first configured keyword and never overrides an explicit tag", () => { + expect(suggestArea("draft an article", WORKSPACE)).toEqual({ tag: "writing", matchedWord: "article" }); + expect(suggestArea("draft an article #learning", WORKSPACE)).toBeNull(); + expect(suggestArea("unrelated errand", WORKSPACE)).toBeNull(); + }); + + it("pins stay-policy sources to their current source and heading", () => { + const task = makeTask({ sourceId: "team", filePath: "Shared/Tasks.md", heading: "Platform", title: "Review handoff" }); + const parsed = parseCapture("Review handoff #writing", TODAY, WORKSPACE); + expect(resolveEditDestination(task, parsed, WORKSPACE)).toEqual({ sourceId: "team", heading: "Platform" }); + }); + + it("routes edit-policy sources and keeps an unrouted task in place when no fallback exists", () => { + const task = makeTask({ title: "Review notes", heading: "Learning" }); + const parsed = parseCapture("Review notes #writing", TODAY, WORKSPACE); + expect(resolveEditDestination(task, parsed, WORKSPACE)).toEqual({ sourceId: "tasks", heading: "Writing" }); + }); + + it("keeps route-policy edits in place when only fallback routing is available", () => { + const task = makeTask({ title: "Review notes", heading: "Learning" }); + const parsed = parseCapture(taskToCaptureString(task), TODAY, WORKSPACE); + expect(parsed.destination).toEqual({ sourceId: "inbox", heading: "Captured" }); + expect(parsed.explicitRouteMatched).toBe(false); + expect(resolveEditDestination(task, parsed, WORKSPACE)).toEqual({ sourceId: "tasks", heading: "Learning" }); + }); +}); diff --git a/tests/editProperties.test.ts b/tests/editProperties.test.ts new file mode 100644 index 0000000..e7f5acf --- /dev/null +++ b/tests/editProperties.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + calendarDates, + localIso, + quickDates, + setCaptureDate, + setCapturePriority, +} from "../src/editProperties"; +import { parseCapture } from "../src/captureRules"; +import { WORKSPACE } from "./fixtures"; + +const FRIDAY = new Date(2026, 6, 17); + +describe("quickDates", () => { + it("matches the Todoist-style next week and next weekend semantics", () => { + expect(quickDates(FRIDAY).map((option) => option.iso)).toEqual([ + "2026-07-17", + "2026-07-18", + "2026-07-20", + "2026-07-25", + ]); + }); +}); + +describe("edit grammar mutations", () => { + it("replaces, adds, and clears due dates without changing the protected title", () => { + const original = '"Explain why this says due Aug 21" #learning by 2026-07-17 !!'; + const moved = setCaptureDate(original, "2026-07-20", FRIDAY, WORKSPACE); + expect(parseCapture(moved, FRIDAY, WORKSPACE).title).toBe("Explain why this says due Aug 21"); + expect(parseCapture(moved, FRIDAY, WORKSPACE).due).toBe("2026-07-20"); + expect(parseCapture(setCaptureDate(moved, null, FRIDAY, WORKSPACE), FRIDAY, WORKSPACE).due).toBeUndefined(); + expect(parseCapture(setCaptureDate('"Undated" #writing', "2026-07-18", FRIDAY, WORKSPACE), FRIDAY, WORKSPACE).due) + .toBe("2026-07-18"); + }); + + it("preserves scheduled semantics when changing an existing scheduled date", () => { + const changed = setCaptureDate('"Start project" starting 2026-07-17', "2026-07-20", FRIDAY, WORKSPACE); + const parsed = parseCapture(changed, FRIDAY, WORKSPACE); + expect(parsed.scheduled).toBe("2026-07-20"); + expect(parsed.due).toBeUndefined(); + }); + + it("sets every priority level and distinguishes low from none", () => { + const original = '"Review task" #writing !!'; + for (const priority of ["p1", "p2", "p3", "p4"] as const) { + const changed = setCapturePriority(original, priority, FRIDAY, WORKSPACE); + expect(parseCapture(changed, FRIDAY, WORKSPACE).priority).toBe(priority); + } + expect(parseCapture(setCapturePriority(original, null, FRIDAY, WORKSPACE), FRIDAY, WORKSPACE).priority).toBeNull(); + }); +}); + +describe("calendarDates", () => { + it("returns a complete Sunday-first six-week grid", () => { + const dates = calendarDates(new Date(2026, 6, 1)); + expect(dates).toHaveLength(42); + expect(localIso(dates[0])).toBe("2026-06-28"); + expect(localIso(dates[41])).toBe("2026-08-08"); + }); +}); diff --git a/tests/fixtures.ts b/tests/fixtures.ts new file mode 100644 index 0000000..47b87ed --- /dev/null +++ b/tests/fixtures.ts @@ -0,0 +1,43 @@ +import { VtTask } from "../src/model"; +import { compileWorkspace } from "../src/settings"; + +export const WORKSPACE = compileWorkspace({ + version: 1, + sources: [ + { id: "tasks", label: "Tasks", path: "Tasks.md", role: "tasks", editPolicy: "route", proposals: true }, + { id: "team", label: "Shared", path: "Shared/Tasks.md", role: "tasks", groupId: "shared", editPolicy: "stay", proposals: false }, + { id: "inbox", label: "Inbox", path: "Inbox.md", role: "inbox", editPolicy: "route", proposals: false }, + ], + sourceGroups: [ + { id: "shared", label: "Shared work", mode: "by-heading", ownerDisplay: true, color: "--color-purple" }, + ], + areas: [ + { id: "writing", label: "Writing", sourceId: "tasks", heading: "Writing", color: "--color-pink" }, + { id: "learning", label: "Learning", sourceId: "tasks", heading: "Learning", color: "--color-blue" }, + ], + captureRoutes: [ + { tag: "writing", aliases: ["write"], destination: { sourceId: "tasks", heading: "Writing" }, keywords: ["article", "draft"], showAsChip: true }, + { tag: "learning", aliases: ["study"], destination: { sourceId: "tasks", heading: "Learning" }, keywords: ["course", "exam"], showAsChip: true }, + { tag: "shared", aliases: [], destination: { sourceId: "team", heading: "General" }, keywords: ["handoff"], showAsChip: false }, + ], + tagFilters: [{ tag: "automated", label: "Automated", color: "--color-green" }], + displayOrder: ["writing", "learning", "shared"], + fallbackCaptureDestination: { sourceId: "inbox", heading: "Captured" }, + ownerSelfAliases: ["me", "self"], +}); + +export function makeTask(partial: Partial & { title: string }): VtTask { + return { + sourceId: "tasks", + filePath: "Tasks.md", + lineNo: 1, + rawLine: "", + status: "todo", + statusChar: " ", + tags: [], + priority: null, + heading: "Writing", + subNotes: [], + ...partial, + }; +} diff --git a/tests/format.test.ts b/tests/format.test.ts new file mode 100644 index 0000000..2d6285e --- /dev/null +++ b/tests/format.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { insertAnnotation, relativeDateLabel, serializeTask, setStatusChar } from "../src/format"; +import { parseTaskLine } from "../src/parser"; + +const ctx = { sourceId: "tasks", filePath: "Tasks.md", lineNo: 1, heading: "Writing" }; + +describe("line formatting", () => { + it("sets status without disturbing other text", () => { + expect(setStatusChar("- [ ] Task", "x")).toBe("- [x] Task"); + expect(setStatusChar("not a task", "x")).toBe("not a task"); + }); + + it("inserts annotations before the complete signifier run", () => { + expect(insertAnnotation("- [ ] Task 🔼 🔁 every week 📅 2026-07-01", "(note)")).toBe( + "- [ ] Task (note) 🔼 🔁 every week 📅 2026-07-01" + ); + expect(insertAnnotation("- [ ] Task", "(note)")).toBe("- [ ] Task (note)"); + }); + + it("serializes metadata in canonical order and preserves generic tags", () => { + const line = "- [ ] Everything #writing (from inbox 2026-06-01) — @Alex 🟡 stale 5d 🔺 🔁 every day ⏳ 2026-07-05 📅 2026-07-06"; + expect(serializeTask(parseTaskLine(line, ctx)!)).toBe(line); + }); + + it("preserves nested task indentation when serializing an edit", () => { + const line = " - [ ] Nested task #writing"; + const task = parseTaskLine(line, ctx)!; + task.title = "Edited nested task"; + expect(serializeTask(task)).toBe(" - [ ] Edited nested task #writing"); + }); +}); + +describe("relativeDateLabel", () => { + const today = new Date(2026, 6, 2); + + it("labels nearby and distant dates", () => { + expect(relativeDateLabel("2026-07-02", today)).toBe("Today"); + expect(relativeDateLabel("2026-07-03", today)).toBe("Tomorrow"); + expect(relativeDateLabel("2026-07-01", today)).toBe("Yesterday"); + expect(relativeDateLabel("2026-07-06", today)).toBe("Mon"); + expect(relativeDateLabel("2025-07-04", today)).toBe("Jul 4, 2025"); + }); + + it.each(["2026-07-02T14:00", "2026-07-02T09:30", "2026-07-02T00:00"])( + "ignores legacy time components because storage is date-only: %s", + (iso) => expect(relativeDateLabel(iso, today)).toBe("Today") + ); +}); diff --git a/tests/longPress.test.ts b/tests/longPress.test.ts new file mode 100644 index 0000000..6989b64 --- /dev/null +++ b/tests/longPress.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { LongPressClickGuard } from "../src/ui/longPress"; + +describe("LongPressClickGuard", () => { + it("does not suppress ordinary clicks", () => { + expect(new LongPressClickGuard().consumeClick()).toBe(false); + }); + + it("suppresses the one synthetic click following a long press", () => { + const guard = new LongPressClickGuard(); + guard.fired(); + expect(guard.consumeClick()).toBe(true); + expect(guard.consumeClick()).toBe(false); + }); + + it("coalesces repeated fired signals into one suppression", () => { + const guard = new LongPressClickGuard(); + guard.fired(); + guard.fired(); + expect([guard.consumeClick(), guard.consumeClick()]).toEqual([true, false]); + }); +}); diff --git a/tests/parser.test.ts b/tests/parser.test.ts new file mode 100644 index 0000000..c416d1b --- /dev/null +++ b/tests/parser.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { serializeTask } from "../src/format"; +import { parseProposalLine, parseTaskLine, parseTrackerFile } from "../src/parser"; +import { WORKSPACE } from "./fixtures"; + +const ctx = (over: Partial<{ sourceId: string; filePath: string; lineNo: number; heading: string }> = {}) => ({ + sourceId: "tasks", + filePath: "Tasks.md", + lineNo: 1, + heading: "Writing", + ...over, +}); + +describe("parseTaskLine", () => { + it("parses tags, provenance, and non-provenance parentheses", () => { + const line = "- [ ] Test device flow (phase 2) #writing (from [[Build Log]])"; + const task = parseTaskLine(line, ctx()); + expect(task).toMatchObject({ title: "Test device flow (phase 2)", tags: ["writing"], sourceId: "tasks" }); + expect(task?.provenance).toEqual({ kind: "link", source: "[[Build Log]]" }); + expect(serializeTask(task!)).toBe(line); + }); + + it("round-trips generic slash/hyphen tags and hyphenated owners", () => { + const line = "- [ ] Review #client/work-stream #follow-up — @alex-smith"; + const task = parseTaskLine(line, ctx())!; + expect(task.tags).toEqual(["client/work-stream", "follow-up"]); + expect(task.owner).toBe("alex-smith"); + expect(serializeTask(task)).toBe(line); + }); + + it("parses recurrence, priorities, dates, owner, and stale state", () => { + const line = "- [/] Publish update #writing (from inbox 2026-06-26) — @Alex 🟡 stale 5d 🔼 🔁 every week ⏳ 2026-07-01 📅 2026-07-02"; + const task = parseTaskLine(line, ctx()); + expect(task).toMatchObject({ + status: "in-progress", + priority: "p3", + scheduled: "2026-07-01", + due: "2026-07-02", + recurrence: "every week", + owner: "Alex", + stale: { level: "warn", days: 5 }, + }); + expect(serializeTask(task!)).toBe(line); + }); + + it("parses done and cancelled states plus their dates", () => { + expect(parseTaskLine("- [x] Finished ✅ 2026-07-01", ctx())).toMatchObject({ status: "done", doneDate: "2026-07-01" }); + expect(parseTaskLine("- [-] Dropped ❌ 2026-07-01", ctx())).toMatchObject({ status: "cancelled", cancelledDate: "2026-07-01" }); + }); + + it("normalizes legacy timed signifiers to Taskline's date-only model", () => { + expect(parseTaskLine("- [ ] Legacy ⏳ 2026-07-01T09:30 📅 2026-07-02T14:00", ctx())) + .toMatchObject({ scheduled: "2026-07-01", due: "2026-07-02" }); + }); + + it("round-trips an alert stale marker and preserves an em dash in provenance", () => { + const line = "- [ ] Escalate review (from [[May 1 — Review]]) — @Alex 🔴 stale 46d (escalate)"; + const task = parseTaskLine(line, ctx())!; + expect(task.stale).toEqual({ level: "alert", days: 46 }); + expect(task.provenance?.source).toBe("[[May 1 — Review]]"); + expect(serializeTask(task)).toBe(line); + }); + + it("handles every open status char", () => { + expect(parseTaskLine("- [ ] Todo", ctx())?.status).toBe("todo"); + expect(parseTaskLine("- [!] Blocked", ctx())?.status).toBe("blocked"); + expect(parseTaskLine("- [?] Planning", ctx())?.status).toBe("planning"); + }); + + it("tolerates malformed and non-task input", () => { + for (const input of ["", "random", "## Heading", null as unknown as string]) { + expect(() => parseTaskLine(input, ctx())).not.toThrow(); + } + expect(parseTaskLine("random", ctx())).toBeNull(); + }); +}); + +describe("proposals and tracker files", () => { + it("parses proposal evidence and source", () => { + const proposal = parseProposalLine('- (propose done) Review draft - evidence: "approved" [[Meeting]]', ctx()); + expect(proposal).toMatchObject({ sourceId: "tasks", action: "complete", text: "Review draft", evidence: "approved", source: "Meeting" }); + }); + + it("parses proposals without evidence and rejects ordinary bullets", () => { + expect(parseProposalLine("- (propose cancel) Drop stale idea", ctx())).toMatchObject({ action: "cancel", text: "Drop stale idea" }); + expect(parseProposalLine("- [ ] Ordinary task", ctx())).toBeNull(); + }); + + it.each([ + ["done", "complete"], + ["complete", "complete"], + ["cancel", "cancel"], + ] as const)("normalizes proposal action %s", (input, action) => { + expect(parseProposalLine(`- (propose ${input}) Task`, ctx())?.action).toBe(action); + }); + + it.each(["finish", "done later", "", "delete"])("rejects unsupported proposal action %j", (action) => { + expect(parseProposalLine(`- (propose ${action}) Task`, ctx())).toBeNull(); + }); + + it("tracks headings, subnotes, source identity, and enabled proposals", () => { + const content = [ + "## Writing", + "- [ ] Draft article #writing", + " - 📝 Waiting for review", + "## Learning", + "- [x] Finish course #learning ✅ 2026-07-01", + "## Proposed", + "- (propose done) Archive notes", + ].join("\n"); + const source = WORKSPACE.sourceById.get("tasks")!; + const result = parseTrackerFile(content, source, WORKSPACE); + expect(result.tasks.map((task) => task.heading)).toEqual(["Writing", "Learning"]); + expect(result.tasks[0].subNotes).toEqual(["Waiting for review"]); + expect(result.proposals).toHaveLength(1); + }); + + it("ignores proposals for a source that disables them", () => { + const source = WORKSPACE.sourceById.get("team")!; + expect(parseTrackerFile("- (propose done) Archive notes", source, WORKSPACE).proposals).toEqual([]); + }); + + it("does not throw on malformed tracker content", () => { + const source = WORKSPACE.sourceById.get("tasks")!; + expect(() => parseTrackerFile("\0 garbage\n- [", source, WORKSPACE)).not.toThrow(); + expect(() => parseTrackerFile(null as unknown as string, source, WORKSPACE)).not.toThrow(); + }); +}); diff --git a/tests/proposals.test.ts b/tests/proposals.test.ts new file mode 100644 index 0000000..7c91a90 --- /dev/null +++ b/tests/proposals.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import type { VtProposal } from "../src/model"; +import { findProposalTarget, normalizeTaskIdentity } from "../src/proposals"; +import { makeTask } from "./fixtures"; + +function proposal(text: string, action: VtProposal["action"] = "complete"): VtProposal { + return { + sourceId: "tasks", + filePath: "Tasks.md", + lineNo: 2, + rawLine: `- (propose ${action}) ${text}`, + action, + text, + }; +} + +describe("proposal identity", () => { + it.each([ + [" Review Draft ", "review draft"], + ["REVIEW DRAFT", "review draft"], + ["\tReview\nDraft", "review draft"], + ])("normalizes whitespace and case in %j", (input, expected) => { + expect(normalizeTaskIdentity(input)).toBe(expected); + }); + + it("matches one exact normalized open task", () => { + const target = makeTask({ title: "Review Draft" }); + expect(findProposalTarget(proposal(" review draft "), [target])).toBe(target); + }); + + it.each([ + ["Review", "Review draft"], + ["Review draft", "Review"], + ["draft", "Review draft"], + ])("does not fuzzy-match %j against %j", (proposalText, taskTitle) => { + expect(findProposalTarget(proposal(proposalText), [makeTask({ title: taskTitle })])).toBeNull(); + }); + + it("rejects ambiguous exact matches", () => { + expect(findProposalTarget(proposal("Same"), [makeTask({ title: "Same" }), makeTask({ title: "same" })])).toBeNull(); + }); + + it("prefers the proposal's own file over an identical task in another source", () => { + const local = makeTask({ title: "Same", filePath: "Tasks.md" }); + const remote = makeTask({ title: "Same", sourceId: "team", filePath: "Shared/Tasks.md" }); + expect(findProposalTarget(proposal("Same"), [remote, local])).toBe(local); + }); + + it("rejects ambiguous exact matches within the proposal's own file", () => { + const first = makeTask({ title: "Same", filePath: "Tasks.md" }); + const second = makeTask({ title: "same", filePath: "Tasks.md" }); + expect(findProposalTarget(proposal("Same"), [first, second])).toBeNull(); + }); + + it.each(["done", "cancelled"] as const)("does not target %s tasks", (status) => { + expect(findProposalTarget(proposal("Closed"), [makeTask({ + title: "Closed", + status, + statusChar: status === "done" ? "x" : "-", + })])).toBeNull(); + }); +}); diff --git a/tests/query.test.ts b/tests/query.test.ts new file mode 100644 index 0000000..6cc8506 --- /dev/null +++ b/tests/query.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + allOpenGrouped, + completedRecent, + effectiveTaskIso, + inboxTasks, + isTaskOverdue, + isTaskToday, + laterTasks, + taskArea, + undatedOpenTasks, + upcomingByDay, +} from "../src/query"; +import { makeTask, WORKSPACE } from "./fixtures"; + +const TODAY = new Date(2026, 6, 2); + +describe("date windows", () => { + const tasks = [ + makeTask({ title: "today", due: "2026-07-02" }), + makeTask({ title: "tomorrow", due: "2026-07-03" }), + makeTask({ title: "day seven", due: "2026-07-09" }), + makeTask({ title: "day eight", due: "2026-07-10" }), + makeTask({ title: "undated" }), + makeTask({ title: "done", status: "done", statusChar: "x", due: "2026-07-04" }), + ]; + + it("buckets open tasks from tomorrow through day seven", () => { + const days = upcomingByDay(tasks, TODAY); + expect(days).toHaveLength(7); + expect(days[0].tasks.map((task) => task.title)).toEqual(["tomorrow"]); + expect(days[6].tasks.map((task) => task.title)).toEqual(["day seven"]); + }); + + it("puts only dates beyond the window in later", () => { + expect(laterTasks(tasks, TODAY).map((task) => task.title)).toEqual(["day eight"]); + }); + + it("returns all open undated tasks", () => { + expect(undatedOpenTasks(tasks).map((task) => task.title)).toEqual(["undated"]); + }); + + it.each([ + ["2026-07-04", "2026-07-03", "2026-07-03"], + ["2026-07-03", "2026-07-04", "2026-07-03"], + ["2026-07-03", undefined, "2026-07-03"], + [undefined, "2026-07-04", "2026-07-04"], + ])("uses the earliest scheduled/due date as the effective date", (due, scheduled, expected) => { + expect(effectiveTaskIso(makeTask({ title: "Both", due, scheduled }))).toBe(expected); + }); + + it("classifies a task by its earliest date across today and overdue", () => { + const task = makeTask({ title: "Mixed", scheduled: "2026-07-01", due: "2026-07-02" }); + expect(isTaskOverdue(task, TODAY)).toBe(true); + expect(isTaskToday(task, TODAY)).toBe(false); + }); + + it("places a mixed-date task in one upcoming bucket using the earliest date", () => { + const task = makeTask({ title: "Mixed", scheduled: "2026-07-04", due: "2026-07-03" }); + const groups = upcomingByDay([task], TODAY); + expect(groups[0].tasks).toEqual([task]); + expect(groups[1].tasks).toEqual([]); + }); + + it("does not place a task in later when its earlier date is within the window", () => { + const task = makeTask({ title: "Mixed", scheduled: "2026-07-03", due: "2026-07-20" }); + expect(laterTasks([task], TODAY)).toEqual([]); + }); +}); + +describe("workspace grouping", () => { + it("uses area IDs, group IDs, and null for inbox identity", () => { + expect(taskArea(makeTask({ title: "area", heading: "Writing" }), WORKSPACE)).toBe("writing"); + expect(taskArea(makeTask({ title: "group", sourceId: "team", filePath: "Shared/Tasks.md" }), WORKSPACE)).toBe("shared"); + expect(taskArea(makeTask({ title: "inbox", sourceId: "inbox", filePath: "Inbox.md" }), WORKSPACE)).toBeNull(); + }); + + it("orders configured areas, retains unknown headings, and combines grouped sources", () => { + const groups = allOpenGrouped([ + makeTask({ title: "learn", heading: "Learning" }), + makeTask({ title: "write", heading: "Writing" }), + makeTask({ title: "unknown", heading: "Someday" }), + makeTask({ title: "shared", sourceId: "team", filePath: "Shared/Tasks.md", heading: "Platform" }), + ], WORKSPACE); + expect(groups.map((group) => group.label)).toEqual(["Writing", "Learning", "Shared work", "Someday"]); + expect(groups.find((group) => group.key === "shared")?.mode).toBe("by-heading"); + }); + + it("selects inbox tasks by configured role rather than path", () => { + const inbox = makeTask({ title: "capture", sourceId: "inbox", filePath: "Inbox.md" }); + expect(inboxTasks([inbox, makeTask({ title: "task" })], WORKSPACE)).toEqual([inbox]); + }); +}); + +describe("completedRecent", () => { + it("sorts newest first and reports truncation", () => { + const tasks = Array.from({ length: 4 }, (_, index) => makeTask({ + title: `done ${index}`, + status: "done", + statusChar: "x", + doneDate: `2026-07-0${index + 1}`, + })); + const result = completedRecent(tasks, 2); + expect(result.tasks.map((task) => task.title)).toEqual(["done 3", "done 2"]); + expect(result.truncated).toBe(true); + }); + + it("uses cancellation dates when no done date exists", () => { + const result = completedRecent([ + makeTask({ title: "cancelled", status: "cancelled", statusChar: "-", cancelledDate: "2026-07-02" }), + makeTask({ title: "done", status: "done", statusChar: "x", doneDate: "2026-07-01" }), + ]); + expect(result.tasks.map((task) => task.title)).toEqual(["cancelled", "done"]); + }); +}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 0000000..51cdfa4 --- /dev/null +++ b/tests/settings.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { compileWorkspace, createSettingsDraft, DEFAULT_SETTINGS, normalizeVaultPath } from "../src/settings"; +import { WORKSPACE } from "./fixtures"; + +describe("settings workspace", () => { + it("ships unconfigured and contains no implicit sources", () => { + const workspace = compileWorkspace(DEFAULT_SETTINGS); + expect(workspace.configured).toBe(false); + expect(workspace.sources).toEqual([]); + expect(workspace.issues).toEqual([]); + }); + + it("normalizes configured paths", () => { + expect(normalizeVaultPath(" /Folder\\Nested//Tasks.md ")).toBe("Folder/Nested/Tasks.md"); + expect(WORKSPACE.sourceById.get("team")?.path).toBe("Shared/Tasks.md"); + }); + + it.each(["../../Tasks.md", "Folder/../Tasks.md", "/Tasks.md", "\\Tasks.md", "C:\\Tasks.md"])( + "rejects source paths outside the vault: %s", + (path) => { + const workspace = compileWorkspace({ + ...DEFAULT_SETTINGS, + sources: [{ id: "tasks", label: "Tasks", path }], + }); + expect(workspace.configured).toBe(false); + expect(workspace.issues).toContainEqual({ + level: "error", + path: "sources.0.path", + message: "Source path must stay within the vault.", + }); + } + ); + + it("reports duplicate IDs, duplicate normalized paths, and broken references", () => { + const workspace = compileWorkspace({ + version: 1, + sources: [ + { id: "tasks", label: "One", path: "Folder\\Tasks.md", role: "tasks", editPolicy: "route", proposals: false }, + { id: "tasks", label: "Two", path: "Folder/Tasks.md", role: "tasks", groupId: "missing", editPolicy: "route", proposals: false }, + ], + sourceGroups: [], + areas: [{ id: "area", label: "Area", sourceId: "missing", heading: "Area" }], + captureRoutes: [{ tag: "area", aliases: [], destination: { sourceId: "missing", heading: "Area" }, keywords: [], showAsChip: true }], + tagFilters: [], + displayOrder: ["missing"], + fallbackCaptureDestination: { sourceId: "missing", heading: "Captured" }, + ownerSelfAliases: [], + }); + expect(workspace.configured).toBe(false); + expect(workspace.issues.map((issue) => issue.message)).toEqual(expect.arrayContaining([ + "Duplicate source ID: tasks", + "Duplicate source path: folder/tasks.md", + "Unknown source group: missing", + "Unknown source: missing", + "Unknown area or group: missing", + ])); + }); + + it("indexes route aliases, headings, filters, and self aliases", () => { + expect(WORKSPACE.routeByTag.get("write")?.tag).toBe("writing"); + expect(WORKSPACE.areasBySourceHeading.get("tasks\u0000writing")?.id).toBe("writing"); + expect(WORKSPACE.tagFilterByTag.get("automated")?.label).toBe("Automated"); + expect(WORKSPACE.selfAliases.has("me")).toBe(true); + }); + + it.each([ + ["sources", [null]], + ["sources", ["bad"]], + ["sourceGroups", [null]], + ["areas", [42]], + ["captureRoutes", [null]], + ["tagFilters", [false]], + ["displayOrder", [null]], + ["ownerSelfAliases", [{}]], + ["sources", null], + ])("defensively rejects malformed %s without throwing", (key, value) => { + const input = { ...DEFAULT_SETTINGS, [key]: value }; + expect(() => compileWorkspace(input)).not.toThrow(); + expect(compileWorkspace(input).issues.some((issue) => issue.level === "error")).toBe(true); + expect(compileWorkspace(input).configured).toBe(false); + }); + + it("rejects unsupported future schema versions", () => { + const workspace = compileWorkspace({ ...DEFAULT_SETTINGS, version: 999 }); + expect(workspace.configured).toBe(false); + expect(workspace.issues).toContainEqual({ + level: "error", + path: "version", + message: "Unsupported future schema version: 999", + }); + }); + + it.each([null, "1", 1.5, 0])("rejects invalid explicit schema version %j", (version) => { + const workspace = compileWorkspace({ ...DEFAULT_SETTINGS, version }); + expect(workspace.issues.some((issue) => issue.path === "version" && issue.level === "error")).toBe(true); + }); + + it.each([ + ["role", "archive", "sources.0.role"], + ["editPolicy", "copy", "sources.0.editPolicy"], + ["proposals", "yes", "sources.0.proposals"], + ])("rejects invalid source %s instead of coercing it", (key, value, expectedPath) => { + const input = JSON.parse(JSON.stringify(WORKSPACE.settings)); + input.sources[0][key] = value; + const workspace = compileWorkspace(input); + expect(workspace.configured).toBe(false); + expect(workspace.issues).toContainEqual(expect.objectContaining({ level: "error", path: expectedPath })); + }); + + it.each([ + ["mode", "nested", "sourceGroups.0.mode"], + ["ownerDisplay", "yes", "sourceGroups.0.ownerDisplay"], + ["color", 42, "sourceGroups.0.color"], + ])("rejects invalid source-group %s instead of activating it", (key, value, expectedPath) => { + const input = JSON.parse(JSON.stringify(WORKSPACE.settings)); + input.sourceGroups[0][key] = value; + const workspace = compileWorkspace(input); + expect(workspace.configured).toBe(false); + expect(workspace.issues).toContainEqual(expect.objectContaining({ level: "error", path: expectedPath })); + }); + + it.each([ + ["areas", "color", "not a color; color:red", "areas.0.color"], + ["tagFilters", "color", {}, "tagFilters.0.color"], + ["captureRoutes", "showAsChip", 1, "captureRoutes.0.showAsChip"], + ["captureRoutes", "destination", [], "captureRoutes.0.destination"], + ])("rejects invalid %s.%s shape or value", (collection, key, value, expectedPath) => { + const input = JSON.parse(JSON.stringify(WORKSPACE.settings)); + input[collection][0][key] = value; + const workspace = compileWorkspace(input); + expect(workspace.configured).toBe(false); + expect(workspace.issues).toContainEqual(expect.objectContaining({ level: "error", path: expectedPath })); + }); + + it("keeps backward-compatible defaults for omitted optional source and group fields", () => { + const workspace = compileWorkspace({ + ...DEFAULT_SETTINGS, + sources: [{ id: "tasks", label: "Tasks", path: "Tasks.md" }], + sourceGroups: [{ id: "group", label: "Group" }], + }); + expect(workspace.issues.filter((issue) => issue.level === "error")).toEqual([]); + expect(workspace.sources[0]).toMatchObject({ role: "tasks", editPolicy: "route", proposals: false }); + expect(workspace.settings.sourceGroups[0]).toMatchObject({ mode: "flat", ownerDisplay: false }); + }); + + it("uses a rejected raw object as the repair draft without mutating the raw or active settings", () => { + const rejected = { + ...JSON.parse(JSON.stringify(WORKSPACE.settings)), + sources: [{ ...WORKSPACE.settings.sources[0], role: "invalid-role" }], + }; + const activeBefore = JSON.stringify(DEFAULT_SETTINGS); + const rejectedBefore = JSON.stringify(rejected); + const draft = createSettingsDraft(DEFAULT_SETTINGS, rejected); + expect((draft.sources as Array<{ role: string }>)[0].role).toBe("invalid-role"); + expect(compileWorkspace(draft).configured).toBe(false); + (draft.sources as Array<{ role: string }>)[0].role = "tasks"; + expect(JSON.stringify(rejected)).toBe(rejectedBefore); + expect(JSON.stringify(DEFAULT_SETTINGS)).toBe(activeBefore); + }); + + it("preserves explicit rejected null shapes in the repair draft", () => { + const draft = createSettingsDraft(WORKSPACE.settings, { ...WORKSPACE.settings, sources: null }); + expect(draft.sources).toBeNull(); + expect(compileWorkspace(draft).configured).toBe(false); + }); +}); diff --git a/tests/writer.test.ts b/tests/writer.test.ts new file mode 100644 index 0000000..91af39d --- /dev/null +++ b/tests/writer.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; +import { + appendUnderHeadingText, + appendBlockText, + applyProposalText, + assertSameFileProposal, + blockLength, + cancelLine, + completeLine, + editLineText, + moveTaskWithinText, + rollbackInsertedBlockText, + removeTaskBlockText, + runCrossFileMove, + VtCrossFileProposalError, + VtPartialMoveError, + VtRecurrenceUnavailableError, + VtStaleError, +} from "../src/writerCore"; +import { makeTask } from "./fixtures"; + +describe("atomic text mutations", () => { + it("validates the exact stale line inside the mutation", () => { + expect(() => editLineText("## Tasks\n- [ ] Changed", { rawLine: "- [ ] Original", lineNo: 2 }, () => "- [x] Original")) + .toThrow(VtStaleError); + }); + + it("always appends a destination block and preserves CRLF", () => { + const content = "## Tasks\r\n- [ ] Existing\r\n"; + const block = ["- [ ] Moved", " - 📝 Detail"]; + const first = appendBlockText(content, "Tasks", block); + const second = appendBlockText(first.content, "Tasks", block); + expect(second.content.match(/- \[ \] Moved/g)).toHaveLength(2); + expect(first.content).toContain("\r\n- [ ] Moved\r\n - 📝 Detail"); + }); + + it("rolls back exactly the insertion identified by its operation receipt", () => { + const original = "## Tasks\n- [ ] Moved"; + const appended = appendBlockText(original, "Tasks", ["- [ ] Moved"]); + expect(rollbackInsertedBlockText(appended.content, appended.receipt)).toBe(original); + expect(() => rollbackInsertedBlockText("## Tasks\n- [ ] Moved\n- [ ] Changed", appended.receipt)) + .toThrow(VtStaleError); + }); + + it("atomically completes a same-file target and removes its proposal", () => { + const task = makeTask({ title: "Review", rawLine: "- [ ] Review", lineNo: 2 }); + const proposal = { + sourceId: "tasks", filePath: "Tasks.md", lineNo: 3, + rawLine: "- (propose done) Review", action: "complete" as const, text: "Review", + }; + const result = applyProposalText("## Tasks\n- [ ] Review\n- (propose done) Review", proposal, task, null); + expect(result).toMatch(/^## Tasks\n- \[x\] Review \(reconciled from proposal\) ✅ \d{4}-\d{2}-\d{2}$/); + }); + + it("atomically cancels a same-file target and removes its proposal", () => { + const task = makeTask({ title: "Drop", rawLine: "- [ ] Drop", lineNo: 2 }); + const proposal = { + sourceId: "tasks", filePath: "Tasks.md", lineNo: 3, + rawLine: "- (propose cancel) Drop", action: "cancel" as const, text: "Drop", source: "Review", + }; + const result = applyProposalText("## Tasks\n- [ ] Drop\n- (propose cancel) Drop", proposal, task, null); + expect(result).toMatch(/^## Tasks\n- \[-\] Drop \(reconciled from Review\) ❌ \d{4}-\d{2}-\d{2}$/); + }); + + it("serializes cancellation without completion semantics", () => { + expect(cancelLine(makeTask({ title: "Drop" }), "- [ ] Drop")).toMatch(/^- \[-\] Drop ❌ \d{4}-\d{2}-\d{2}$/); + }); + + it("preserves CRLF while editing and appending", () => { + const edited = editLineText("## Tasks\r\n- [ ] Original\r\n", { rawLine: "- [ ] Original", lineNo: 2 }, () => "- [x] Original"); + expect(edited).toBe("## Tasks\r\n- [x] Original\r\n"); + expect(appendUnderHeadingText(edited, "Tasks", ["- [ ] Added"])).toBe("## Tasks\r\n- [x] Original\r\n\r\n- [ ] Added"); + }); + + it("moves a task and its subnotes within one text transaction", () => { + const content = ["## First", "- [ ] Move me", " - 📝 Detail", "## Second", "- [ ] Existing"].join("\n"); + const task = makeTask({ rawLine: "- [ ] Move me", lineNo: 2, heading: "First", title: "Move me" }); + expect(moveTaskWithinText(content, task, "- [ ] Moved", "Second")).toBe( + ["## First", "## Second", "- [ ] Existing", "- [ ] Moved", " - 📝 Detail"].join("\n") + ); + }); + + it("moves every consecutive deeper-indented child line and stops at top-level boundaries", () => { + const lines = [ + "## First", + "- [ ] Parent", + " - [ ] Nested task", + " continuation text", + " %% comment %%", + " ![[embed]]", + "- [ ] Next task", + "## Second", + ]; + expect(blockLength(lines, 1)).toBe(5); + const moved = moveTaskWithinText(lines.join("\n"), makeTask({ + title: "Parent", rawLine: "- [ ] Parent", lineNo: 2, heading: "First", + }), "- [ ] Parent", "Second"); + expect(moved).toContain("- [ ] Next task\n## Second\n- [ ] Parent\n - [ ] Nested task\n continuation text\n %% comment %%\n ![[embed]]"); + }); + + it("stops a moved block at a blank line even when later text is indented", () => { + expect(blockLength(["- [ ] Parent", " child", "", " unrelated"], 0)).toBe(2); + }); + + it("refuses to remove a source block whose indented children changed after append", () => { + const task = makeTask({ title: "Parent", rawLine: "- [ ] Parent", lineNo: 2 }); + expect(() => removeTaskBlockText( + "## Tasks\n- [ ] Parent\n changed", + task, + ["- [ ] Parent", " original"] + )).toThrow(VtStaleError); + }); +}); + +describe("cross-file move transaction", () => { + it("preserves cardinality when the destination already has an identical task", async () => { + let source = "## Tasks\n- [ ] Same"; + let destination = "## Tasks\n- [ ] Same"; + await runCrossFileMove({ + appendDestination: async () => { + const result = appendBlockText(destination, "Tasks", ["- [ ] Same"]); + destination = result.content; + return result.receipt; + }, + removeSource: async () => { + source = editLineText(source, { rawLine: "- [ ] Same", lineNo: 2 }, () => []); + }, + rollbackDestination: async (receipt) => { + destination = rollbackInsertedBlockText(destination, receipt); + }, + }); + expect(source).toBe("## Tasks"); + expect(destination.match(/- \[ \] Same/g)).toHaveLength(2); + }); + + it("rolls back the inserted block when source removal fails", async () => { + const original = "## Tasks\n- [ ] Existing"; + let destination = original; + const sourceError = new Error("injected source failure"); + await expect(runCrossFileMove({ + appendDestination: async () => { + const result = appendBlockText(destination, "Tasks", ["- [ ] Moved"]); + destination = result.content; + return result.receipt; + }, + removeSource: async () => { throw sourceError; }, + rollbackDestination: async (receipt) => { + destination = rollbackInsertedBlockText(destination, receipt); + }, + })).rejects.toBe(sourceError); + expect(destination).toBe(original); + }); + + it("throws a typed partial-move error with recovery instructions when rollback fails", async () => { + const operation = runCrossFileMove({ + appendDestination: async () => ({ operation: "receipt" }), + removeSource: async () => { throw new Error("injected source failure"); }, + rollbackDestination: async () => { throw new Error("injected rollback failure"); }, + }); + await expect(operation).rejects.toBeInstanceOf(VtPartialMoveError); + await expect(operation).rejects.toMatchObject({ + recoveryInstructions: expect.stringContaining("both files"), + }); + }); +}); + +describe("proposal transaction boundary", () => { + it.each(["complete", "cancel"] as const)("rejects a cross-file %s proposal before mutation", (action) => { + const proposal = { + filePath: "Proposals.md", rawLine: `- (propose ${action}) Exact`, action, text: "Exact", + }; + const task = makeTask({ title: "Exact", filePath: "Tasks.md", rawLine: "- [ ] Exact" }); + expect(() => assertSameFileProposal(proposal, task)).toThrow(VtCrossFileProposalError); + try { + assertSameFileProposal(proposal, task); + } catch (error) { + expect((error as Error).message).toContain("Move the proposal to Tasks.md"); + } + }); + + it("allows same-file confirmation to continue to the exact atomic mutation", () => { + expect(() => assertSameFileProposal({ filePath: "Tasks.md" }, { filePath: "Tasks.md" })).not.toThrow(); + }); +}); + +describe("recurrence completion safety", () => { + it("refuses recurring completion without the Tasks API", () => { + const task = makeTask({ title: "Repeat", recurrence: "every day" }); + expect(() => completeLine(null, task, "- [ ] Repeat 🔁 every day 📅 2026-07-02")) + .toThrow(VtRecurrenceUnavailableError); + }); + + it("keeps the non-recurring completion fallback", () => { + const line = completeLine(null, makeTask({ title: "Once" }), "- [ ] Once"); + expect(line).toMatch(/^- \[x\] Once ✅ \d{4}-\d{2}-\d{2}$/); + }); + + it("accepts recurrence output produced by the Tasks API", () => { + const task = makeTask({ title: "Repeat", recurrence: "every day" }); + const api = { + executeToggleTaskDoneCommand: () => ["- [x] Repeat ✅ 2026-07-02", "- [ ] Repeat 🔁 every day 📅 2026-07-03"], + }; + expect(completeLine(api, task, "- [ ] Repeat 🔁 every day 📅 2026-07-02")).toEqual([ + "- [x] Repeat ✅ 2026-07-02", + "- [ ] Repeat 🔁 every day 📅 2026-07-03", + ]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c44b729 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES7" + ] + }, + "include": [ + "**/*.ts" + ] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..708016d --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.5.0" +}