From acb81c91ef00778891f1268fb529939cd5835abf Mon Sep 17 00:00:00 2001 From: Savar-G Date: Sat, 18 Jul 2026 14:16:25 -0700 Subject: [PATCH] Initial public release --- .github/CODEOWNERS | 1 + .github/workflows/ci.yml | 38 + .github/workflows/release.yml | 43 + .gitignore | 4 + CONTRIBUTING.md | 13 + LICENSE | 21 + README.md | 124 +++ SECURITY.md | 11 + esbuild.config.mjs | 67 ++ manifest.json | 10 + package-lock.json | 1868 +++++++++++++++++++++++++++++++++ package.json | 34 + src/captureRules.ts | 513 +++++++++ src/editProperties.ts | 76 ++ src/format.ts | 121 +++ src/main.ts | 183 ++++ src/model.ts | 95 ++ src/parser.ts | 248 +++++ src/proposals.ts | 18 + src/query.ts | 158 +++ src/settings.ts | 391 +++++++ src/settingsTab.ts | 152 +++ src/store.ts | 175 +++ src/todayView.ts | 955 +++++++++++++++++ src/ui/areaColor.ts | 31 + src/ui/captureModal.ts | 711 +++++++++++++ src/ui/editModal.ts | 402 +++++++ src/ui/longPress.ts | 13 + src/ui/motion.ts | 120 +++ src/ui/proposedRow.ts | 62 ++ src/ui/taskRow.ts | 211 ++++ src/writer.ts | 126 +++ src/writerCore.ts | 226 ++++ styles.css | 1823 ++++++++++++++++++++++++++++++++ tests/areaColor.test.ts | 16 + tests/captureRules.test.ts | 161 +++ tests/editProperties.test.ts | 60 ++ tests/fixtures.ts | 43 + tests/format.test.ts | 48 + tests/longPress.test.ts | 22 + tests/parser.test.ts | 128 +++ tests/proposals.test.ts | 62 ++ tests/query.test.ts | 115 ++ tests/settings.test.ts | 166 +++ tests/writer.test.ts | 208 ++++ tsconfig.json | 24 + versions.json | 3 + 47 files changed, 10100 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 esbuild.config.mjs create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/captureRules.ts create mode 100644 src/editProperties.ts create mode 100644 src/format.ts create mode 100644 src/main.ts create mode 100644 src/model.ts create mode 100644 src/parser.ts create mode 100644 src/proposals.ts create mode 100644 src/query.ts create mode 100644 src/settings.ts create mode 100644 src/settingsTab.ts create mode 100644 src/store.ts create mode 100644 src/todayView.ts create mode 100644 src/ui/areaColor.ts create mode 100644 src/ui/captureModal.ts create mode 100644 src/ui/editModal.ts create mode 100644 src/ui/longPress.ts create mode 100644 src/ui/motion.ts create mode 100644 src/ui/proposedRow.ts create mode 100644 src/ui/taskRow.ts create mode 100644 src/writer.ts create mode 100644 src/writerCore.ts create mode 100644 styles.css create mode 100644 tests/areaColor.test.ts create mode 100644 tests/captureRules.test.ts create mode 100644 tests/editProperties.test.ts create mode 100644 tests/fixtures.ts create mode 100644 tests/format.test.ts create mode 100644 tests/longPress.test.ts create mode 100644 tests/parser.test.ts create mode 100644 tests/proposals.test.ts create mode 100644 tests/query.test.ts create mode 100644 tests/settings.test.ts create mode 100644 tests/writer.test.ts create mode 100644 tsconfig.json create mode 100644 versions.json 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" +}