mirror of
https://github.com/savar-g/taskline.git
synced 2026-07-22 08:33:55 +00:00
Initial public release
This commit is contained in:
commit
acb81c91ef
47 changed files with 10100 additions and 0 deletions
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
* @Savar-G
|
||||
38
.github/workflows/ci.yml
vendored
Normal file
38
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -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
|
||||
43
.github/workflows/release.yml
vendored
Normal file
43
.github/workflows/release.yml
vendored
Normal file
|
|
@ -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"
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
main.js
|
||||
.DS_Store
|
||||
*.log
|
||||
13
CONTRIBUTING.md
Normal file
13
CONTRIBUTING.md
Normal file
|
|
@ -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 `<vault>/.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.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||
124
README.md
Normal file
124
README.md
Normal file
|
|
@ -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 `<vault>/.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)
|
||||
11
SECURITY.md
Normal file
11
SECURITY.md
Normal file
|
|
@ -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.
|
||||
67
esbuild.config.mjs
Normal file
67
esbuild.config.mjs
Normal file
|
|
@ -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();
|
||||
}
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -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
|
||||
}
|
||||
1868
package-lock.json
generated
Normal file
1868
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
34
package.json
Normal file
34
package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
513
src/captureRules.ts
Normal file
513
src/captureRules.ts
Normal file
|
|
@ -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 <weekday>'
|
||||
{
|
||||
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<VtTask, "due" | "scheduled"> {
|
||||
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;
|
||||
}
|
||||
76
src/editProperties.ts
Normal file
76
src/editProperties.ts
Normal file
|
|
@ -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<Exclude<EditablePriority, null>, 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));
|
||||
}
|
||||
121
src/format.ts
Normal file
121
src/format.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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}`;
|
||||
}
|
||||
183
src/main.ts
Normal file
183
src/main.ts
Normal file
|
|
@ -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<void> = Promise.resolve();
|
||||
|
||||
activeTab: VtTabId = "today";
|
||||
activeFilters: Set<string> = new Set();
|
||||
expandedGroups: Set<string> = new Set();
|
||||
|
||||
async onload(): Promise<void> {
|
||||
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<void> {
|
||||
const operation = this.settingsWrite.then(() => this.applySettings(candidate));
|
||||
this.settingsWrite = operation.catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
private async applySettings(candidateSettings: unknown): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
95
src/model.ts
Normal file
95
src/model.ts
Normal file
|
|
@ -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 "-";
|
||||
}
|
||||
}
|
||||
248
src/parser.ts
Normal file
248
src/parser.ts
Normal file
|
|
@ -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<string, "p1" | "p2" | "p3" | "p4"> = {
|
||||
"🔺": "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 };
|
||||
}
|
||||
18
src/proposals.ts
Normal file
18
src/proposals.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
158
src/query.ts
Normal file
158
src/query.ts
Normal file
|
|
@ -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<string, VtTask[]>();
|
||||
const headingGroups = new Map<string, { sourceId: string; heading: string; tasks: VtTask[] }>();
|
||||
|
||||
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 };
|
||||
}
|
||||
391
src/settings.ts
Normal file
391
src/settings.ts
Normal file
|
|
@ -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<string, TaskSourceSetting>;
|
||||
sourceByPath: Map<string, TaskSourceSetting>;
|
||||
groupById: Map<string, SourceGroupSetting>;
|
||||
areaById: Map<string, AreaSetting>;
|
||||
areasBySourceHeading: Map<string, AreaSetting>;
|
||||
routeByTag: Map<string, CaptureRouteSetting>;
|
||||
tagFilterByTag: Map<string, TagFilterSetting>;
|
||||
displayRank: Map<string, number>;
|
||||
selfAliases: Set<string>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
|
||||
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<T extends string>(
|
||||
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<T>(
|
||||
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<string, unknown> {
|
||||
const rejected = record(rejectedRaw);
|
||||
const source = rejected ? { ...active, ...rejected } : active;
|
||||
return JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function compileWorkspace(data: unknown): RuntimeWorkspace {
|
||||
const decoded = decodeSettings(data);
|
||||
const settings = decoded.settings;
|
||||
const issues: SettingsIssue[] = [...decoded.issues];
|
||||
const sourceById = new Map<string, TaskSourceSetting>();
|
||||
const sourceByPath = new Map<string, TaskSourceSetting>();
|
||||
const groupById = new Map<string, SourceGroupSetting>();
|
||||
const areaById = new Map<string, AreaSetting>();
|
||||
const areasBySourceHeading = new Map<string, AreaSetting>();
|
||||
const routeByTag = new Map<string, CaptureRouteSetting>();
|
||||
const tagFilterByTag = new Map<string, TagFilterSetting>();
|
||||
|
||||
const duplicate = <T>(map: Map<string, T>, 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;
|
||||
}
|
||||
152
src/settingsTab.ts
Normal file
152
src/settingsTab.ts
Normal file
|
|
@ -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<string, string>();
|
||||
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}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
175
src/store.ts
Normal file
175
src/store.ts
Normal file
|
|
@ -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<string, VtTask[]> = new Map();
|
||||
private proposalsByFile: Map<string, VtProposal[]> = new Map();
|
||||
private listeners: Set<VtChangeListener> = new Set();
|
||||
private eventRefs: Array<ReturnType<App["vault"]["on"]>> = [];
|
||||
private reparseGeneration: Map<string, number> = new Map();
|
||||
private disposed = false;
|
||||
|
||||
constructor(app: App, workspace: RuntimeWorkspace) {
|
||||
this.app = app;
|
||||
this.workspace = workspace;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
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<boolean> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
955
src/todayView.ts
Normal file
955
src/todayView.ts
Normal file
|
|
@ -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<VtTabId, string> = {
|
||||
today: "Today",
|
||||
upcoming: "Upcoming",
|
||||
all: "All Tasks",
|
||||
};
|
||||
|
||||
const TAB_ICONS: Record<VtTabId, string> = {
|
||||
today: "calendar-days",
|
||||
upcoming: "calendar-range",
|
||||
all: "list-todo",
|
||||
};
|
||||
|
||||
const PRIORITY_RANK: Record<string, number> = { 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<AnimToken>();
|
||||
|
||||
// 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<HTMLElement>();
|
||||
|
||||
// Roving tabindex state + per-row keyboard actions (rebuilt each render).
|
||||
private rows: HTMLElement[] = [];
|
||||
private activeRow = 0;
|
||||
private primaryAction: Map<HTMLElement, () => void> = new Map();
|
||||
private editAction: Map<HTMLElement, () => void> = new Map();
|
||||
private rejectAction: Map<HTMLElement, () => 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<VaultTasksPlugin["store"]>, 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<HTMLElement>(`.vt-tab[data-tab="${next}"]`);
|
||||
btn?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private buildFilterPills(openTasks: VtTask[]): void {
|
||||
const areaSet = new Set<string>();
|
||||
const tagSet = new Set<string>();
|
||||
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<VaultTasksPlugin["store"]>, 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<VaultTasksPlugin["store"]>, 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<VaultTasksPlugin["store"]>, 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<string, VtTask[]>();
|
||||
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<HTMLElement>(".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<HTMLElement>('.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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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)?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
31
src/ui/areaColor.ts
Normal file
31
src/ui/areaColor.ts
Normal file
|
|
@ -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})`;
|
||||
}
|
||||
711
src/ui/captureModal.ts
Normal file
711
src/ui/captureModal.ts
Normal file
|
|
@ -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<number>();
|
||||
|
||||
// ---- '#' 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<void>;
|
||||
|
||||
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<HTMLElement>(".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 ' #<area>' 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<void> {
|
||||
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<HTMLElement>(".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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
402
src/ui/editModal.ts
Normal file
402
src/ui/editModal.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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<string, HTMLButtonElement>();
|
||||
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<HTMLElement>(".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<HTMLElement>(".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<HTMLButtonElement>('.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<HTMLButtonElement>("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<HTMLButtonElement>(`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<HTMLButtonElement>(`.vt-calendar-day[data-date="${targetIso}"]`);
|
||||
if (!next) {
|
||||
this.calendarMonth = new Date(current.getFullYear(), current.getMonth(), 1);
|
||||
this.renderCalendar();
|
||||
next = this.calendarWrap?.querySelector<HTMLButtonElement>(`.vt-calendar-day[data-date="${targetIso}"]`);
|
||||
}
|
||||
this.calendarWrap?.querySelectorAll<HTMLButtonElement>(".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<HTMLButtonElement>(".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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
13
src/ui/longPress.ts
Normal file
13
src/ui/longPress.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
120
src/ui/motion.ts
Normal file
120
src/ui/motion.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
62
src/ui/proposedRow.ts
Normal file
62
src/ui/proposedRow.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
211
src/ui/taskRow.ts
Normal file
211
src/ui/taskRow.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
126
src/writer.ts
Normal file
126
src/writer.ts
Normal file
|
|
@ -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<TFile> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, { apiV1?: TasksPluginApiV1 }> } };
|
||||
return anyApp.plugins?.plugins?.["obsidian-tasks-plugin"]?.apiV1 ?? null;
|
||||
}
|
||||
|
||||
export async function completeTask(app: App, task: VtTask): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await editLine(app, task, (line) => annotation ? insertAnnotation(setStatusChar(line, char), annotation) : setStatusChar(line, char));
|
||||
}
|
||||
|
||||
export async function removeLine(app: App, ref: LineRef): Promise<void> {
|
||||
await editLine(app, ref, () => []);
|
||||
}
|
||||
|
||||
export async function appendUnderHeading(app: App, filePath: string, heading: string, line: string | string[]): Promise<void> {
|
||||
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<void> {
|
||||
await editLine(app, task, () => newLine);
|
||||
}
|
||||
|
||||
export async function moveTaskLine(
|
||||
app: App,
|
||||
task: VtTask,
|
||||
newLine: string,
|
||||
dest: { file: string; heading: string }
|
||||
): Promise<void> {
|
||||
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<InsertedBlockReceipt>({
|
||||
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)),
|
||||
});
|
||||
}
|
||||
226
src/writerCore.ts
Normal file
226
src/writerCore.ts
Normal file
|
|
@ -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<T>(operations: {
|
||||
appendDestination(): Promise<T>;
|
||||
removeSource(): Promise<void>;
|
||||
rollbackDestination(receipt: T): Promise<void>;
|
||||
}): Promise<void> {
|
||||
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<VtProposal, "filePath">, task: Pick<VtTask, "filePath">): 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<VtTask, "rawLine" | "lineNo">, 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()}`);
|
||||
}
|
||||
1823
styles.css
Normal file
1823
styles.css
Normal file
File diff suppressed because it is too large
Load diff
16
tests/areaColor.test.ts
Normal file
16
tests/areaColor.test.ts
Normal file
|
|
@ -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]+\)$/);
|
||||
});
|
||||
});
|
||||
161
tests/captureRules.test.ts
Normal file
161
tests/captureRules.test.ts
Normal file
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
60
tests/editProperties.test.ts
Normal file
60
tests/editProperties.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
43
tests/fixtures.ts
Normal file
43
tests/fixtures.ts
Normal file
|
|
@ -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<VtTask> & { title: string }): VtTask {
|
||||
return {
|
||||
sourceId: "tasks",
|
||||
filePath: "Tasks.md",
|
||||
lineNo: 1,
|
||||
rawLine: "",
|
||||
status: "todo",
|
||||
statusChar: " ",
|
||||
tags: [],
|
||||
priority: null,
|
||||
heading: "Writing",
|
||||
subNotes: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
48
tests/format.test.ts
Normal file
48
tests/format.test.ts
Normal file
|
|
@ -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")
|
||||
);
|
||||
});
|
||||
22
tests/longPress.test.ts
Normal file
22
tests/longPress.test.ts
Normal file
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
128
tests/parser.test.ts
Normal file
128
tests/parser.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
62
tests/proposals.test.ts
Normal file
62
tests/proposals.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
115
tests/query.test.ts
Normal file
115
tests/query.test.ts
Normal file
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
166
tests/settings.test.ts
Normal file
166
tests/settings.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
208
tests/writer.test.ts
Normal file
208
tests/writer.test.ts
Normal file
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.1.0": "1.5.0"
|
||||
}
|
||||
Loading…
Reference in a new issue