v0.7.0: Gantt chart, notebook targeting & profile-wide scope
Single release covering two feature groups (CHANGELOG 0.7.0 + 0.6.0): - gtd-gantt block: project-grouped timeline of bars (notes with begin/end dates) and milestones (to-dos on their due date); dual-role fence, swimlanes, month tick header, today line, read-only. - notebook: targeting across all four view blocks; scope: all for profile-wide scans with size guardrails; note body fetched only when the inclusion mode actually needs the gtd block. jest 140/140. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9780d95223
commit
187fd34531
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
|||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
publish/
|
publish/
|
||||||
|
build-archive/
|
||||||
|
|||||||
15
CHANGELOG.md
15
CHANGELOG.md
@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
All notable changes to GTD Calendar are documented here. Versions follow the plugin's `manifest.json` / `package.json` version, which also matches the published npm releases.
|
All notable changes to GTD Calendar are documented here. Versions follow the plugin's `manifest.json` / `package.json` version, which also matches the published npm releases.
|
||||||
|
|
||||||
|
## 0.7.0 — Gantt chart
|
||||||
|
|
||||||
|
- New `gtd-gantt` block: a project-grouped timeline of bars and milestones.
|
||||||
|
- **Dual-role fence** — a `gtd-gantt` block **with** a `project:` key is an *item declaration* (renders as an inline `gantt: <project>` badge); **without** one it's the *chart*. Same rule applied by the renderer and the collector.
|
||||||
|
- **Notes → bars** (`begin-date` + `end-date`; both required and valid, `end` ≥ `begin`, else excluded with a warning). **To-dos → milestones** (◆ on the native due date; `begin`/`end` on a to-do are ignored with a warning; no due date → excluded). No Unscheduled section.
|
||||||
|
- Items grouped into swimlanes by `project:` (case-insensitive, alphabetical); completed milestones dimmed/struck, recurring get ↻; styling keys (`bg-colour`/`fg-colour`/`title`/`text`) mirror the `gtd` block.
|
||||||
|
- Chart options: `title`, `scope` (incl. `all`), `notebook`, `filter-project`, `sort`/`sort-type` (`begin-date`/`title`/`modified-date`), `card-detail`. No `notes`/`todos` option (items opt in via `project:`).
|
||||||
|
- Rendering: CSS-grid timeline with a month tick header, a "today" line, horizontal scroll for wide ranges, and click-to-open. Read-only.
|
||||||
|
|
||||||
|
## 0.6.0 — Notebook targeting & profile-wide scope
|
||||||
|
|
||||||
|
- Added `notebook:` to every view block (`gtd-calendar`, `gtd-kanban`, `gtd-matrix`, `gtd-gantt`): root the folder scan at a specific notebook — by unique name, `Parent/Child` title path, or 32-char folder id — even one outside the host note's tree. `scope` then applies relative to it. Unknown/ambiguous notebooks warn and fall back to the host note's folder.
|
||||||
|
- Added `scope: all`: scan **every** notebook in the profile. Guardrails — the stats footnote reports the scan size and is marked `scope: all`, and a soft ⚠ warning appears past ~2000 scanned notes. `notebook:` is ignored under `scope: all` (with a warning).
|
||||||
|
- Performance: view collectors now request note bodies only when the inclusion mode actually needs the `gtd` block, trimming data fetched for profile-wide scans.
|
||||||
|
|
||||||
## 0.5.0 — The Skeleton Matrix
|
## 0.5.0 — The Skeleton Matrix
|
||||||
|
|
||||||
- Added `mode: skeleton | eisenhower` to `gtd-matrix` (default: `skeleton`).
|
- Added `mode: skeleton | eisenhower` to `gtd-matrix` (default: `skeleton`).
|
||||||
|
|||||||
64
README.md
64
README.md
@ -46,7 +46,8 @@ Goes in the note that should display the calendar. All options are optional.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `view` | `day`, `week`, `month`, `month-grid` | `day` | `day`/`week`/`month` (or `d`/`w`/`m`) render a compact tile strip spanning all events. `month-grid` (or `grid`) renders the entire **current** calendar month as a classic 7-column grid with weekday headers. |
|
| `view` | `day`, `week`, `month`, `month-grid` | `day` | `day`/`week`/`month` (or `d`/`w`/`m`) render a compact tile strip spanning all events. `month-grid` (or `grid`) renders the entire **current** calendar month as a classic 7-column grid with weekday headers. |
|
||||||
| `title` | text | — | Heading above the calendar. |
|
| `title` | text | — | Heading above the calendar. |
|
||||||
| `scope` | `this-folder`, `children`, or a number | `this-folder` | Which folders to scan: just this one, all descendants, or *n* levels down (`scope: 2`). Broad scopes scan every note body in the tree — fine for normal folders, worth knowing for huge ones. |
|
| `scope` | `this-folder`, `children`, a number, or `all` | `this-folder` | Which folders to scan: just this one, all descendants, or *n* levels down (`scope: 2`). Broad scopes scan every note body in the tree — fine for normal folders, worth knowing for huge ones. **`scope: all` scans every notebook in your entire profile** — powerful for a global dashboard, but potentially slow on large profiles; a footnote reports how much was scanned, and a ⚠ appears past ~2000 notes. `notebook:` is ignored under `scope: all`. |
|
||||||
|
| `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook instead of the one holding this note — even one in a different tree. `scope` then applies relative to it. A bare name must be unique; if several notebooks share it, qualify with a path (`Work/Archive`). Unknown/ambiguous → ⚠ warning + falls back to this note's folder. |
|
||||||
| `notes` | `gtd-only`, `all`, `none` | `gtd-only` | Which plain notes appear: only those with a `gtd` block, every note in scope, or none. |
|
| `notes` | `gtd-only`, `all`, `none` | `gtd-only` | Which plain notes appear: only those with a `gtd` block, every note in scope, or none. |
|
||||||
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Same, for to-dos. `todos: all` is handy — every to-do with a due date lands on the calendar with zero markup. |
|
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Same, for to-dos. `todos: all` is handy — every to-do with a due date lands on the calendar with zero markup. |
|
||||||
| `sort` | `asc`, `desc` | `asc` | Order of events *within* each day/week/month and in Unscheduled. The timeline itself is always chronological. |
|
| `sort` | `asc`, `desc` | `asc` | Order of events *within* each day/week/month and in Unscheduled. The timeline itself is always chronological. |
|
||||||
@ -108,7 +109,8 @@ A completed to-do always lands in **Done**, even if it still carries the in-prog
|
|||||||
| Option | Values | Default | Description |
|
| Option | Values | Default | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `title` | text | — | Heading above the board. |
|
| `title` | text | — | Heading above the board. |
|
||||||
| `scope` | `this-folder`, `children`, integer | `this-folder` | Same folder-scanning rules as the calendar. |
|
| `scope` | `this-folder`, `children`, integer, `all` | `this-folder` | Same folder-scanning rules as the calendar, including `all` (every notebook — see the calendar table's caution). |
|
||||||
|
| `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook (see the calendar table for the full resolution rules). `scope` applies relative to it. |
|
||||||
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. `gtd-only` requires a `gtd` block; `all` includes every to-do in scope. (Plain notes never appear on the board.) |
|
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. `gtd-only` requires a `gtd` block; `all` includes every to-do in scope. (Plain notes never appear on the board.) |
|
||||||
| `sort-type` | `due-date`, `title`, `modified-date` | `due-date` | Order of cards within each column. Under `due-date`, cards with no due date sort last. |
|
| `sort-type` | `due-date`, `title`, `modified-date` | `due-date` | Order of cards within each column. Under `due-date`, cards with no due date sort last. |
|
||||||
| `sort` | `asc`, `desc` | `asc` | Sort direction. |
|
| `sort` | `asc`, `desc` | `asc` | Sort direction. |
|
||||||
@ -153,7 +155,8 @@ Quadrants from the urgent/important tags: Do First (both), Schedule (important),
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `mode` | `skeleton`, `eisenhower` | `skeleton` | Which matrix semantics to use. |
|
| `mode` | `skeleton`, `eisenhower` | `skeleton` | Which matrix semantics to use. |
|
||||||
| `title` | text | — | Heading above the matrix. |
|
| `title` | text | — | Heading above the matrix. |
|
||||||
| `scope` | `this-folder`, `children`, integer | `this-folder` | Same folder-scanning rules as the calendar and kanban. |
|
| `scope` | `this-folder`, `children`, integer, `all` | `this-folder` | Same folder-scanning rules as the calendar and kanban, including `all` (every notebook — see the calendar table's caution). |
|
||||||
|
| `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook (see the calendar table for the full resolution rules). `scope` applies relative to it. |
|
||||||
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. Plain notes never do. |
|
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Which to-dos appear. Plain notes never do. |
|
||||||
| `sort-type` | `due-date`, `title`, `modified-date` | `due-date` | Order within each quadrant; dateless cards sort last under `due-date`. |
|
| `sort-type` | `due-date`, `title`, `modified-date` | `due-date` | Order within each quadrant; dateless cards sort last under `due-date`. |
|
||||||
| `sort` | `asc`, `desc` | `asc` | Sort direction. |
|
| `sort` | `asc`, `desc` | `asc` | Sort direction. |
|
||||||
@ -165,6 +168,61 @@ Quadrants from the urgent/important tags: Do First (both), Schedule (important),
|
|||||||
|
|
||||||
Cards behave like kanban cards: compact, ↻ for recurring, click to open. Read-only.
|
Cards behave like kanban cards: compact, ↻ for recurring, click to open. Read-only.
|
||||||
|
|
||||||
|
## Gantt chart
|
||||||
|
|
||||||
|
A timeline of **projects** built from items across your notes. Unlike the other views, gantt items opt in with their **own** ` ```gtd-gantt ` block (not the `gtd` block), and every item declares a `project:` — that's what groups items into swimlanes.
|
||||||
|
|
||||||
|
The same fence name does two jobs:
|
||||||
|
|
||||||
|
- A ` ```gtd-gantt ` block **with** a `project:` key is an **item declaration**. It renders in place as a small inline badge (`gantt: <project>`), and the item appears on any gantt chart that scans it.
|
||||||
|
- A ` ```gtd-gantt ` block **without** a `project:` key is the **chart** itself.
|
||||||
|
|
||||||
|
### Item blocks
|
||||||
|
|
||||||
|
Put one in any note or to-do you want on the timeline:
|
||||||
|
|
||||||
|
````
|
||||||
|
```gtd-gantt
|
||||||
|
project: bluesky
|
||||||
|
begin-date: 2026-07-01 # notes only — the bar's span
|
||||||
|
end-date: 2026-07-14
|
||||||
|
bg-colour: teal # optional, same styling keys as the gtd block
|
||||||
|
fg-colour: white
|
||||||
|
title: Design phase # optional; defaults to the note title
|
||||||
|
text: Hover detail # optional
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
- **Notes become bars** — a span from `begin-date` to `end-date`. Both are required and must be valid, with `end-date` on or after `begin-date`; otherwise the item is excluded with a ⚠ warning.
|
||||||
|
- **To-dos become milestones** — a ◆ marker on the to-do's **native due date**. `begin-date`/`end-date` on a to-do are ignored (with a warning). A to-do with no due date is excluded with a warning. Completed milestones render dimmed and struck through; recurring to-dos get a ↻.
|
||||||
|
- Dates accept `yyyy-mm-dd` (and `mm-dd-yyyy`), same as everywhere else.
|
||||||
|
|
||||||
|
There is no "Unscheduled" section — an item with no usable date simply doesn't appear (the warning tells you why).
|
||||||
|
|
||||||
|
### Chart block
|
||||||
|
|
||||||
|
Put this where you want the timeline drawn:
|
||||||
|
|
||||||
|
````
|
||||||
|
```gtd-gantt
|
||||||
|
title: Roadmap
|
||||||
|
scope: children # this-folder | children | N | all
|
||||||
|
filter-project: bluesky # optional — limit the chart to one project
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
| Option | Values | Default | What it does |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `title` | text | — | Heading above the chart. |
|
||||||
|
| `scope` | `this-folder`, `children`, a number, or `all` | `this-folder` | Same folder-scanning rules as every other view. |
|
||||||
|
| `notebook` | notebook name, `Parent/Child` path, or folder id | this note's folder | Root the scan at a specific notebook (see the calendar table). Ignored under `scope: all`. |
|
||||||
|
| `filter-project` | project name | — | Limit the chart to a single project (case-insensitive). |
|
||||||
|
| `sort-type` | `begin-date`, `title`, `modified-date` | `begin-date` | Row order **within** each project. |
|
||||||
|
| `sort` | `asc`, `desc` | `asc` | Sort direction. |
|
||||||
|
| `card-detail` | `hover`, `always`, `none` | `hover` | `none` suppresses the hover tooltip on bars/milestones. |
|
||||||
|
|
||||||
|
Note there is **no `notes`/`todos` option** — items opt in via their `project:` block, so those keys aren't used (and warn as unknown if set). Projects are shown alphabetically; the chart draws a month tick header, a red "today" line when today falls in range, and scrolls horizontally for wide ranges. Bars and milestones are clickable — they open the source note. Read-only.
|
||||||
|
|
||||||
## Drilldown
|
## Drilldown
|
||||||
|
|
||||||
Everything is clickable: single-event tiles, every row of a multi-event hover card, every chip in the month grid, every Unscheduled chip. Clicking opens the source note.
|
Everything is clickable: single-event tiles, every row of a multi-event hover card, every chip in the month grid, every Unscheduled chip. Clicking opens the source note.
|
||||||
|
|||||||
71
SPEC.md
71
SPEC.md
@ -25,7 +25,8 @@ Dateless items are not lost — they collect in an **Unscheduled** section below
|
|||||||
```gtd-calendar
|
```gtd-calendar
|
||||||
view: month # day | week | month | month-grid (default: day)
|
view: month # day | week | month | month-grid (default: day)
|
||||||
title: Editorial Schedule
|
title: Editorial Schedule
|
||||||
scope: this-folder # this-folder | children | <integer depth> (default: this-folder)
|
scope: this-folder # this-folder | children | <integer depth> | all (default: this-folder)
|
||||||
|
notebook: Work/Ideas # notebook name | Parent/Child path | folder id (default: this note's folder)
|
||||||
notes: gtd-only # gtd-only | all | none (default: gtd-only)
|
notes: gtd-only # gtd-only | all | none (default: gtd-only)
|
||||||
todos: gtd-only # gtd-only | all | none (default: gtd-only)
|
todos: gtd-only # gtd-only | all | none (default: gtd-only)
|
||||||
sort: asc # asc | desc (default: asc)
|
sort: asc # asc | desc (default: asc)
|
||||||
@ -36,7 +37,8 @@ sort-type: title # title | modified_date (default: title)
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `view` | `day`, `week`, `month` (also `d`/`w`/`m`), `month-grid` (also `grid`) | `day` | Grouping granularity. `day`/`week`/`month` render the compact tile strip spanning all events; `month-grid` renders a classic 7-column grid of the entire *current* calendar month with weekday headers, dimmed adjacent-month days, today highlighted, and clickable event chips in each day cell. Events outside the displayed month are counted in a footnote. |
|
| `view` | `day`, `week`, `month` (also `d`/`w`/`m`), `month-grid` (also `grid`) | `day` | Grouping granularity. `day`/`week`/`month` render the compact tile strip spanning all events; `month-grid` renders a classic 7-column grid of the entire *current* calendar month with weekday headers, dimmed adjacent-month days, today highlighted, and clickable event chips in each day cell. Events outside the displayed month are counted in a footnote. |
|
||||||
| `title` | string | none | Rendered as a heading above the calendar. |
|
| `title` | string | none | Rendered as a heading above the calendar. |
|
||||||
| `scope` | `this-folder`, `children`, or an integer | `this-folder` | `children` = unlimited recursion into subfolders. An integer *n* = this folder plus *n* levels of descendants (`0` ≡ `this-folder`). Documentation will caution that broad scopes scan every note body in the tree. |
|
| `scope` | `this-folder`, `children`, an integer, or `all` | `this-folder` | `children` = unlimited recursion into subfolders. An integer *n* = this folder plus *n* levels of descendants (`0` ≡ `this-folder`). `all` = **every notebook in the profile**, ignoring the root folder and tree position entirely — an explicit, deliberately expensive opt-in for a global dashboard. Guardrails: the stats footnote reports scanned notebooks/notes and is marked `scope: all`; a soft ⚠ warning appears when more than ~2000 notes are scanned (advisory, never blocks). `notebook:` is ignored under `scope: all` (with a warning). Documentation cautions that broad scopes scan every note body in the tree. |
|
||||||
|
| `notebook` | notebook title, `Parent/Child` title path, or 32-char folder id | this note's folder | Roots the folder scan at a chosen notebook, possibly outside the host note's tree; `scope` then applies relative to it. Resolution order: (1) a raw folder id; (2) a `Parent/Child/…` case-insensitive title path; (3) a bare title, used only if unique. Any miss — unknown id, unresolvable path, ambiguous or absent title — appends a ⚠ warning and falls back to the host note's own folder (warn-don't-fail). |
|
||||||
| `notes` | `gtd-only`, `all`, `none` | `gtd-only` | Which plain notes participate. `all` includes notes without a `gtd` block (they receive default styling and are Unscheduled unless... see §3.2). |
|
| `notes` | `gtd-only`, `all`, `none` | `gtd-only` | Which plain notes participate. `all` includes notes without a `gtd` block (they receive default styling and are Unscheduled unless... see §3.2). |
|
||||||
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Same semantics for todos. |
|
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Same semantics for todos. |
|
||||||
| `sort` | `asc`, `desc` | `asc` | Order of events **within** each day/week/month grouping and within Unscheduled. Groupings themselves always run chronologically. |
|
| `sort` | `asc`, `desc` | `asc` | Order of events **within** each day/week/month grouping and within Unscheduled. Groupings themselves always run chronologically. |
|
||||||
@ -77,7 +79,8 @@ An **empty** ` ```gtd ` block is valid and meaningful: it is the opt-in signal w
|
|||||||
```
|
```
|
||||||
```gtd-kanban
|
```gtd-kanban
|
||||||
title: Editorial Board
|
title: Editorial Board
|
||||||
scope: this-folder # same semantics as gtd-calendar
|
scope: this-folder # this-folder | children | N | all — same semantics as gtd-calendar
|
||||||
|
notebook: Work/Ideas # same semantics as gtd-calendar (default: this note's folder)
|
||||||
todos: gtd-only # gtd-only | all | none (default: gtd-only)
|
todos: gtd-only # gtd-only | all | none (default: gtd-only)
|
||||||
sort-type: due-date # due-date | title | modified-date (default: due-date)
|
sort-type: due-date # due-date | title | modified-date (default: due-date)
|
||||||
sort: asc # asc | desc (default: asc)
|
sort: asc # asc | desc (default: asc)
|
||||||
@ -94,7 +97,8 @@ Three columns, always shown: **Backlog**, **In Progress**, **Done**. To-dos only
|
|||||||
```gtd-matrix
|
```gtd-matrix
|
||||||
mode: skeleton # skeleton | eisenhower (default: skeleton)
|
mode: skeleton # skeleton | eisenhower (default: skeleton)
|
||||||
title: Priorities
|
title: Priorities
|
||||||
scope: this-folder
|
scope: this-folder # this-folder | children | N | all — same semantics as gtd-calendar
|
||||||
|
notebook: Work/Ideas # same semantics as gtd-calendar (default: this note's folder)
|
||||||
todos: gtd-only
|
todos: gtd-only
|
||||||
sort-type: due-date
|
sort-type: due-date
|
||||||
sort: asc
|
sort: asc
|
||||||
@ -113,6 +117,53 @@ A 2×2 grid with labelled axes. To-dos only; **completed to-dos are excluded ent
|
|||||||
|
|
||||||
A warning is raised if `urgent-tag` and `important-tag` resolve to the same tag (Eisenhower mode only — the axes would collapse). Read-only.
|
A warning is raised if `urgent-tag` and `important-tag` resolve to the same tag (Eisenhower mode only — the axes would collapse). Read-only.
|
||||||
|
|
||||||
|
### 2.5 The `gtd-gantt` block (dual-role: chart **and** item declaration)
|
||||||
|
|
||||||
|
Unlike the other views, gantt items opt in through their **own** `gtd-gantt` block rather than the shared `gtd` block, and every item carries a `project:` that groups items into swimlanes. The same fence name therefore serves two roles, disambiguated by a single rule:
|
||||||
|
|
||||||
|
> **A `gtd-gantt` block containing a non-empty `project:` key is an ITEM declaration; one without is the CHART config block.**
|
||||||
|
|
||||||
|
The markdown-it content script applies this rule with a cheap line scan (no YAML lib): an item block renders in place as a small inline badge (`gantt: <project>`) and is never drawn as a chart; a chart block emits the usual webview placeholder. `collectGantt` applies the identical rule when scanning (empty/whitespace project ⇒ treated as a chart block, i.e. not an item).
|
||||||
|
|
||||||
|
**Item block** (lives in any participating note/todo):
|
||||||
|
|
||||||
|
```
|
||||||
|
```gtd-gantt
|
||||||
|
project: bluesky # REQUIRED — the swimlane; its presence marks this an item
|
||||||
|
begin-date: 2026-07-01 # notes only — bar span start (yyyy-mm-dd or mm-dd-yyyy)
|
||||||
|
end-date: 2026-07-14 # notes only — bar span end
|
||||||
|
bg-colour: teal # optional styling, mirroring the gtd block
|
||||||
|
fg-colour: white
|
||||||
|
title: Design phase # optional; defaults to the note title
|
||||||
|
text: Hover detail # optional; tooltip
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key | Applies to | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| `project` | both | Required. Groups the item into a swimlane (case-insensitive; first-seen casing is the display name). Empty/absent ⇒ this is a chart block, not an item. |
|
||||||
|
| `begin-date` / `end-date` | notes → bars | Both required and valid, `end-date` ≥ `begin-date`, else the item is **excluded with a warning**. |
|
||||||
|
| (native due date) | todos → milestones | A to-do becomes a ◆ **milestone** on its own `todo_due`. `begin-date`/`end-date` on a to-do are ignored (warning). No due date ⇒ excluded with a warning. |
|
||||||
|
| `bg-colour`/`fg-colour`/`title`/`text` | both | As the gtd block. Bars default to the stable pastel hash when no colour is set. |
|
||||||
|
|
||||||
|
Completed to-do milestones render dimmed and struck through; recurring to-dos get the ↻ mark. There is **no Unscheduled section** — an item with no usable date is simply excluded (the warning explains why).
|
||||||
|
|
||||||
|
**Chart block** (lives where the timeline is drawn):
|
||||||
|
|
||||||
|
```
|
||||||
|
```gtd-gantt
|
||||||
|
title: Roadmap
|
||||||
|
scope: children # this-folder | children | N | all — same semantics as gtd-calendar
|
||||||
|
notebook: Work/Ideas # same semantics as gtd-calendar; ignored under scope: all
|
||||||
|
filter-project: bluesky # optional — limit to a single project (case-insensitive)
|
||||||
|
sort-type: begin-date # begin-date | title | modified-date (default: begin-date)
|
||||||
|
sort: asc # asc | desc (default: asc)
|
||||||
|
card-detail: hover # hover | always | none (default: hover)
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
There is deliberately **no `notes`/`todos` inclusion option**: items opt in solely via their `project:` block, so those keys are unknown here (and warn as such). Projects render alphabetically; within each project, rows sort by `sort-type`/`sort`. The renderer draws a month tick header, a "today" line when today falls within the chart's date envelope, and scrolls horizontally for wide ranges. The host note is always excluded from its own scan (as with every view). Bars and milestones are clickable (open the source note); hover detail uses a native tooltip, suppressed by `card-detail: none`. Read-only.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Data Model Rules
|
## 3. Data Model Rules
|
||||||
@ -314,6 +365,18 @@ A fourth display block: a 2×2 prioritisation matrix, tag-driven like the kanban
|
|||||||
- To-dos only; same `scope` / `todos` / `sort-type` / `sort` / `card-detail` options as the kanban; same compact clickable cards with ↻.
|
- To-dos only; same `scope` / `todos` / `sort-type` / `sort` / `card-detail` options as the kanban; same compact clickable cards with ↻.
|
||||||
- Own block (`gtd-matrix`), not a kanban mode — different layout, different tag semantics.
|
- Own block (`gtd-matrix`), not a kanban mode — different layout, different tag semantics.
|
||||||
|
|
||||||
|
### v0.6.0 — Notebook targeting & profile-wide scope — SHIPPED
|
||||||
|
|
||||||
|
Two scope-axis additions shared across all four view blocks (see §2.1 / §2.5 for the normative schema):
|
||||||
|
|
||||||
|
- **`notebook:`** roots the folder scan at a chosen notebook rather than the host note's own folder, so a dashboard note can live anywhere and aggregate a different tree. Resolution order: raw 32-char folder id → `Parent/Child` case-insensitive title path → unique bare title. Any miss (unknown id, unresolvable path, ambiguous or absent title) warns and falls back to the host folder (warn-don't-fail). `scope` applies relative to the resolved notebook. Implemented as a pure `resolveNotebook.ts` (folders + spec → id + warning) plus a shared `parseNotebookOption`; resolution lives in `index.ts` (needs the folder tree), collectors stay unchanged.
|
||||||
|
- **`scope: all`** scans every notebook in the profile, ignoring root and depth — an explicit, deliberately expensive opt-in ("very dangerous, yes"). Represented as a `scopeAll` flag (not a `scopeDepth` sentinel); `folderScope` returns all folder ids when set. Guardrails: the stats footnote reports scan size and is marked `scope: all`; a soft ⚠ appears past ~2000 scanned notes (advisory, never blocks). `notebook:` + `scope: all` → `all` wins, with a warning.
|
||||||
|
- **Body-fetch optimisation** (groundwork the profile-wide scan leans on): the data adapter fetches note bodies only when a view's inclusion mode actually scans the `gtd` block, via an `includeBody` hint; behaviour-preserving.
|
||||||
|
|
||||||
|
### v0.7.0 — Gantt chart (`gtd-gantt` block) — SHIPPED
|
||||||
|
|
||||||
|
A fifth view: a project-grouped timeline, on the **views axis** like the matrix (another read-only rendering of existing notes/to-dos), not the authoring axis. Unlike the other blocks, gantt items opt in through their **own** `gtd-gantt` block carrying a `project:`, and the same fence name is disambiguated by that key (item vs chart — see §2.5). Notes become bars (`begin-date`→`end-date`), to-dos become ◆ milestones on their due date; grouped into per-project swimlanes, no Unscheduled section (dateless/invalid items are excluded with a warning). Data layer is pure and unit-tested (`ganttBlock.ts`, `parseGanttConfig.ts`, `collectGantt.ts`); rendering is a CSS-grid timeline (month ticks, today line, horizontal scroll, click-to-open). Item blocks render in place as a small `gantt: <project>` badge. Read-only. Open follow-ups (deferred): richer hover cards (currently native tooltips), `card-detail: always` inline text on bars, an optional Unscheduled strip.
|
||||||
|
|
||||||
### v0.5.0+ — The authoring axis (drag-and-drop + create-from-view) — one deliberate epic
|
### v0.5.0+ — The authoring axis (drag-and-drop + create-from-view) — one deliberate epic
|
||||||
|
|
||||||
Clustered because they share write-back machinery, and adopting any of them commits the plugin to mutating the vault:
|
Clustered because they share write-back machinery, and adopting any of them commits the plugin to mutating the vault:
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
* https://jestjs.io/docs/configuration
|
* https://jestjs.io/docs/configuration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default {
|
module.exports = {
|
||||||
// All imported modules in your tests should be mocked automatically
|
// All imported modules in your tests should be mocked automatically
|
||||||
// automock: false,
|
// automock: false,
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "joplin-plugin-gtd-calendar",
|
"name": "joplin-plugin-gtd-calendar",
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
|
"predist": "bash scripts/archive-jpl.sh",
|
||||||
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive",
|
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive",
|
||||||
"prepare": "npm run dist",
|
"prepare": "npm run dist",
|
||||||
"update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force",
|
"update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force",
|
||||||
|
|||||||
38
scripts/archive-jpl.sh
Normal file
38
scripts/archive-jpl.sh
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Archive existing publish/*.jpl builds before a new `npm run dist`.
|
||||||
|
#
|
||||||
|
# Per CLAUDE.md: never overwrite a previous .jpl. This moves each current
|
||||||
|
# publish/*.jpl into build-archive/ with a version + timestamp suffix so a
|
||||||
|
# fresh build can't clobber the last one. No-op when no .jpl is present.
|
||||||
|
#
|
||||||
|
# The archive lives at repo root (NOT under publish/) deliberately: webpack's
|
||||||
|
# buildMain step runs `fs.removeSync(publishDir)` and would wipe an in-publish
|
||||||
|
# archive, and `package.json` "files": ["publish"] would ship it to npm.
|
||||||
|
# build-archive/ is git-ignored and outside both.
|
||||||
|
#
|
||||||
|
# Wired as the `predist` npm script, so it runs automatically before every
|
||||||
|
# `npm run dist`. Safe to run manually too.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
publish_dir="$repo_root/publish"
|
||||||
|
archive_dir="$repo_root/build-archive"
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
jpls=("$publish_dir"/*.jpl)
|
||||||
|
if [ ${#jpls[@]} -eq 0 ]; then
|
||||||
|
echo "archive-jpl: no publish/*.jpl to archive."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
version="$(node -p "require('$repo_root/package.json').version")"
|
||||||
|
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$archive_dir"
|
||||||
|
|
||||||
|
for jpl in "${jpls[@]}"; do
|
||||||
|
base="$(basename "$jpl" .jpl)"
|
||||||
|
dest="$archive_dir/${base}-${version}-${timestamp}.jpl"
|
||||||
|
mv "$jpl" "$dest"
|
||||||
|
echo "archive-jpl: moved $(basename "$jpl") -> build-archive/$(basename "$dest")"
|
||||||
|
done
|
||||||
@ -38,14 +38,20 @@ export default async function collectEvents(
|
|||||||
const folderIds = resolveScopedFolderIds(
|
const folderIds = resolveScopedFolderIds(
|
||||||
folders,
|
folders,
|
||||||
calendarFolderId,
|
calendarFolderId,
|
||||||
config.scopeDepth
|
config.scopeDepth,
|
||||||
|
config.scopeAll
|
||||||
);
|
);
|
||||||
|
|
||||||
let scannedNotes = 0;
|
let scannedNotes = 0;
|
||||||
const events: GtdEvent[] = [];
|
const events: GtdEvent[] = [];
|
||||||
|
|
||||||
|
// Bodies are only scanned (gtd block → styling/date override) when at least
|
||||||
|
// one of notes/todos is included. When both are "none" the loop produces
|
||||||
|
// nothing, so skip fetching bodies entirely.
|
||||||
|
const needsBody = config.notes !== "none" || config.todos !== "none";
|
||||||
|
|
||||||
for (const folderId of folderIds) {
|
for (const folderId of folderIds) {
|
||||||
const notes = await adapter.getNotesInFolder(folderId);
|
const notes = await adapter.getNotesInFolder(folderId, needsBody);
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
scannedNotes += 1;
|
scannedNotes += 1;
|
||||||
if (note.id === calendarNoteId) continue;
|
if (note.id === calendarNoteId) continue;
|
||||||
|
|||||||
280
src/Gtd/collectGantt.ts
Normal file
280
src/Gtd/collectGantt.ts
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
import {
|
||||||
|
DataAdapter,
|
||||||
|
GanttBar,
|
||||||
|
GanttChart,
|
||||||
|
GanttConfig,
|
||||||
|
GanttMilestone,
|
||||||
|
GanttProject,
|
||||||
|
RawNote,
|
||||||
|
RECURRING_TAG,
|
||||||
|
} from "./types";
|
||||||
|
import resolveScopedFolderIds from "./folderScope";
|
||||||
|
import extractGanttBlock from "./ganttBlock";
|
||||||
|
import { parseGtdDate, timestampToLocalDate } from "./resolveDate";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a gantt chart from items in scope (SPEC.md §2.5).
|
||||||
|
*
|
||||||
|
* - Scan every note in scope for an ITEM gtd-gantt block (one with a
|
||||||
|
* `project:` key). Chart blocks (no project) are ignored as items.
|
||||||
|
* - Notes → bars (begin-date + end-date, both required and valid).
|
||||||
|
* - To-dos → milestones (the to-do's native due date); begin/end on a to-do
|
||||||
|
* are ignored with a warning.
|
||||||
|
* - Items with missing/unparseable/invalid dates are excluded with a warning
|
||||||
|
* (a gantt has no Unscheduled section).
|
||||||
|
* - Grouped by project (case preserved from first sighting); projects sorted
|
||||||
|
* alphabetically, items sorted within each project per config.
|
||||||
|
*
|
||||||
|
* The host gantt note is always excluded.
|
||||||
|
*/
|
||||||
|
export default async function collectGantt(
|
||||||
|
adapter: DataAdapter,
|
||||||
|
ganttNoteId: string,
|
||||||
|
ganttFolderId: string,
|
||||||
|
config: GanttConfig
|
||||||
|
): Promise<GanttChart> {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
const folders = await adapter.getFolders();
|
||||||
|
const folderIds = resolveScopedFolderIds(
|
||||||
|
folders,
|
||||||
|
ganttFolderId,
|
||||||
|
config.scopeDepth,
|
||||||
|
config.scopeAll
|
||||||
|
);
|
||||||
|
|
||||||
|
const filter = config.filterProject
|
||||||
|
? config.filterProject.toLowerCase()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Preserve first-seen display name per lowercased project key.
|
||||||
|
const projects = new Map<
|
||||||
|
string,
|
||||||
|
{ name: string; bars: GanttBar[]; milestones: GanttMilestone[] }
|
||||||
|
>();
|
||||||
|
|
||||||
|
let scannedNotes = 0;
|
||||||
|
|
||||||
|
for (const folderId of folderIds) {
|
||||||
|
const notes = await adapter.getNotesInFolder(folderId, true);
|
||||||
|
for (const note of notes) {
|
||||||
|
scannedNotes += 1;
|
||||||
|
if (note.id === ganttNoteId) continue;
|
||||||
|
|
||||||
|
const result = extractGanttBlock(note.body);
|
||||||
|
if (!result.found || !result.block) continue;
|
||||||
|
|
||||||
|
const block = result.block;
|
||||||
|
// Only blocks with a project are items; the rest are chart blocks.
|
||||||
|
if (block.project === null || block.project.trim() === "") continue;
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
warnings.push(
|
||||||
|
`"${note.title}": gtd-gantt block problem — ${result.error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectName = block.project.trim();
|
||||||
|
if (filter && projectName.toLowerCase() !== filter) continue;
|
||||||
|
|
||||||
|
const lane = getLane(projects, projectName);
|
||||||
|
|
||||||
|
if (note.is_todo) {
|
||||||
|
const milestone = await buildMilestone(
|
||||||
|
adapter,
|
||||||
|
note,
|
||||||
|
block,
|
||||||
|
warnings
|
||||||
|
);
|
||||||
|
if (milestone) lane.milestones.push(milestone);
|
||||||
|
} else {
|
||||||
|
const bar = buildBar(note, block, warnings);
|
||||||
|
if (bar) lane.bars.push(bar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectList = finaliseProjects(projects, config);
|
||||||
|
const { rangeStart, rangeEnd } = computeRange(projectList);
|
||||||
|
|
||||||
|
const itemCount = projectList.reduce(
|
||||||
|
(sum, p) => sum + p.bars.length + p.milestones.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects: projectList,
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
warnings,
|
||||||
|
scannedFolders: folderIds.length,
|
||||||
|
scannedNotes,
|
||||||
|
itemCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLane(
|
||||||
|
projects: Map<
|
||||||
|
string,
|
||||||
|
{ name: string; bars: GanttBar[]; milestones: GanttMilestone[] }
|
||||||
|
>,
|
||||||
|
name: string
|
||||||
|
) {
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
let lane = projects.get(key);
|
||||||
|
if (!lane) {
|
||||||
|
lane = { name, bars: [], milestones: [] };
|
||||||
|
projects.set(key, lane);
|
||||||
|
}
|
||||||
|
return lane;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBar(
|
||||||
|
note: RawNote,
|
||||||
|
block: NonNullable<ReturnType<typeof extractGanttBlock>["block"]>,
|
||||||
|
warnings: string[]
|
||||||
|
): GanttBar | null {
|
||||||
|
const begin = parseGtdDate(block.beginDate);
|
||||||
|
const end = parseGtdDate(block.endDate);
|
||||||
|
|
||||||
|
if (!begin || !end) {
|
||||||
|
warnings.push(
|
||||||
|
`"${note.title}": gantt bar needs a valid begin-date and end-date (excluded)`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (end < begin) {
|
||||||
|
warnings.push(
|
||||||
|
`"${note.title}": gantt end-date is before begin-date (excluded)`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: note.id,
|
||||||
|
title: block.title ? block.title : note.title,
|
||||||
|
beginDate: begin,
|
||||||
|
endDate: end,
|
||||||
|
bgColour: block.bgColour,
|
||||||
|
fgColour: block.fgColour,
|
||||||
|
text: block.text,
|
||||||
|
updatedTime: note.updated_time,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildMilestone(
|
||||||
|
adapter: DataAdapter,
|
||||||
|
note: RawNote,
|
||||||
|
block: NonNullable<ReturnType<typeof extractGanttBlock>["block"]>,
|
||||||
|
warnings: string[]
|
||||||
|
): Promise<GanttMilestone | null> {
|
||||||
|
if (block.beginDate || block.endDate) {
|
||||||
|
warnings.push(
|
||||||
|
`"${note.title}": begin-date/end-date on a to-do are ignored — rendered as a milestone on its due date`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!note.todo_due || note.todo_due <= 0) {
|
||||||
|
warnings.push(
|
||||||
|
`"${note.title}": gantt milestone to-do has no due date (excluded)`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = await adapter.getNoteTagTitles(note.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: note.id,
|
||||||
|
title: block.title ? block.title : note.title,
|
||||||
|
date: timestampToLocalDate(note.todo_due),
|
||||||
|
completed: note.todo_completed > 0,
|
||||||
|
isRecurring: tags.includes(RECURRING_TAG),
|
||||||
|
bgColour: block.bgColour,
|
||||||
|
fgColour: block.fgColour,
|
||||||
|
text: block.text,
|
||||||
|
updatedTime: note.updated_time,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function finaliseProjects(
|
||||||
|
projects: Map<
|
||||||
|
string,
|
||||||
|
{ name: string; bars: GanttBar[]; milestones: GanttMilestone[] }
|
||||||
|
>,
|
||||||
|
config: GanttConfig
|
||||||
|
): GanttProject[] {
|
||||||
|
const list: GanttProject[] = Array.from(projects.values()).map((lane) => ({
|
||||||
|
name: lane.name,
|
||||||
|
bars: lane.bars,
|
||||||
|
milestones: lane.milestones,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Projects always alphabetical for a stable row order.
|
||||||
|
list.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const project of list) {
|
||||||
|
sortBars(project.bars, config);
|
||||||
|
sortMilestones(project.milestones, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortBars(bars: GanttBar[], config: GanttConfig): void {
|
||||||
|
const dir = config.sort === "desc" ? -1 : 1;
|
||||||
|
bars.sort((a, b) => compareItems(a, b, a.beginDate, b.beginDate, config) * dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortMilestones(
|
||||||
|
milestones: GanttMilestone[],
|
||||||
|
config: GanttConfig
|
||||||
|
): void {
|
||||||
|
const dir = config.sort === "desc" ? -1 : 1;
|
||||||
|
milestones.sort(
|
||||||
|
(a, b) => compareItems(a, b, a.date, b.date, config) * dir
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareItems(
|
||||||
|
a: { title: string; updatedTime: number },
|
||||||
|
b: { title: string; updatedTime: number },
|
||||||
|
aDate: string,
|
||||||
|
bDate: string,
|
||||||
|
config: GanttConfig
|
||||||
|
): number {
|
||||||
|
if (config.sortType === "title") {
|
||||||
|
return a.title.localeCompare(b.title, undefined, { sensitivity: "base" });
|
||||||
|
}
|
||||||
|
if (config.sortType === "modified-date") {
|
||||||
|
return a.updatedTime - b.updatedTime;
|
||||||
|
}
|
||||||
|
// begin-date (bars by begin, milestones by their point date).
|
||||||
|
return aDate.localeCompare(bDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeRange(projects: GanttProject[]): {
|
||||||
|
rangeStart: string | null;
|
||||||
|
rangeEnd: string | null;
|
||||||
|
} {
|
||||||
|
let rangeStart: string | null = null;
|
||||||
|
let rangeEnd: string | null = null;
|
||||||
|
|
||||||
|
const consider = (date: string) => {
|
||||||
|
if (rangeStart === null || date < rangeStart) rangeStart = date;
|
||||||
|
if (rangeEnd === null || date > rangeEnd) rangeEnd = date;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
for (const bar of project.bars) {
|
||||||
|
consider(bar.beginDate);
|
||||||
|
consider(bar.endDate);
|
||||||
|
}
|
||||||
|
for (const milestone of project.milestones) {
|
||||||
|
consider(milestone.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rangeStart, rangeEnd };
|
||||||
|
}
|
||||||
@ -42,7 +42,8 @@ export default async function collectKanban(
|
|||||||
const folderIds = resolveScopedFolderIds(
|
const folderIds = resolveScopedFolderIds(
|
||||||
folders,
|
folders,
|
||||||
kanbanFolderId,
|
kanbanFolderId,
|
||||||
config.scopeDepth
|
config.scopeDepth,
|
||||||
|
config.scopeAll
|
||||||
);
|
);
|
||||||
|
|
||||||
let scannedNotes = 0;
|
let scannedNotes = 0;
|
||||||
@ -55,8 +56,12 @@ export default async function collectKanban(
|
|||||||
? -Infinity
|
? -Infinity
|
||||||
: now.getTime() - config.doneWindow * 24 * 60 * 60 * 1000;
|
: now.getTime() - config.doneWindow * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Bodies are only scanned (gtd block → card styling/title/date) when to-dos
|
||||||
|
// are actually included; "none" yields an empty board, so skip fetching them.
|
||||||
|
const needsBody = config.todos !== "none";
|
||||||
|
|
||||||
for (const folderId of folderIds) {
|
for (const folderId of folderIds) {
|
||||||
const notes = await adapter.getNotesInFolder(folderId);
|
const notes = await adapter.getNotesInFolder(folderId, needsBody);
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
scannedNotes += 1;
|
scannedNotes += 1;
|
||||||
if (note.id === kanbanNoteId) continue;
|
if (note.id === kanbanNoteId) continue;
|
||||||
|
|||||||
@ -45,7 +45,8 @@ export default async function collectMatrix(
|
|||||||
const folderIds = resolveScopedFolderIds(
|
const folderIds = resolveScopedFolderIds(
|
||||||
folders,
|
folders,
|
||||||
matrixFolderId,
|
matrixFolderId,
|
||||||
config.scopeDepth
|
config.scopeDepth,
|
||||||
|
config.scopeAll
|
||||||
);
|
);
|
||||||
|
|
||||||
let scannedNotes = 0;
|
let scannedNotes = 0;
|
||||||
@ -57,8 +58,12 @@ export default async function collectMatrix(
|
|||||||
// Skeleton mode: a date on or before this ISO threshold is "due soon".
|
// Skeleton mode: a date on or before this ISO threshold is "due soon".
|
||||||
const soonThreshold = isoDaysFromNow(now, config.urgentWindow);
|
const soonThreshold = isoDaysFromNow(now, config.urgentWindow);
|
||||||
|
|
||||||
|
// Bodies are only scanned (gtd block → card styling/title/date) when to-dos
|
||||||
|
// are actually included; "none" yields an empty matrix, so skip fetching them.
|
||||||
|
const needsBody = config.todos !== "none";
|
||||||
|
|
||||||
for (const folderId of folderIds) {
|
for (const folderId of folderIds) {
|
||||||
const notes = await adapter.getNotesInFolder(folderId);
|
const notes = await adapter.getNotesInFolder(folderId, needsBody);
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
scannedNotes += 1;
|
scannedNotes += 1;
|
||||||
if (note.id === matrixNoteId) continue;
|
if (note.id === matrixNoteId) continue;
|
||||||
|
|||||||
@ -3,12 +3,19 @@ import { RawFolder } from "./types";
|
|||||||
/**
|
/**
|
||||||
* Resolve which folder ids are in scope: the root folder plus descendants
|
* Resolve which folder ids are in scope: the root folder plus descendants
|
||||||
* down to `depth` levels (0 = root only, Infinity = all descendants).
|
* down to `depth` levels (0 = root only, Infinity = all descendants).
|
||||||
|
*
|
||||||
|
* When `scopeAll` is true (`scope: all`), the root and depth are ignored and
|
||||||
|
* every folder in the profile is returned. This is deliberately expensive — the
|
||||||
|
* whole tree is scanned — and gated behind an explicit opt-in.
|
||||||
*/
|
*/
|
||||||
export default function resolveScopedFolderIds(
|
export default function resolveScopedFolderIds(
|
||||||
folders: RawFolder[],
|
folders: RawFolder[],
|
||||||
rootFolderId: string,
|
rootFolderId: string,
|
||||||
depth: number
|
depth: number,
|
||||||
|
scopeAll = false
|
||||||
): string[] {
|
): string[] {
|
||||||
|
if (scopeAll) return folders.map((folder) => folder.id);
|
||||||
|
|
||||||
const childrenByParent = new Map<string, string[]>();
|
const childrenByParent = new Map<string, string[]>();
|
||||||
for (const folder of folders) {
|
for (const folder of folders) {
|
||||||
const siblings = childrenByParent.get(folder.parent_id) || [];
|
const siblings = childrenByParent.get(folder.parent_id) || [];
|
||||||
|
|||||||
83
src/Gtd/ganttBlock.ts
Normal file
83
src/Gtd/ganttBlock.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
const YAML = require("yaml");
|
||||||
|
|
||||||
|
import { GanttBlockResult, GanttItemBlock } from "./types";
|
||||||
|
|
||||||
|
// Matches a fenced code block whose info string is exactly "gtd-gantt".
|
||||||
|
// Mirrors gtdBlock.ts: tolerates leading indentation, ``` or ~~~ fences, and
|
||||||
|
// trailing spaces after the info string. Captures the block body.
|
||||||
|
const GANTT_FENCE_REGEX =
|
||||||
|
/^[ \t]*(```|~~~)gtd-gantt[ \t]*\r?\n((?:[\s\S]*?\r?\n)?)[ \t]*\1[ \t]*$/m;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first ```gtd-gantt block in a note body and parse its YAML.
|
||||||
|
*
|
||||||
|
* The same fence name serves two roles (SPEC.md §2.5): a block WITH a
|
||||||
|
* `project:` key is an ITEM declaration (a bar or milestone); one WITHOUT is
|
||||||
|
* the CHART config block. This extractor always parses; callers classify via
|
||||||
|
* `block.project` (non-null ⇒ item).
|
||||||
|
*
|
||||||
|
* - No block → { found: false }
|
||||||
|
* - Empty block → { found: true } with all-null properties (a chart block
|
||||||
|
* with all defaults — never an item, since project is null)
|
||||||
|
* - Malformed YAML → { found: true, error }
|
||||||
|
*/
|
||||||
|
export default function extractGanttBlock(
|
||||||
|
body: string | undefined
|
||||||
|
): GanttBlockResult {
|
||||||
|
const match = GANTT_FENCE_REGEX.exec(body || "");
|
||||||
|
if (!match) return { found: false, block: null, error: null };
|
||||||
|
|
||||||
|
const content = match[2];
|
||||||
|
|
||||||
|
let parsed: any = {};
|
||||||
|
try {
|
||||||
|
parsed = YAML.parse(content);
|
||||||
|
} catch (error) {
|
||||||
|
return { found: true, block: emptyBlock(), error: String(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed === null || parsed === undefined) {
|
||||||
|
return { found: true, block: emptyBlock(), error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
block: emptyBlock(),
|
||||||
|
error: "gtd-gantt block must contain key: value pairs",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const block: GanttItemBlock = {
|
||||||
|
project: stringOrNull(parsed.project),
|
||||||
|
beginDate: stringOrNull(parsed["begin-date"]),
|
||||||
|
endDate: stringOrNull(parsed["end-date"]),
|
||||||
|
bgColour: stringOrNull(parsed["bg-colour"] ?? parsed.bgColor),
|
||||||
|
fgColour: stringOrNull(parsed["fg-colour"]),
|
||||||
|
title: stringOrNull(parsed.title),
|
||||||
|
text: stringOrNull(parsed.text),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { found: true, block, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyBlock(): GanttItemBlock {
|
||||||
|
return {
|
||||||
|
project: null,
|
||||||
|
beginDate: null,
|
||||||
|
endDate: null,
|
||||||
|
bgColour: null,
|
||||||
|
fgColour: null,
|
||||||
|
title: null,
|
||||||
|
text: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOrNull(value: any): string | null {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
if (value instanceof Date) {
|
||||||
|
// The yaml library parses unquoted yyyy-mm-dd as a Date.
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
@ -17,7 +17,9 @@ const GTD_FENCE_REGEX =
|
|||||||
* - Malformed YAML → { found: true, error } — the item still opts in,
|
* - Malformed YAML → { found: true, error } — the item still opts in,
|
||||||
* but the problem is surfaced.
|
* but the problem is surfaced.
|
||||||
*/
|
*/
|
||||||
export default function extractGtdBlock(body: string): GtdBlockResult {
|
export default function extractGtdBlock(
|
||||||
|
body: string | undefined
|
||||||
|
): GtdBlockResult {
|
||||||
const match = GTD_FENCE_REGEX.exec(body || "");
|
const match = GTD_FENCE_REGEX.exec(body || "");
|
||||||
if (!match) return { found: false, block: null, error: null };
|
if (!match) return { found: false, block: null, error: null };
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { CalendarConfig, InclusionMode } from "./types";
|
import { CalendarConfig, InclusionMode } from "./types";
|
||||||
|
import { parseNotebookOption } from "./resolveNotebook";
|
||||||
|
|
||||||
const VIEW_ALIASES: Record<string, CalendarConfig["view"]> = {
|
const VIEW_ALIASES: Record<string, CalendarConfig["view"]> = {
|
||||||
day: "day",
|
day: "day",
|
||||||
@ -26,6 +27,7 @@ export default function parseCalendarConfig(raw: any): CalendarConfig {
|
|||||||
"view",
|
"view",
|
||||||
"title",
|
"title",
|
||||||
"scope",
|
"scope",
|
||||||
|
"notebook",
|
||||||
"notes",
|
"notes",
|
||||||
"todos",
|
"todos",
|
||||||
"sort",
|
"sort",
|
||||||
@ -56,9 +58,12 @@ export default function parseCalendarConfig(raw: any): CalendarConfig {
|
|||||||
|
|
||||||
// scope
|
// scope
|
||||||
let scopeDepth = 0;
|
let scopeDepth = 0;
|
||||||
|
let scopeAll = false;
|
||||||
if (input.scope !== undefined) {
|
if (input.scope !== undefined) {
|
||||||
const value = input.scope;
|
const value = input.scope;
|
||||||
if (value === "this-folder") {
|
if (value === "all") {
|
||||||
|
scopeAll = true;
|
||||||
|
} else if (value === "this-folder") {
|
||||||
scopeDepth = 0;
|
scopeDepth = 0;
|
||||||
} else if (value === "children") {
|
} else if (value === "children") {
|
||||||
scopeDepth = Infinity;
|
scopeDepth = Infinity;
|
||||||
@ -69,6 +74,15 @@ export default function parseCalendarConfig(raw: any): CalendarConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notebook (id / title / Parent/Child path); resolved later against the
|
||||||
|
// folder tree. Empty → null (host folder). Ignored under scope: all.
|
||||||
|
const notebook = parseNotebookOption(input.notebook);
|
||||||
|
if (scopeAll && notebook) {
|
||||||
|
warnings.push(
|
||||||
|
`"notebook" is ignored when scope: all (scanning every notebook)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// notes / todos
|
// notes / todos
|
||||||
const notes = parseInclusion(input.notes, "notes", warnings);
|
const notes = parseInclusion(input.notes, "notes", warnings);
|
||||||
const todos = parseInclusion(input.todos, "todos", warnings);
|
const todos = parseInclusion(input.todos, "todos", warnings);
|
||||||
@ -113,6 +127,8 @@ export default function parseCalendarConfig(raw: any): CalendarConfig {
|
|||||||
view,
|
view,
|
||||||
title,
|
title,
|
||||||
scopeDepth,
|
scopeDepth,
|
||||||
|
scopeAll,
|
||||||
|
notebook,
|
||||||
notes,
|
notes,
|
||||||
todos,
|
todos,
|
||||||
sort,
|
sort,
|
||||||
|
|||||||
116
src/Gtd/parseGanttConfig.ts
Normal file
116
src/Gtd/parseGanttConfig.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { GanttConfig, GanttSortType } from "./types";
|
||||||
|
import { parseNotebookOption } from "./resolveNotebook";
|
||||||
|
|
||||||
|
const SORT_TYPES: GanttSortType[] = ["begin-date", "title", "modified-date"];
|
||||||
|
const CARD_DETAILS = ["hover", "always", "none"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a parsed CHART gtd-gantt YAML object into a GanttConfig with
|
||||||
|
* SPEC defaults. Invalid values produce warnings, not failures. Only the
|
||||||
|
* chart block (no `project:` key) reaches this — item blocks are classified
|
||||||
|
* out earlier by the renderer / collector.
|
||||||
|
*/
|
||||||
|
export default function parseGanttConfig(raw: any): GanttConfig {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const input = raw && typeof raw === "object" ? raw : {};
|
||||||
|
|
||||||
|
const knownKeys = [
|
||||||
|
"title",
|
||||||
|
"scope",
|
||||||
|
"notebook",
|
||||||
|
"filter-project",
|
||||||
|
"sort",
|
||||||
|
"sort-type",
|
||||||
|
"card-detail",
|
||||||
|
];
|
||||||
|
for (const key of Object.keys(input)) {
|
||||||
|
if (!knownKeys.includes(key)) warnings.push(`Unknown option "${key}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title =
|
||||||
|
input.title !== undefined && input.title !== null
|
||||||
|
? String(input.title)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// scope (shared semantics with the other views)
|
||||||
|
let scopeDepth = 0;
|
||||||
|
let scopeAll = false;
|
||||||
|
if (input.scope !== undefined) {
|
||||||
|
const value = input.scope;
|
||||||
|
if (value === "all") {
|
||||||
|
scopeAll = true;
|
||||||
|
} else if (value === "this-folder") {
|
||||||
|
scopeDepth = 0;
|
||||||
|
} else if (value === "children") {
|
||||||
|
scopeDepth = Infinity;
|
||||||
|
} else if (Number.isInteger(Number(value)) && Number(value) >= 0) {
|
||||||
|
scopeDepth = Number(value);
|
||||||
|
} else {
|
||||||
|
warnings.push(`Invalid scope "${value}" (using "this-folder")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notebook = parseNotebookOption(input.notebook);
|
||||||
|
if (scopeAll && notebook) {
|
||||||
|
warnings.push(
|
||||||
|
`"notebook" is ignored when scope: all (scanning every notebook)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter-project: limit the chart to a single project (null = all).
|
||||||
|
const filterProject =
|
||||||
|
input["filter-project"] !== undefined &&
|
||||||
|
input["filter-project"] !== null &&
|
||||||
|
String(input["filter-project"]).trim() !== ""
|
||||||
|
? String(input["filter-project"]).trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// sort-type
|
||||||
|
let sortType: GanttSortType = "begin-date";
|
||||||
|
if (input["sort-type"] !== undefined) {
|
||||||
|
const candidate = String(input["sort-type"]).toLowerCase();
|
||||||
|
if (SORT_TYPES.includes(candidate as GanttSortType)) {
|
||||||
|
sortType = candidate as GanttSortType;
|
||||||
|
} else {
|
||||||
|
warnings.push(
|
||||||
|
`Invalid sort-type "${input["sort-type"]}" (using "begin-date")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort
|
||||||
|
let sort: GanttConfig["sort"] = "asc";
|
||||||
|
if (input.sort !== undefined) {
|
||||||
|
const candidate = String(input.sort).toLowerCase();
|
||||||
|
if (candidate === "asc" || candidate === "desc") {
|
||||||
|
sort = candidate;
|
||||||
|
} else {
|
||||||
|
warnings.push(`Invalid sort "${input.sort}" (using "asc")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// card-detail
|
||||||
|
let cardDetail: GanttConfig["cardDetail"] = "hover";
|
||||||
|
if (input["card-detail"] !== undefined) {
|
||||||
|
const candidate = String(input["card-detail"]).toLowerCase();
|
||||||
|
if (CARD_DETAILS.includes(candidate)) {
|
||||||
|
cardDetail = candidate as GanttConfig["cardDetail"];
|
||||||
|
} else {
|
||||||
|
warnings.push(
|
||||||
|
`Invalid card-detail "${input["card-detail"]}" (using "hover")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
scopeDepth,
|
||||||
|
scopeAll,
|
||||||
|
notebook,
|
||||||
|
filterProject,
|
||||||
|
sortType,
|
||||||
|
sort,
|
||||||
|
cardDetail,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { KanbanConfig, InclusionMode, KanbanSortType } from "./types";
|
import { KanbanConfig, InclusionMode, KanbanSortType } from "./types";
|
||||||
|
import { parseNotebookOption } from "./resolveNotebook";
|
||||||
|
|
||||||
const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"];
|
const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"];
|
||||||
const SORT_TYPES: KanbanSortType[] = ["due-date", "title", "modified-date"];
|
const SORT_TYPES: KanbanSortType[] = ["due-date", "title", "modified-date"];
|
||||||
@ -15,6 +16,7 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
|
|||||||
const knownKeys = [
|
const knownKeys = [
|
||||||
"title",
|
"title",
|
||||||
"scope",
|
"scope",
|
||||||
|
"notebook",
|
||||||
"todos",
|
"todos",
|
||||||
"sort-type",
|
"sort-type",
|
||||||
"sort",
|
"sort",
|
||||||
@ -33,9 +35,12 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
|
|||||||
|
|
||||||
// scope (shared semantics with gtd-calendar)
|
// scope (shared semantics with gtd-calendar)
|
||||||
let scopeDepth = 0;
|
let scopeDepth = 0;
|
||||||
|
let scopeAll = false;
|
||||||
if (input.scope !== undefined) {
|
if (input.scope !== undefined) {
|
||||||
const value = input.scope;
|
const value = input.scope;
|
||||||
if (value === "this-folder") {
|
if (value === "all") {
|
||||||
|
scopeAll = true;
|
||||||
|
} else if (value === "this-folder") {
|
||||||
scopeDepth = 0;
|
scopeDepth = 0;
|
||||||
} else if (value === "children") {
|
} else if (value === "children") {
|
||||||
scopeDepth = Infinity;
|
scopeDepth = Infinity;
|
||||||
@ -46,6 +51,13 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notebook = parseNotebookOption(input.notebook);
|
||||||
|
if (scopeAll && notebook) {
|
||||||
|
warnings.push(
|
||||||
|
`"notebook" is ignored when scope: all (scanning every notebook)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// todos inclusion
|
// todos inclusion
|
||||||
let todos: InclusionMode = "gtd-only";
|
let todos: InclusionMode = "gtd-only";
|
||||||
if (input.todos !== undefined) {
|
if (input.todos !== undefined) {
|
||||||
@ -121,6 +133,8 @@ export default function parseKanbanConfig(raw: any): KanbanConfig {
|
|||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
scopeDepth,
|
scopeDepth,
|
||||||
|
scopeAll,
|
||||||
|
notebook,
|
||||||
todos,
|
todos,
|
||||||
sortType,
|
sortType,
|
||||||
sort,
|
sort,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
IMPORTANT_TAG,
|
IMPORTANT_TAG,
|
||||||
IN_PROGRESS_TAG,
|
IN_PROGRESS_TAG,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { parseNotebookOption } from "./resolveNotebook";
|
||||||
|
|
||||||
const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"];
|
const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"];
|
||||||
const SORT_TYPES: KanbanSortType[] = ["due-date", "title", "modified-date"];
|
const SORT_TYPES: KanbanSortType[] = ["due-date", "title", "modified-date"];
|
||||||
@ -24,6 +25,7 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
|
|||||||
"mode",
|
"mode",
|
||||||
"title",
|
"title",
|
||||||
"scope",
|
"scope",
|
||||||
|
"notebook",
|
||||||
"todos",
|
"todos",
|
||||||
"sort-type",
|
"sort-type",
|
||||||
"sort",
|
"sort",
|
||||||
@ -55,9 +57,12 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
|
|||||||
|
|
||||||
// scope (shared semantics with gtd-calendar / gtd-kanban)
|
// scope (shared semantics with gtd-calendar / gtd-kanban)
|
||||||
let scopeDepth = 0;
|
let scopeDepth = 0;
|
||||||
|
let scopeAll = false;
|
||||||
if (input.scope !== undefined) {
|
if (input.scope !== undefined) {
|
||||||
const value = input.scope;
|
const value = input.scope;
|
||||||
if (value === "this-folder") {
|
if (value === "all") {
|
||||||
|
scopeAll = true;
|
||||||
|
} else if (value === "this-folder") {
|
||||||
scopeDepth = 0;
|
scopeDepth = 0;
|
||||||
} else if (value === "children") {
|
} else if (value === "children") {
|
||||||
scopeDepth = Infinity;
|
scopeDepth = Infinity;
|
||||||
@ -68,6 +73,13 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notebook = parseNotebookOption(input.notebook);
|
||||||
|
if (scopeAll && notebook) {
|
||||||
|
warnings.push(
|
||||||
|
`"notebook" is ignored when scope: all (scanning every notebook)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// todos inclusion
|
// todos inclusion
|
||||||
let todos: InclusionMode = "gtd-only";
|
let todos: InclusionMode = "gtd-only";
|
||||||
if (input.todos !== undefined) {
|
if (input.todos !== undefined) {
|
||||||
@ -143,6 +155,8 @@ export default function parseMatrixConfig(raw: any): MatrixConfig {
|
|||||||
mode,
|
mode,
|
||||||
title,
|
title,
|
||||||
scopeDepth,
|
scopeDepth,
|
||||||
|
scopeAll,
|
||||||
|
notebook,
|
||||||
todos,
|
todos,
|
||||||
sortType,
|
sortType,
|
||||||
sort,
|
sort,
|
||||||
|
|||||||
123
src/Gtd/resolveNotebook.ts
Normal file
123
src/Gtd/resolveNotebook.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { RawFolder } from "./types";
|
||||||
|
|
||||||
|
export interface ResolvedNotebook {
|
||||||
|
/** Folder id to root the scan at. Falls back to the host folder on any miss. */
|
||||||
|
folderId: string;
|
||||||
|
/** Human-readable problem, surfaced as a ⚠ warning; null on a clean resolve. */
|
||||||
|
warning: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEX_ID = /^[0-9a-f]{32}$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a raw `notebook:` YAML value into a trimmed string, or null when
|
||||||
|
* absent/empty. Shared by all three config parsers.
|
||||||
|
*/
|
||||||
|
export function parseNotebookOption(value: any): string | null {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
const trimmed = String(value).trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a `notebook:` option to a folder id, per SPEC.md §2.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. A raw 32-char hex folder id (must exist).
|
||||||
|
* 2. A `Parent/Child/...` title path (case-insensitive, walked from a root).
|
||||||
|
* 3. A bare title — used when it matches exactly one notebook.
|
||||||
|
*
|
||||||
|
* Any miss (unknown id, unresolvable path, ambiguous or absent title) warns and
|
||||||
|
* falls back to `hostFolderId`, consistent with the plugin's warn-don't-fail
|
||||||
|
* convention.
|
||||||
|
*/
|
||||||
|
export default function resolveNotebook(
|
||||||
|
folders: RawFolder[],
|
||||||
|
spec: string,
|
||||||
|
hostFolderId: string
|
||||||
|
): ResolvedNotebook {
|
||||||
|
const trimmed = (spec || "").trim();
|
||||||
|
if (!trimmed) return { folderId: hostFolderId, warning: null };
|
||||||
|
|
||||||
|
// 1. Raw folder id.
|
||||||
|
if (HEX_ID.test(trimmed)) {
|
||||||
|
const match = folders.find((f) => f.id.toLowerCase() === trimmed.toLowerCase());
|
||||||
|
if (match) return { folderId: match.id, warning: null };
|
||||||
|
return {
|
||||||
|
folderId: hostFolderId,
|
||||||
|
warning: `notebook: no notebook with id "${trimmed}" (using this note's folder)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Path (Parent/Child/...). A single segment with no slash falls through
|
||||||
|
// to the bare-title branch below.
|
||||||
|
const segments = trimmed
|
||||||
|
.split("/")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
|
||||||
|
if (segments.length > 1) {
|
||||||
|
const resolved = resolvePath(folders, segments);
|
||||||
|
if (resolved.length === 1) return { folderId: resolved[0], warning: null };
|
||||||
|
if (resolved.length === 0) {
|
||||||
|
return {
|
||||||
|
folderId: hostFolderId,
|
||||||
|
warning: `notebook: path "${trimmed}" not found (using this note's folder)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
folderId: hostFolderId,
|
||||||
|
warning: `notebook: path "${trimmed}" is ambiguous (${resolved.length} matches — using this note's folder)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Bare title — anywhere in the tree, if unique.
|
||||||
|
const wanted = trimmed.toLowerCase();
|
||||||
|
const matches = folders.filter((f) => (f.title || "").toLowerCase() === wanted);
|
||||||
|
if (matches.length === 1) return { folderId: matches[0].id, warning: null };
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return {
|
||||||
|
folderId: hostFolderId,
|
||||||
|
warning: `notebook: no notebook named "${trimmed}" (using this note's folder)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
folderId: hostFolderId,
|
||||||
|
warning: `notebook: "${trimmed}" is ambiguous (${matches.length} notebooks share that name — qualify it as a Parent/Child path; using this note's folder)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a title path from the tree roots, returning every folder id the full
|
||||||
|
* path resolves to (0 = miss, >1 = ambiguous). A "root" is any folder whose
|
||||||
|
* parent is not itself a folder in the set (top-level notebooks).
|
||||||
|
*/
|
||||||
|
function resolvePath(folders: RawFolder[], segments: string[]): string[] {
|
||||||
|
const ids = new Set(folders.map((f) => f.id));
|
||||||
|
const childrenByParent = new Map<string, RawFolder[]>();
|
||||||
|
for (const folder of folders) {
|
||||||
|
const key = ids.has(folder.parent_id) ? folder.parent_id : "";
|
||||||
|
const siblings = childrenByParent.get(key) || [];
|
||||||
|
siblings.push(folder);
|
||||||
|
childrenByParent.set(key, siblings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start at the roots (parent key "").
|
||||||
|
let frontier: RawFolder[] = childrenByParent.get("") || [];
|
||||||
|
frontier = frontier.filter(
|
||||||
|
(f) => (f.title || "").toLowerCase() === segments[0].toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 1; i < segments.length; i += 1) {
|
||||||
|
const wanted = segments[i].toLowerCase();
|
||||||
|
const next: RawFolder[] = [];
|
||||||
|
for (const folder of frontier) {
|
||||||
|
for (const child of childrenByParent.get(folder.id) || []) {
|
||||||
|
if ((child.title || "").toLowerCase() === wanted) next.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return frontier.map((f) => f.id);
|
||||||
|
}
|
||||||
131
src/Gtd/types.ts
131
src/Gtd/types.ts
@ -4,6 +4,17 @@ export interface CalendarConfig {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
/** Folder recursion depth: 0 = this folder only, Infinity = all children. */
|
/** Folder recursion depth: 0 = this folder only, Infinity = all children. */
|
||||||
scopeDepth: number;
|
scopeDepth: number;
|
||||||
|
/**
|
||||||
|
* `scope: all` — scan every notebook in the profile, ignoring the root
|
||||||
|
* folder entirely. When true, `scopeDepth` and `notebook` are irrelevant.
|
||||||
|
*/
|
||||||
|
scopeAll: boolean;
|
||||||
|
/**
|
||||||
|
* Notebook to root the folder scan at (id, title, or Parent/Child path).
|
||||||
|
* null = the host note's own folder. `scopeDepth` applies relative to it.
|
||||||
|
* Ignored when `scopeAll` is true.
|
||||||
|
*/
|
||||||
|
notebook: string | null;
|
||||||
notes: InclusionMode;
|
notes: InclusionMode;
|
||||||
todos: InclusionMode;
|
todos: InclusionMode;
|
||||||
sort: "asc" | "desc";
|
sort: "asc" | "desc";
|
||||||
@ -26,7 +37,13 @@ export interface RawNote {
|
|||||||
todo_due: number;
|
todo_due: number;
|
||||||
todo_completed: number;
|
todo_completed: number;
|
||||||
updated_time: number;
|
updated_time: number;
|
||||||
body: string;
|
/**
|
||||||
|
* Note body. Only fetched when the caller asks for it via
|
||||||
|
* `getNotesInFolder(folderId, true)`; undefined otherwise. Collectors only
|
||||||
|
* omit the body when their inclusion mode means the gtd block is never
|
||||||
|
* scanned, so callers must never read this in a body-less path.
|
||||||
|
*/
|
||||||
|
body?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Properties parsed from a ```gtd block. */
|
/** Properties parsed from a ```gtd block. */
|
||||||
@ -65,12 +82,26 @@ export interface GtdEvent {
|
|||||||
export interface RawFolder {
|
export interface RawFolder {
|
||||||
id: string;
|
id: string;
|
||||||
parent_id: string;
|
parent_id: string;
|
||||||
|
/**
|
||||||
|
* Notebook title. Needed only to resolve the `notebook:` option by
|
||||||
|
* name/path (folder scoping uses id/parent_id alone), so it is optional for
|
||||||
|
* the benefit of scope-only test fixtures; the real adapter always sets it.
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Thin abstraction over the Joplin data API so collectEvents is testable. */
|
/** Thin abstraction over the Joplin data API so collectEvents is testable. */
|
||||||
export interface DataAdapter {
|
export interface DataAdapter {
|
||||||
getFolders(): Promise<RawFolder[]>;
|
getFolders(): Promise<RawFolder[]>;
|
||||||
getNotesInFolder(folderId: string): Promise<RawNote[]>;
|
/**
|
||||||
|
* Notes in a folder. `includeBody` is a fetch hint: when false, the adapter
|
||||||
|
* may omit the (potentially large) `body` field. Collectors pass false only
|
||||||
|
* when their inclusion mode guarantees the gtd block is never scanned.
|
||||||
|
*/
|
||||||
|
getNotesInFolder(
|
||||||
|
folderId: string,
|
||||||
|
includeBody: boolean
|
||||||
|
): Promise<RawNote[]>;
|
||||||
/** Lowercased tag titles for a note. Used to detect recurrence. */
|
/** Lowercased tag titles for a note. Used to detect recurrence. */
|
||||||
getNoteTagTitles(noteId: string): Promise<string[]>;
|
getNoteTagTitles(noteId: string): Promise<string[]>;
|
||||||
}
|
}
|
||||||
@ -96,6 +127,10 @@ export type KanbanSortType = "due-date" | "title" | "modified-date";
|
|||||||
export interface KanbanConfig {
|
export interface KanbanConfig {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
scopeDepth: number;
|
scopeDepth: number;
|
||||||
|
/** `scope: all` — scan every notebook; ignores scopeDepth and notebook. */
|
||||||
|
scopeAll: boolean;
|
||||||
|
/** Notebook to root the scan at (id/title/path); null = host folder. */
|
||||||
|
notebook: string | null;
|
||||||
todos: InclusionMode;
|
todos: InclusionMode;
|
||||||
sortType: KanbanSortType;
|
sortType: KanbanSortType;
|
||||||
sort: "asc" | "desc";
|
sort: "asc" | "desc";
|
||||||
@ -141,6 +176,10 @@ export interface MatrixConfig {
|
|||||||
mode: MatrixMode;
|
mode: MatrixMode;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
scopeDepth: number;
|
scopeDepth: number;
|
||||||
|
/** `scope: all` — scan every notebook; ignores scopeDepth and notebook. */
|
||||||
|
scopeAll: boolean;
|
||||||
|
/** Notebook to root the scan at (id/title/path); null = host folder. */
|
||||||
|
notebook: string | null;
|
||||||
todos: InclusionMode;
|
todos: InclusionMode;
|
||||||
sortType: KanbanSortType;
|
sortType: KanbanSortType;
|
||||||
sort: "asc" | "desc";
|
sort: "asc" | "desc";
|
||||||
@ -180,3 +219,91 @@ export interface MatrixLabels {
|
|||||||
bottomRight: string;
|
bottomRight: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gantt (SLICE4 data layer)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type GanttSortType = "begin-date" | "title" | "modified-date";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed properties of an item `gtd-gantt` block (one carrying a `project:`
|
||||||
|
* key). Notes become bars (begin/end), to-dos become milestones (the to-do's
|
||||||
|
* native due date; begin/end on a to-do are ignored with a warning).
|
||||||
|
*/
|
||||||
|
export interface GanttItemBlock {
|
||||||
|
/** Non-null marks this as an ITEM block; null/absent means a CHART block. */
|
||||||
|
project: string | null;
|
||||||
|
beginDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
bgColour: string | null;
|
||||||
|
fgColour: string | null;
|
||||||
|
title: string | null;
|
||||||
|
text: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GanttBlockResult {
|
||||||
|
found: boolean;
|
||||||
|
block: GanttItemBlock | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parsed and normalised CHART `gtd-gantt` config (no `project:` key). */
|
||||||
|
export interface GanttConfig {
|
||||||
|
title: string | null;
|
||||||
|
scopeDepth: number;
|
||||||
|
scopeAll: boolean;
|
||||||
|
notebook: string | null;
|
||||||
|
/** Limit the chart to a single project (case-insensitive); null = all. */
|
||||||
|
filterProject: string | null;
|
||||||
|
sortType: GanttSortType;
|
||||||
|
sort: "asc" | "desc";
|
||||||
|
cardDetail: "hover" | "always" | "none";
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A note rendered as a gantt bar (a dated span). */
|
||||||
|
export interface GanttBar {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
/** yyyy-mm-dd, inclusive. */
|
||||||
|
beginDate: string;
|
||||||
|
endDate: string;
|
||||||
|
bgColour: string | null;
|
||||||
|
fgColour: string | null;
|
||||||
|
text: string | null;
|
||||||
|
updatedTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A to-do rendered as a gantt milestone (a single dated point). */
|
||||||
|
export interface GanttMilestone {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
/** yyyy-mm-dd (the to-do's due date). */
|
||||||
|
date: string;
|
||||||
|
completed: boolean;
|
||||||
|
isRecurring: boolean;
|
||||||
|
bgColour: string | null;
|
||||||
|
fgColour: string | null;
|
||||||
|
text: string | null;
|
||||||
|
updatedTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One swimlane: all bars + milestones sharing a `project:`. */
|
||||||
|
export interface GanttProject {
|
||||||
|
name: string;
|
||||||
|
bars: GanttBar[];
|
||||||
|
milestones: GanttMilestone[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full gantt payload: projects plus the overall date envelope. */
|
||||||
|
export interface GanttChart {
|
||||||
|
projects: GanttProject[];
|
||||||
|
/** Earliest/latest dates across all items; null when the chart is empty. */
|
||||||
|
rangeStart: string | null;
|
||||||
|
rangeEnd: string | null;
|
||||||
|
warnings: string[];
|
||||||
|
scannedFolders: number;
|
||||||
|
scannedNotes: number;
|
||||||
|
itemCount: number;
|
||||||
|
}
|
||||||
|
|||||||
@ -407,3 +407,131 @@
|
|||||||
background-color: rgba(128, 128, 128, 0.06);
|
background-color: rgba(128, 128, 128, 0.06);
|
||||||
border-color: rgba(128, 128, 128, 0.4);
|
border-color: rgba(128, 128, 128, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* gtd-gantt */
|
||||||
|
|
||||||
|
/* Inline badge shown in item notes (a gtd-gantt block with a project). */
|
||||||
|
.gtd-gantt-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 0.1em 0.45em;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.5);
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: rgba(128, 128, 128, 0.12);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-empty {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-grid {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
row-gap: 4px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-corner {
|
||||||
|
grid-column: 1;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
background-color: rgba(128, 128, 128, 0.12);
|
||||||
|
border-bottom: 1px solid rgba(128, 128, 128, 0.3);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-tick {
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0.75;
|
||||||
|
padding: 0.25em 0.4em;
|
||||||
|
border-bottom: 1px solid rgba(128, 128, 128, 0.3);
|
||||||
|
border-left: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-project-header {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.82em;
|
||||||
|
padding: 0.3em 0.5em;
|
||||||
|
margin-top: 0.2em;
|
||||||
|
background-color: rgba(128, 128, 128, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-row-label {
|
||||||
|
grid-column: 1;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 0.82em;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
background-color: var(--joplin-background-color, white);
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-bar {
|
||||||
|
height: 1.5em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.78em;
|
||||||
|
line-height: 1.5em;
|
||||||
|
padding: 0 0.4em;
|
||||||
|
color: black;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-bar:hover {
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-milestone-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-milestone {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1;
|
||||||
|
color: rgb(200, 120, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-milestone:hover {
|
||||||
|
filter: brightness(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-gantt-milestone-done {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical "today" marker spanning the body rows. */
|
||||||
|
.gtd-gantt-today {
|
||||||
|
pointer-events: none;
|
||||||
|
border-left: 2px solid rgba(220, 60, 60, 0.7);
|
||||||
|
z-index: 1;
|
||||||
|
align-self: stretch;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,34 @@
|
|||||||
const CALENDAR_FENCE = "gtd-calendar";
|
const CALENDAR_FENCE = "gtd-calendar";
|
||||||
const KANBAN_FENCE = "gtd-kanban";
|
const KANBAN_FENCE = "gtd-kanban";
|
||||||
const MATRIX_FENCE = "gtd-matrix";
|
const MATRIX_FENCE = "gtd-matrix";
|
||||||
|
const GANTT_FENCE = "gtd-gantt";
|
||||||
|
|
||||||
|
// A gtd-gantt block carrying a non-empty `project:` value is an ITEM
|
||||||
|
// declaration (a bar/milestone), rendered as a small inline badge — not a
|
||||||
|
// chart. Mirrors collectGantt's classification (non-empty project ⇒ item).
|
||||||
|
// Cheap line scan; no YAML lib in the markdown-it context.
|
||||||
|
const GANTT_PROJECT_LINE = /^[ \t]*project[ \t]*:[ \t]*(\S.*?)[ \t]*$/m;
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip surrounding matching quotes from a scanned YAML scalar. */
|
||||||
|
function unquote(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (
|
||||||
|
trimmed.length >= 2 &&
|
||||||
|
((trimmed[0] === '"' && trimmed[trimmed.length - 1] === '"') ||
|
||||||
|
(trimmed[0] === "'" && trimmed[trimmed.length - 1] === "'"))
|
||||||
|
) {
|
||||||
|
return trimmed.slice(1, -1);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markdown-it content script.
|
* Markdown-it content script.
|
||||||
@ -40,20 +68,37 @@ export default function (context: { contentScriptId: string }) {
|
|||||||
const isCalendar = token.info === CALENDAR_FENCE;
|
const isCalendar = token.info === CALENDAR_FENCE;
|
||||||
const isKanban = token.info === KANBAN_FENCE;
|
const isKanban = token.info === KANBAN_FENCE;
|
||||||
const isMatrix = token.info === MATRIX_FENCE;
|
const isMatrix = token.info === MATRIX_FENCE;
|
||||||
if (!isCalendar && !isKanban && !isMatrix)
|
const isGantt = token.info === GANTT_FENCE;
|
||||||
|
if (!isCalendar && !isKanban && !isMatrix && !isGantt)
|
||||||
return defaultRender(tokens, idx, options, env, self);
|
return defaultRender(tokens, idx, options, env, self);
|
||||||
|
|
||||||
|
// gtd-gantt item declaration (has a project) → inline badge, not
|
||||||
|
// a chart placeholder.
|
||||||
|
if (isGantt) {
|
||||||
|
const projectMatch = GANTT_PROJECT_LINE.exec(token.content);
|
||||||
|
if (projectMatch) {
|
||||||
|
const project = escapeHtml(unquote(projectMatch[1]));
|
||||||
|
return (
|
||||||
|
`<span class="gtd-gantt-badge">gantt: ${project}</span>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const encodedConfig = encodeURIComponent(token.content);
|
const encodedConfig = encodeURIComponent(token.content);
|
||||||
const blockType = isCalendar
|
const blockType = isCalendar
|
||||||
? "calendar"
|
? "calendar"
|
||||||
: isKanban
|
: isKanban
|
||||||
? "kanban"
|
? "kanban"
|
||||||
: "matrix";
|
: isMatrix
|
||||||
|
? "matrix"
|
||||||
|
: "gantt";
|
||||||
const label = isCalendar
|
const label = isCalendar
|
||||||
? "Loading GTD Calendar…"
|
? "Loading GTD Calendar…"
|
||||||
: isKanban
|
: isKanban
|
||||||
? "Loading GTD Kanban…"
|
? "Loading GTD Kanban…"
|
||||||
: "Loading GTD Matrix…";
|
: isMatrix
|
||||||
|
? "Loading GTD Matrix…"
|
||||||
|
: "Loading GTD Gantt…";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
`<div class="gtd-calendar-placeholder"` +
|
`<div class="gtd-calendar-placeholder"` +
|
||||||
|
|||||||
@ -44,6 +44,8 @@
|
|||||||
? "getKanban"
|
? "getKanban"
|
||||||
: blockType === "matrix"
|
: blockType === "matrix"
|
||||||
? "getMatrix"
|
? "getMatrix"
|
||||||
|
: blockType === "gantt"
|
||||||
|
? "getGantt"
|
||||||
: "getEvents";
|
: "getEvents";
|
||||||
|
|
||||||
webviewApi
|
webviewApi
|
||||||
@ -60,6 +62,8 @@
|
|||||||
renderKanban(el, payload, contentScriptId);
|
renderKanban(el, payload, contentScriptId);
|
||||||
} else if (blockType === "matrix") {
|
} else if (blockType === "matrix") {
|
||||||
renderMatrix(el, payload, contentScriptId);
|
renderMatrix(el, payload, contentScriptId);
|
||||||
|
} else if (blockType === "gantt") {
|
||||||
|
renderGantt(el, payload, contentScriptId);
|
||||||
} else {
|
} else {
|
||||||
renderPayload(el, payload, contentScriptId);
|
renderPayload(el, payload, contentScriptId);
|
||||||
}
|
}
|
||||||
@ -180,7 +184,8 @@
|
|||||||
" note(s) in " +
|
" note(s) in " +
|
||||||
payload.stats.scannedFolders +
|
payload.stats.scannedFolders +
|
||||||
" folder(s) · view: " +
|
" folder(s) · view: " +
|
||||||
payload.view;
|
payload.view +
|
||||||
|
(payload.scopeAll ? " · scope: all (every notebook)" : "");
|
||||||
wrapper.appendChild(meta);
|
wrapper.appendChild(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -523,7 +528,8 @@
|
|||||||
payload.stats.scannedNotes +
|
payload.stats.scannedNotes +
|
||||||
" note(s) in " +
|
" note(s) in " +
|
||||||
payload.stats.scannedFolders +
|
payload.stats.scannedFolders +
|
||||||
" folder(s)";
|
" folder(s)" +
|
||||||
|
(payload.scopeAll ? " · scope: all (every notebook)" : "");
|
||||||
wrapper.appendChild(meta);
|
wrapper.appendChild(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -704,7 +710,8 @@
|
|||||||
payload.stats.scannedNotes +
|
payload.stats.scannedNotes +
|
||||||
" note(s) in " +
|
" note(s) in " +
|
||||||
payload.stats.scannedFolders +
|
payload.stats.scannedFolders +
|
||||||
" folder(s)";
|
" folder(s)" +
|
||||||
|
(payload.scopeAll ? " · scope: all (every notebook)" : "");
|
||||||
wrapper.appendChild(meta);
|
wrapper.appendChild(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -738,6 +745,284 @@
|
|||||||
return quadrant;
|
return quadrant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Gantt (gtd-gantt block)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
var GANTT_DAY_WIDTH = 26; // px per day column
|
||||||
|
var GANTT_LABEL_WIDTH = 160; // px for the row-label column
|
||||||
|
|
||||||
|
function renderGantt(el, payload, contentScriptId) {
|
||||||
|
el.innerHTML = "";
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "gtd-gantt";
|
||||||
|
|
||||||
|
if (payload.title) {
|
||||||
|
const heading = document.createElement("h3");
|
||||||
|
heading.className = "gtd-calendar-title";
|
||||||
|
heading.textContent = payload.title;
|
||||||
|
wrapper.appendChild(heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.configError) {
|
||||||
|
const error = document.createElement("p");
|
||||||
|
error.className = "gtd-calendar-error";
|
||||||
|
error.textContent = "Config problem: " + payload.configError;
|
||||||
|
wrapper.appendChild(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
(payload.warnings || []).forEach(function (warning) {
|
||||||
|
const warn = document.createElement("p");
|
||||||
|
warn.className = "gtd-calendar-warning";
|
||||||
|
warn.textContent = "⚠ " + warning;
|
||||||
|
wrapper.appendChild(warn);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chart = payload.chart || {
|
||||||
|
projects: [],
|
||||||
|
rangeStart: null,
|
||||||
|
rangeEnd: null,
|
||||||
|
};
|
||||||
|
const detail = payload.cardDetail || "hover";
|
||||||
|
|
||||||
|
if (!chart.projects.length || !chart.rangeStart) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "gtd-gantt-empty";
|
||||||
|
empty.textContent = "No gantt items in scope.";
|
||||||
|
wrapper.appendChild(empty);
|
||||||
|
} else {
|
||||||
|
wrapper.appendChild(buildGanttGrid(chart, detail, contentScriptId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.stats) {
|
||||||
|
const meta = document.createElement("p");
|
||||||
|
meta.className = "gtd-calendar-debug-meta";
|
||||||
|
meta.textContent =
|
||||||
|
payload.stats.itemCount +
|
||||||
|
" item(s) from " +
|
||||||
|
payload.stats.scannedNotes +
|
||||||
|
" note(s) in " +
|
||||||
|
payload.stats.scannedFolders +
|
||||||
|
" folder(s)" +
|
||||||
|
(payload.scopeAll ? " · scope: all (every notebook)" : "");
|
||||||
|
wrapper.appendChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGanttGrid(chart, detail, contentScriptId) {
|
||||||
|
const scroll = document.createElement("div");
|
||||||
|
scroll.className = "gtd-gantt-scroll";
|
||||||
|
|
||||||
|
const totalDays = offsetDays(chart.rangeStart, chart.rangeEnd) + 1;
|
||||||
|
|
||||||
|
const grid = document.createElement("div");
|
||||||
|
grid.className = "gtd-gantt-grid";
|
||||||
|
grid.style.gridTemplateColumns =
|
||||||
|
GANTT_LABEL_WIDTH +
|
||||||
|
"px repeat(" +
|
||||||
|
totalDays +
|
||||||
|
", " +
|
||||||
|
GANTT_DAY_WIDTH +
|
||||||
|
"px)";
|
||||||
|
|
||||||
|
// Row 1: corner + month tick header.
|
||||||
|
const corner = document.createElement("div");
|
||||||
|
corner.className = "gtd-gantt-corner";
|
||||||
|
corner.style.gridColumn = "1";
|
||||||
|
corner.style.gridRow = "1";
|
||||||
|
grid.appendChild(corner);
|
||||||
|
|
||||||
|
monthTicks(chart.rangeStart, chart.rangeEnd).forEach(function (tick) {
|
||||||
|
const cell = document.createElement("div");
|
||||||
|
cell.className = "gtd-gantt-tick";
|
||||||
|
cell.style.gridRow = "1";
|
||||||
|
cell.style.gridColumn =
|
||||||
|
tick.startOffset + 2 + " / " + (tick.endOffset + 3);
|
||||||
|
cell.textContent = tick.label;
|
||||||
|
grid.appendChild(cell);
|
||||||
|
});
|
||||||
|
|
||||||
|
let rowIndex = 2; // row 1 is the tick header
|
||||||
|
|
||||||
|
chart.projects.forEach(function (project) {
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "gtd-gantt-project-header";
|
||||||
|
header.style.gridColumn = "1 / -1";
|
||||||
|
header.style.gridRow = String(rowIndex);
|
||||||
|
header.textContent =
|
||||||
|
project.name +
|
||||||
|
" (" +
|
||||||
|
(project.bars.length + project.milestones.length) +
|
||||||
|
")";
|
||||||
|
grid.appendChild(header);
|
||||||
|
rowIndex += 1;
|
||||||
|
|
||||||
|
project.bars.forEach(function (bar) {
|
||||||
|
addGanttRowLabel(grid, rowIndex, bar.title, false, false);
|
||||||
|
const beginOff = clampOffset(
|
||||||
|
offsetDays(chart.rangeStart, bar.beginDate),
|
||||||
|
totalDays
|
||||||
|
);
|
||||||
|
const endOff = clampOffset(
|
||||||
|
offsetDays(chart.rangeStart, bar.endDate),
|
||||||
|
totalDays
|
||||||
|
);
|
||||||
|
const barEl = document.createElement("div");
|
||||||
|
barEl.className = "gtd-gantt-bar gtd-calendar-clickable";
|
||||||
|
barEl.style.gridRow = String(rowIndex);
|
||||||
|
barEl.style.gridColumn = beginOff + 2 + " / " + (endOff + 3);
|
||||||
|
barEl.style.backgroundColor = bar.bgColour || colourFromId(bar.id);
|
||||||
|
if (bar.fgColour) barEl.style.color = bar.fgColour;
|
||||||
|
barEl.textContent = bar.title;
|
||||||
|
if (detail !== "none") {
|
||||||
|
barEl.title = ganttBarTooltip(bar);
|
||||||
|
}
|
||||||
|
barEl.addEventListener("click", function () {
|
||||||
|
openNote(contentScriptId, bar.id);
|
||||||
|
});
|
||||||
|
grid.appendChild(barEl);
|
||||||
|
rowIndex += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
project.milestones.forEach(function (ms) {
|
||||||
|
addGanttRowLabel(
|
||||||
|
grid,
|
||||||
|
rowIndex,
|
||||||
|
ms.title,
|
||||||
|
ms.completed,
|
||||||
|
ms.isRecurring
|
||||||
|
);
|
||||||
|
const off = clampOffset(
|
||||||
|
offsetDays(chart.rangeStart, ms.date),
|
||||||
|
totalDays
|
||||||
|
);
|
||||||
|
const cell = document.createElement("div");
|
||||||
|
cell.className = "gtd-gantt-milestone-cell";
|
||||||
|
cell.style.gridRow = String(rowIndex);
|
||||||
|
cell.style.gridColumn = off + 2 + " / span 1";
|
||||||
|
const marker = document.createElement("span");
|
||||||
|
marker.className =
|
||||||
|
"gtd-gantt-milestone gtd-calendar-clickable";
|
||||||
|
if (ms.completed)
|
||||||
|
marker.className += " gtd-gantt-milestone-done";
|
||||||
|
marker.textContent = "◆"; // ◆
|
||||||
|
if (ms.bgColour) marker.style.color = ms.bgColour;
|
||||||
|
if (detail !== "none") {
|
||||||
|
marker.title = ganttMilestoneTooltip(ms);
|
||||||
|
}
|
||||||
|
marker.addEventListener("click", function () {
|
||||||
|
openNote(contentScriptId, ms.id);
|
||||||
|
});
|
||||||
|
cell.appendChild(marker);
|
||||||
|
grid.appendChild(cell);
|
||||||
|
rowIndex += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Today line spanning all body rows (only if within range).
|
||||||
|
const todayISO = todayLocalISO();
|
||||||
|
if (
|
||||||
|
todayISO >= chart.rangeStart &&
|
||||||
|
todayISO <= chart.rangeEnd &&
|
||||||
|
rowIndex > 2
|
||||||
|
) {
|
||||||
|
const off = offsetDays(chart.rangeStart, todayISO);
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "gtd-gantt-today";
|
||||||
|
line.style.gridColumn = off + 2 + " / span 1";
|
||||||
|
line.style.gridRow = "1 / " + rowIndex;
|
||||||
|
line.title = "Today";
|
||||||
|
grid.appendChild(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll.appendChild(grid);
|
||||||
|
return scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGanttRowLabel(grid, rowIndex, title, completed, recurring) {
|
||||||
|
const label = document.createElement("div");
|
||||||
|
label.className = "gtd-gantt-row-label";
|
||||||
|
label.style.gridColumn = "1";
|
||||||
|
label.style.gridRow = String(rowIndex);
|
||||||
|
label.textContent =
|
||||||
|
(completed ? "☑ " : "") + title + (recurring ? " ↻" : "");
|
||||||
|
if (completed) label.style.textDecoration = "line-through";
|
||||||
|
label.title = title;
|
||||||
|
grid.appendChild(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole-day offset from one ISO date to another (b - a). */
|
||||||
|
function offsetDays(fromISO, toISO) {
|
||||||
|
const from = isoToLocalDate(fromISO);
|
||||||
|
const to = isoToLocalDate(toISO);
|
||||||
|
return Math.round((to.getTime() - from.getTime()) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampOffset(off, totalDays) {
|
||||||
|
return Math.max(0, Math.min(off, totalDays - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Month header segments spanning the chart range. */
|
||||||
|
function monthTicks(startISO, endISO) {
|
||||||
|
const ticks = [];
|
||||||
|
const start = isoToLocalDate(startISO);
|
||||||
|
const end = isoToLocalDate(endISO);
|
||||||
|
let cur = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||||
|
while (cur <= end) {
|
||||||
|
const monthStart = cur < start ? start : cur;
|
||||||
|
const nextMonth = new Date(
|
||||||
|
cur.getFullYear(),
|
||||||
|
cur.getMonth() + 1,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
const monthEnd =
|
||||||
|
nextMonth <= end
|
||||||
|
? new Date(nextMonth.getTime() - 86400000)
|
||||||
|
: end;
|
||||||
|
ticks.push({
|
||||||
|
label: cur.toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
}),
|
||||||
|
startOffset: Math.round(
|
||||||
|
(monthStart.getTime() - start.getTime()) / 86400000
|
||||||
|
),
|
||||||
|
endOffset: Math.round(
|
||||||
|
(monthEnd.getTime() - start.getTime()) / 86400000
|
||||||
|
),
|
||||||
|
});
|
||||||
|
cur = nextMonth;
|
||||||
|
}
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayLocalISO() {
|
||||||
|
const d = new Date();
|
||||||
|
return (
|
||||||
|
d.getFullYear() +
|
||||||
|
"-" +
|
||||||
|
String(d.getMonth() + 1).padStart(2, "0") +
|
||||||
|
"-" +
|
||||||
|
String(d.getDate()).padStart(2, "0")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ganttBarTooltip(bar) {
|
||||||
|
let s = formatCardDate(bar.beginDate) + " → " + formatCardDate(bar.endDate);
|
||||||
|
if (bar.text) s += " · " + bar.text;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ganttMilestoneTooltip(ms) {
|
||||||
|
let s = formatCardDate(ms.date);
|
||||||
|
if (ms.completed) s += " · done";
|
||||||
|
if (ms.text) s += " · " + ms.text;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
processPlaceholders();
|
processPlaceholders();
|
||||||
|
|
||||||
// Joplin may replace rendered content without re-running asset scripts;
|
// Joplin may replace rendered content without re-running asset scripts;
|
||||||
|
|||||||
171
src/index.ts
171
src/index.ts
@ -10,7 +10,10 @@ import parseKanbanConfig from "./Gtd/parseKanbanConfig";
|
|||||||
import collectKanban from "./Gtd/collectKanban";
|
import collectKanban from "./Gtd/collectKanban";
|
||||||
import parseMatrixConfig from "./Gtd/parseMatrixConfig";
|
import parseMatrixConfig from "./Gtd/parseMatrixConfig";
|
||||||
import collectMatrix from "./Gtd/collectMatrix";
|
import collectMatrix from "./Gtd/collectMatrix";
|
||||||
|
import parseGanttConfig from "./Gtd/parseGanttConfig";
|
||||||
|
import collectGantt from "./Gtd/collectGantt";
|
||||||
import matrixLabels from "./Gtd/matrixLabels";
|
import matrixLabels from "./Gtd/matrixLabels";
|
||||||
|
import resolveNotebook from "./Gtd/resolveNotebook";
|
||||||
import { DataAdapter, RawFolder, RawNote } from "./Gtd/types";
|
import { DataAdapter, RawFolder, RawNote } from "./Gtd/types";
|
||||||
|
|
||||||
const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
|
const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
|
||||||
@ -23,20 +26,25 @@ const NOTE_FIELDS = [
|
|||||||
"todo_due",
|
"todo_due",
|
||||||
"todo_completed",
|
"todo_completed",
|
||||||
"updated_time",
|
"updated_time",
|
||||||
"body",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** NOTE_FIELDS plus `body`, requested only when the collector needs it. */
|
||||||
|
const NOTE_FIELDS_WITH_BODY = [...NOTE_FIELDS, "body"];
|
||||||
|
|
||||||
/** DataAdapter backed by the Joplin data API, with pagination. */
|
/** DataAdapter backed by the Joplin data API, with pagination. */
|
||||||
const joplinAdapter: DataAdapter = {
|
const joplinAdapter: DataAdapter = {
|
||||||
async getFolders(): Promise<RawFolder[]> {
|
async getFolders(): Promise<RawFolder[]> {
|
||||||
return fetchAllPages<RawFolder>(["folders"], {
|
return fetchAllPages<RawFolder>(["folders"], {
|
||||||
fields: ["id", "parent_id"],
|
fields: ["id", "parent_id", "title"],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async getNotesInFolder(folderId: string): Promise<RawNote[]> {
|
async getNotesInFolder(
|
||||||
|
folderId: string,
|
||||||
|
includeBody: boolean
|
||||||
|
): Promise<RawNote[]> {
|
||||||
return fetchAllPages<RawNote>(["folders", folderId, "notes"], {
|
return fetchAllPages<RawNote>(["folders", folderId, "notes"], {
|
||||||
fields: NOTE_FIELDS,
|
fields: includeBody ? NOTE_FIELDS_WITH_BODY : NOTE_FIELDS,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -49,6 +57,49 @@ const joplinAdapter: DataAdapter = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advisory threshold: under `scope: all`, if more notes than this were scanned
|
||||||
|
* we surface a soft warning so a huge profile-wide scan is visible. Advisory
|
||||||
|
* only — never blocks rendering.
|
||||||
|
*/
|
||||||
|
const SCOPE_ALL_SOFT_CAP = 2000;
|
||||||
|
|
||||||
|
/** Soft-cap warning for scope: all, or null if under the threshold / not all. */
|
||||||
|
function scopeAllSoftCapWarning(
|
||||||
|
config: { scopeAll: boolean },
|
||||||
|
scannedNotes: number
|
||||||
|
): string | null {
|
||||||
|
if (config.scopeAll && scannedNotes > SCOPE_ALL_SOFT_CAP) {
|
||||||
|
return `scope: all scanned ${scannedNotes} notes across every notebook — this can be slow. Consider narrowing scope or notebook.`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the folder to root the scan at. When the block sets `notebook:`,
|
||||||
|
* resolve it against the folder tree (id / title / Parent/Child path); any
|
||||||
|
* miss warns and falls back to the host note's own folder. Warnings are pushed
|
||||||
|
* onto the config so they surface alongside the view.
|
||||||
|
*/
|
||||||
|
async function resolveScanFolder(
|
||||||
|
config: { notebook: string | null; scopeAll: boolean; warnings: string[] },
|
||||||
|
hostFolderId: string
|
||||||
|
): Promise<string> {
|
||||||
|
// Under scope: all the root folder is irrelevant (every notebook is
|
||||||
|
// scanned) and any notebook: is already flagged by the parser — skip
|
||||||
|
// resolution entirely.
|
||||||
|
if (config.scopeAll) return hostFolderId;
|
||||||
|
if (!config.notebook) return hostFolderId;
|
||||||
|
const folders = await joplinAdapter.getFolders();
|
||||||
|
const { folderId, warning } = resolveNotebook(
|
||||||
|
folders,
|
||||||
|
config.notebook,
|
||||||
|
hostFolderId
|
||||||
|
);
|
||||||
|
if (warning) config.warnings.push(warning);
|
||||||
|
return folderId;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAllPages<T>(path: string[], query: any): Promise<T[]> {
|
async function fetchAllPages<T>(path: string[], query: any): Promise<T[]> {
|
||||||
const items: T[] = [];
|
const items: T[] = [];
|
||||||
let page = 1;
|
let page = 1;
|
||||||
@ -81,6 +132,8 @@ joplin.plugins.register({
|
|||||||
return handleGetKanban(message);
|
return handleGetKanban(message);
|
||||||
case "getMatrix":
|
case "getMatrix":
|
||||||
return handleGetMatrix(message);
|
return handleGetMatrix(message);
|
||||||
|
case "getGantt":
|
||||||
|
return handleGetGantt(message);
|
||||||
case "openNote":
|
case "openNote":
|
||||||
return handleOpenNote(message);
|
return handleOpenNote(message);
|
||||||
default:
|
default:
|
||||||
@ -132,21 +185,29 @@ async function handleGetEvents(message: { rawConfig?: string }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Collect events from the folder tree.
|
// 3. Collect events from the folder tree (rooted at notebook: or host folder).
|
||||||
|
const scanFolderId = await resolveScanFolder(config, sourceNote.parentId);
|
||||||
const result = await collectEvents(
|
const result = await collectEvents(
|
||||||
joplinAdapter,
|
joplinAdapter,
|
||||||
sourceNote.id,
|
sourceNote.id,
|
||||||
sourceNote.parentId,
|
scanFolderId,
|
||||||
config
|
config
|
||||||
);
|
);
|
||||||
|
|
||||||
const calendar = groupEvents(result.events, config.view);
|
const calendar = groupEvents(result.events, config.view);
|
||||||
|
|
||||||
|
const softCap = scopeAllSoftCapWarning(config, result.scannedNotes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: config.title,
|
title: config.title,
|
||||||
view: config.view,
|
view: config.view,
|
||||||
|
scopeAll: config.scopeAll,
|
||||||
configError,
|
configError,
|
||||||
warnings: [...config.warnings, ...result.warnings],
|
warnings: [
|
||||||
|
...config.warnings,
|
||||||
|
...result.warnings,
|
||||||
|
...(softCap ? [softCap] : []),
|
||||||
|
],
|
||||||
sourceNote,
|
sourceNote,
|
||||||
calendar,
|
calendar,
|
||||||
unscheduledTodos: config.unscheduledTodos,
|
unscheduledTodos: config.unscheduledTodos,
|
||||||
@ -197,18 +258,26 @@ async function handleGetKanban(message: { rawConfig?: string }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scanFolderId = await resolveScanFolder(config, sourceNote.parentId);
|
||||||
const result = await collectKanban(
|
const result = await collectKanban(
|
||||||
joplinAdapter,
|
joplinAdapter,
|
||||||
sourceNote.id,
|
sourceNote.id,
|
||||||
sourceNote.parentId,
|
scanFolderId,
|
||||||
config
|
config
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const softCap = scopeAllSoftCapWarning(config, result.scannedNotes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: config.title,
|
title: config.title,
|
||||||
cardDetail: config.cardDetail,
|
cardDetail: config.cardDetail,
|
||||||
|
scopeAll: config.scopeAll,
|
||||||
configError,
|
configError,
|
||||||
warnings: [...config.warnings, ...result.warnings],
|
warnings: [
|
||||||
|
...config.warnings,
|
||||||
|
...result.warnings,
|
||||||
|
...(softCap ? [softCap] : []),
|
||||||
|
],
|
||||||
board: result.board,
|
board: result.board,
|
||||||
stats: {
|
stats: {
|
||||||
scannedFolders: result.scannedFolders,
|
scannedFolders: result.scannedFolders,
|
||||||
@ -262,18 +331,26 @@ async function handleGetMatrix(message: { rawConfig?: string }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scanFolderId = await resolveScanFolder(config, sourceNote.parentId);
|
||||||
const result = await collectMatrix(
|
const result = await collectMatrix(
|
||||||
joplinAdapter,
|
joplinAdapter,
|
||||||
sourceNote.id,
|
sourceNote.id,
|
||||||
sourceNote.parentId,
|
scanFolderId,
|
||||||
config
|
config
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const softCap = scopeAllSoftCapWarning(config, result.scannedNotes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: config.title,
|
title: config.title,
|
||||||
cardDetail: config.cardDetail,
|
cardDetail: config.cardDetail,
|
||||||
|
scopeAll: config.scopeAll,
|
||||||
configError,
|
configError,
|
||||||
warnings: [...config.warnings, ...result.warnings],
|
warnings: [
|
||||||
|
...config.warnings,
|
||||||
|
...result.warnings,
|
||||||
|
...(softCap ? [softCap] : []),
|
||||||
|
],
|
||||||
labels: matrixLabels(config.mode),
|
labels: matrixLabels(config.mode),
|
||||||
board: result.board,
|
board: result.board,
|
||||||
stats: {
|
stats: {
|
||||||
@ -284,6 +361,78 @@ async function handleGetMatrix(message: { rawConfig?: string }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGetGantt(message: { rawConfig?: string }) {
|
||||||
|
let rawParsed: any = {};
|
||||||
|
let configError: string | null = null;
|
||||||
|
try {
|
||||||
|
rawParsed = YAML.parse(message.rawConfig || "") || {};
|
||||||
|
} catch (error) {
|
||||||
|
configError = String(error);
|
||||||
|
}
|
||||||
|
const config = parseGanttConfig(rawParsed);
|
||||||
|
|
||||||
|
let sourceNote: { id: string; title: string; parentId: string } | null =
|
||||||
|
null;
|
||||||
|
try {
|
||||||
|
const note = await joplin.workspace.selectedNote();
|
||||||
|
if (note) {
|
||||||
|
sourceNote = {
|
||||||
|
id: note.id,
|
||||||
|
title: note.title,
|
||||||
|
parentId: note.parent_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("gtd-gantt: could not resolve selected note", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceNote) {
|
||||||
|
return {
|
||||||
|
title: config.title,
|
||||||
|
cardDetail: config.cardDetail,
|
||||||
|
scopeAll: config.scopeAll,
|
||||||
|
configError:
|
||||||
|
configError ||
|
||||||
|
"Could not determine which note contains this gantt.",
|
||||||
|
warnings: config.warnings,
|
||||||
|
chart: { projects: [], rangeStart: null, rangeEnd: null },
|
||||||
|
stats: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanFolderId = await resolveScanFolder(config, sourceNote.parentId);
|
||||||
|
const result = await collectGantt(
|
||||||
|
joplinAdapter,
|
||||||
|
sourceNote.id,
|
||||||
|
scanFolderId,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
|
||||||
|
const softCap = scopeAllSoftCapWarning(config, result.scannedNotes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: config.title,
|
||||||
|
cardDetail: config.cardDetail,
|
||||||
|
scopeAll: config.scopeAll,
|
||||||
|
configError,
|
||||||
|
warnings: [
|
||||||
|
...config.warnings,
|
||||||
|
...result.warnings,
|
||||||
|
...(softCap ? [softCap] : []),
|
||||||
|
],
|
||||||
|
chart: {
|
||||||
|
projects: result.projects,
|
||||||
|
rangeStart: result.rangeStart,
|
||||||
|
rangeEnd: result.rangeEnd,
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
scannedFolders: result.scannedFolders,
|
||||||
|
scannedNotes: result.scannedNotes,
|
||||||
|
itemCount: result.itemCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function handleOpenNote(message: { noteId?: string }) {
|
async function handleOpenNote(message: { noteId?: string }) {
|
||||||
if (!message.noteId) return { ok: false, error: "No noteId provided" };
|
if (!message.noteId) return { ok: false, error: "No noteId provided" };
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 1,
|
"manifest_version": 1,
|
||||||
"id": "com.victorwiebe.joplin.plugin.gtd-calendar",
|
"id": "com.victorwiebe.joplin.plugin.gtd-calendar",
|
||||||
"app_min_version": "2.7",
|
"app_min_version": "2.7",
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"name": "GTD Calendar",
|
"name": "GTD Calendar",
|
||||||
"description": "Day, week, and month calendars populated by your notes and to-dos, with click-through to the source note. Configure with simple YAML blocks. A GTD-friendly fork of Event Calendar by Franco Speziali.",
|
"description": "Day, week, and month calendars populated by your notes and to-dos, with click-through to the source note. Configure with simple YAML blocks. A GTD-friendly fork of Event Calendar by Franco Speziali.",
|
||||||
"author": "Victor Wiebe",
|
"author": "Victor Wiebe",
|
||||||
|
|||||||
89
src/tests/Gtd/fieldsHint.test.ts
Normal file
89
src/tests/Gtd/fieldsHint.test.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import collectEvents from "../../Gtd/collectEvents";
|
||||||
|
import collectKanban from "../../Gtd/collectKanban";
|
||||||
|
import collectMatrix from "../../Gtd/collectMatrix";
|
||||||
|
import parseCalendarConfig from "../../Gtd/parseCalendarConfig";
|
||||||
|
import parseKanbanConfig from "../../Gtd/parseKanbanConfig";
|
||||||
|
import parseMatrixConfig from "../../Gtd/parseMatrixConfig";
|
||||||
|
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Body-fetch optimization (SLICE1 Task 4): each collector must ask the adapter
|
||||||
|
// for note bodies ONLY when its inclusion mode means the gtd block gets
|
||||||
|
// scanned. When the view can produce nothing (all relevant modes "none"), the
|
||||||
|
// body field is skipped. This test records the `includeBody` arg the collectors
|
||||||
|
// pass to getNotesInFolder.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const folders: RawFolder[] = [{ id: "folder", parent_id: "root" }];
|
||||||
|
|
||||||
|
function makeNote(overrides: Partial<RawNote> = {}): RawNote {
|
||||||
|
return {
|
||||||
|
id: "n1",
|
||||||
|
title: "Note",
|
||||||
|
parent_id: "folder",
|
||||||
|
is_todo: 1,
|
||||||
|
todo_due: 0,
|
||||||
|
todo_completed: 0,
|
||||||
|
updated_time: 0,
|
||||||
|
body: "```gtd\n```",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adapter that records every `includeBody` flag getNotesInFolder was called with. */
|
||||||
|
function recordingAdapter(notes: RawNote[]): {
|
||||||
|
adapter: DataAdapter;
|
||||||
|
bodyFlags: boolean[];
|
||||||
|
} {
|
||||||
|
const bodyFlags: boolean[] = [];
|
||||||
|
const adapter: DataAdapter = {
|
||||||
|
getFolders: async () => folders,
|
||||||
|
getNotesInFolder: async (folderId: string, includeBody: boolean) => {
|
||||||
|
bodyFlags.push(includeBody);
|
||||||
|
return notes.filter((n) => n.parent_id === folderId);
|
||||||
|
},
|
||||||
|
getNoteTagTitles: async () => [],
|
||||||
|
};
|
||||||
|
return { adapter, bodyFlags };
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW = new Date(2026, 5, 15, 12, 0);
|
||||||
|
|
||||||
|
describe("collectEvents — body fetch hint", () => {
|
||||||
|
test.each([
|
||||||
|
["gtd-only notes", { notes: "gtd-only", todos: "none" }, true],
|
||||||
|
["all todos", { notes: "none", todos: "all" }, true],
|
||||||
|
["both none", { notes: "none", todos: "none" }, false],
|
||||||
|
])("%s → includeBody %s", async (_label, raw, expected) => {
|
||||||
|
const { adapter, bodyFlags } = recordingAdapter([makeNote()]);
|
||||||
|
const config = parseCalendarConfig(raw as any);
|
||||||
|
await collectEvents(adapter, "host", "folder", config);
|
||||||
|
expect(bodyFlags).toEqual([expected]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collectKanban — body fetch hint", () => {
|
||||||
|
test.each([
|
||||||
|
["gtd-only", { todos: "gtd-only" }, true],
|
||||||
|
["all", { todos: "all" }, true],
|
||||||
|
["none", { todos: "none" }, false],
|
||||||
|
])("%s → includeBody %s", async (_label, raw, expected) => {
|
||||||
|
const { adapter, bodyFlags } = recordingAdapter([makeNote()]);
|
||||||
|
const config = parseKanbanConfig(raw as any);
|
||||||
|
await collectKanban(adapter, "host", "folder", config, NOW);
|
||||||
|
expect(bodyFlags).toEqual([expected]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collectMatrix — body fetch hint", () => {
|
||||||
|
test.each([
|
||||||
|
["gtd-only", { todos: "gtd-only" }, true],
|
||||||
|
["all", { todos: "all" }, true],
|
||||||
|
["none", { todos: "none" }, false],
|
||||||
|
])("%s → includeBody %s", async (_label, raw, expected) => {
|
||||||
|
const { adapter, bodyFlags } = recordingAdapter([makeNote()]);
|
||||||
|
const config = parseMatrixConfig(raw as any);
|
||||||
|
await collectMatrix(adapter, "host", "folder", config, NOW);
|
||||||
|
expect(bodyFlags).toEqual([expected]);
|
||||||
|
});
|
||||||
|
});
|
||||||
336
src/tests/Gtd/gantt.test.ts
Normal file
336
src/tests/Gtd/gantt.test.ts
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
import extractGanttBlock from "../../Gtd/ganttBlock";
|
||||||
|
import parseGanttConfig from "../../Gtd/parseGanttConfig";
|
||||||
|
import collectGantt from "../../Gtd/collectGantt";
|
||||||
|
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// extractGanttBlock — item vs chart disambiguation & parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("extractGanttBlock", () => {
|
||||||
|
test("no block → not found", () => {
|
||||||
|
expect(extractGanttBlock("just text").found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chart block (no project) → found, project null", () => {
|
||||||
|
const r = extractGanttBlock("```gtd-gantt\ntitle: Roadmap\n```");
|
||||||
|
expect(r.found).toBe(true);
|
||||||
|
expect(r.block?.project).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("item block (has project) parses bar fields", () => {
|
||||||
|
const body =
|
||||||
|
"```gtd-gantt\nproject: bluesky\nbegin-date: 2026-07-01\nend-date: 2026-07-10\n```";
|
||||||
|
const r = extractGanttBlock(body);
|
||||||
|
expect(r.block).toMatchObject({
|
||||||
|
project: "bluesky",
|
||||||
|
beginDate: "2026-07-01",
|
||||||
|
endDate: "2026-07-10",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("styling keys mirror the gtd block", () => {
|
||||||
|
const body =
|
||||||
|
"```gtd-gantt\nproject: p\nbg-colour: teal\nfg-colour: white\ntitle: T\ntext: hi\n```";
|
||||||
|
expect(r_block(body)).toMatchObject({
|
||||||
|
bgColour: "teal",
|
||||||
|
fgColour: "white",
|
||||||
|
title: "T",
|
||||||
|
text: "hi",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("yaml Date coercion for begin/end (unquoted dates)", () => {
|
||||||
|
const body =
|
||||||
|
"```gtd-gantt\nproject: p\nbegin-date: 2026-07-01\nend-date: 2026-07-10\n```";
|
||||||
|
const b = r_block(body);
|
||||||
|
expect(typeof b?.beginDate).toBe("string");
|
||||||
|
expect(b?.beginDate).toBe("2026-07-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed yaml → found with error", () => {
|
||||||
|
const r = extractGanttBlock("```gtd-gantt\nproject: [a, b\n```");
|
||||||
|
expect(r.found).toBe(true);
|
||||||
|
expect(r.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
function r_block(body: string) {
|
||||||
|
return extractGanttBlock(body).block;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseGanttConfig
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("parseGanttConfig", () => {
|
||||||
|
test("defaults", () => {
|
||||||
|
const c = parseGanttConfig({});
|
||||||
|
expect(c).toMatchObject({
|
||||||
|
title: null,
|
||||||
|
scopeDepth: 0,
|
||||||
|
scopeAll: false,
|
||||||
|
notebook: null,
|
||||||
|
filterProject: null,
|
||||||
|
sortType: "begin-date",
|
||||||
|
sort: "asc",
|
||||||
|
cardDetail: "hover",
|
||||||
|
});
|
||||||
|
expect(c.warnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("full config", () => {
|
||||||
|
const c = parseGanttConfig({
|
||||||
|
title: "Roadmap",
|
||||||
|
scope: "children",
|
||||||
|
notebook: "Work",
|
||||||
|
"filter-project": "bluesky",
|
||||||
|
"sort-type": "title",
|
||||||
|
sort: "desc",
|
||||||
|
"card-detail": "always",
|
||||||
|
});
|
||||||
|
expect(c).toMatchObject({
|
||||||
|
title: "Roadmap",
|
||||||
|
scopeDepth: Infinity,
|
||||||
|
notebook: "Work",
|
||||||
|
filterProject: "bluesky",
|
||||||
|
sortType: "title",
|
||||||
|
sort: "desc",
|
||||||
|
cardDetail: "always",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scope: all + notebook → conflict warning", () => {
|
||||||
|
const c = parseGanttConfig({ scope: "all", notebook: "Work" });
|
||||||
|
expect(c.scopeAll).toBe(true);
|
||||||
|
expect(c.warnings.some((w) => /notebook.*ignored/i.test(w))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid values warn and fall back", () => {
|
||||||
|
const c = parseGanttConfig({
|
||||||
|
"sort-type": "priority",
|
||||||
|
"card-detail": "popup",
|
||||||
|
nonsense: 1,
|
||||||
|
});
|
||||||
|
expect(c.sortType).toBe("begin-date");
|
||||||
|
expect(c.cardDetail).toBe("hover");
|
||||||
|
expect(c.warnings.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// collectGantt
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const folders: RawFolder[] = [{ id: "f", parent_id: "root", title: "F" }];
|
||||||
|
|
||||||
|
function makeNote(overrides: Partial<RawNote>): RawNote {
|
||||||
|
return {
|
||||||
|
id: "id",
|
||||||
|
title: "Item",
|
||||||
|
parent_id: "f",
|
||||||
|
is_todo: 0,
|
||||||
|
todo_due: 0,
|
||||||
|
todo_completed: 0,
|
||||||
|
updated_time: 0,
|
||||||
|
body: "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ganttBody(fields: Record<string, string>): string {
|
||||||
|
const lines = Object.entries(fields)
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join("\n");
|
||||||
|
return "```gtd-gantt\n" + lines + "\n```";
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapterFor(
|
||||||
|
notes: RawNote[],
|
||||||
|
tagsByNote: Record<string, string[]> = {}
|
||||||
|
): DataAdapter {
|
||||||
|
return {
|
||||||
|
getFolders: async () => folders,
|
||||||
|
getNotesInFolder: async (folderId: string) =>
|
||||||
|
notes.filter((n) => n.parent_id === folderId),
|
||||||
|
getNoteTagTitles: async (id: string) => tagsByNote[id] || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("collectGantt", () => {
|
||||||
|
test("groups bars and milestones by project; computes range", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({
|
||||||
|
id: "bar1",
|
||||||
|
title: "Design",
|
||||||
|
body: ganttBody({
|
||||||
|
project: "bluesky",
|
||||||
|
"begin-date": "2026-07-01",
|
||||||
|
"end-date": "2026-07-10",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
makeNote({
|
||||||
|
id: "ms1",
|
||||||
|
title: "Launch",
|
||||||
|
is_todo: 1,
|
||||||
|
todo_due: Date.UTC(2026, 6, 15, 12), // Jul 15
|
||||||
|
body: ganttBody({ project: "bluesky" }),
|
||||||
|
}),
|
||||||
|
makeNote({
|
||||||
|
id: "bar2",
|
||||||
|
title: "Spec",
|
||||||
|
body: ganttBody({
|
||||||
|
project: "apollo",
|
||||||
|
"begin-date": "2026-08-01",
|
||||||
|
"end-date": "2026-08-05",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const chart = await collectGantt(adapterFor(notes), "host", "f", {
|
||||||
|
...parseGanttConfig({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Projects alphabetical: apollo, bluesky
|
||||||
|
expect(chart.projects.map((p) => p.name)).toEqual(["apollo", "bluesky"]);
|
||||||
|
const bluesky = chart.projects.find((p) => p.name === "bluesky")!;
|
||||||
|
expect(bluesky.bars.map((b) => b.id)).toEqual(["bar1"]);
|
||||||
|
expect(bluesky.milestones.map((m) => m.id)).toEqual(["ms1"]);
|
||||||
|
expect(chart.itemCount).toBe(3);
|
||||||
|
expect(chart.rangeStart).toBe("2026-07-01");
|
||||||
|
expect(chart.rangeEnd).toBe("2026-08-05");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chart blocks (no project) and non-gantt notes are ignored", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "chart", body: "```gtd-gantt\ntitle: X\n```" }),
|
||||||
|
makeNote({ id: "plain", body: "no block here" }),
|
||||||
|
];
|
||||||
|
const chart = await collectGantt(adapterFor(notes), "host", "f", {
|
||||||
|
...parseGanttConfig({}),
|
||||||
|
});
|
||||||
|
expect(chart.projects).toHaveLength(0);
|
||||||
|
expect(chart.itemCount).toBe(0);
|
||||||
|
expect(chart.rangeStart).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("host note is excluded even with an item block", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({
|
||||||
|
id: "host",
|
||||||
|
body: ganttBody({
|
||||||
|
project: "p",
|
||||||
|
"begin-date": "2026-07-01",
|
||||||
|
"end-date": "2026-07-02",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const chart = await collectGantt(adapterFor(notes), "host", "f", {
|
||||||
|
...parseGanttConfig({}),
|
||||||
|
});
|
||||||
|
expect(chart.itemCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bar with missing/invalid dates → warn + exclude", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "nobegin", body: ganttBody({ project: "p", "end-date": "2026-07-10" }) }),
|
||||||
|
makeNote({
|
||||||
|
id: "reversed",
|
||||||
|
body: ganttBody({
|
||||||
|
project: "p",
|
||||||
|
"begin-date": "2026-07-10",
|
||||||
|
"end-date": "2026-07-01",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const chart = await collectGantt(adapterFor(notes), "host", "f", {
|
||||||
|
...parseGanttConfig({}),
|
||||||
|
});
|
||||||
|
expect(chart.itemCount).toBe(0);
|
||||||
|
expect(chart.warnings.length).toBe(2);
|
||||||
|
expect(chart.warnings.some((w) => /before begin-date/.test(w))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("to-do milestone uses due date; begin/end trigger a warning", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({
|
||||||
|
id: "ms",
|
||||||
|
is_todo: 1,
|
||||||
|
todo_due: Date.UTC(2026, 6, 20, 12),
|
||||||
|
body: ganttBody({
|
||||||
|
project: "p",
|
||||||
|
"begin-date": "2026-07-01",
|
||||||
|
"end-date": "2026-07-10",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const chart = await collectGantt(adapterFor(notes), "host", "f", {
|
||||||
|
...parseGanttConfig({}),
|
||||||
|
});
|
||||||
|
const ms = chart.projects[0].milestones[0];
|
||||||
|
expect(ms.date).toBe("2026-07-20");
|
||||||
|
expect(chart.warnings.some((w) => /ignored/.test(w))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("to-do milestone without a due date → warn + exclude", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "ms", is_todo: 1, todo_due: 0, body: ganttBody({ project: "p" }) }),
|
||||||
|
];
|
||||||
|
const chart = await collectGantt(adapterFor(notes), "host", "f", {
|
||||||
|
...parseGanttConfig({}),
|
||||||
|
});
|
||||||
|
expect(chart.itemCount).toBe(0);
|
||||||
|
expect(chart.warnings.some((w) => /no due date/.test(w))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("completed + recurring flags on milestones", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({
|
||||||
|
id: "ms",
|
||||||
|
is_todo: 1,
|
||||||
|
todo_due: Date.UTC(2026, 6, 20, 12),
|
||||||
|
todo_completed: Date.UTC(2026, 6, 21, 12),
|
||||||
|
body: ganttBody({ project: "p" }),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const chart = await collectGantt(adapterFor(notes, { ms: ["recurring"] }), "host", "f", {
|
||||||
|
...parseGanttConfig({}),
|
||||||
|
});
|
||||||
|
const ms = chart.projects[0].milestones[0];
|
||||||
|
expect(ms.completed).toBe(true);
|
||||||
|
expect(ms.isRecurring).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filter-project limits to one project (case-insensitive)", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "a", body: ganttBody({ project: "Bluesky", "begin-date": "2026-07-01", "end-date": "2026-07-02" }) }),
|
||||||
|
makeNote({ id: "b", body: ganttBody({ project: "Apollo", "begin-date": "2026-07-01", "end-date": "2026-07-02" }) }),
|
||||||
|
];
|
||||||
|
const chart = await collectGantt(adapterFor(notes), "host", "f", {
|
||||||
|
...parseGanttConfig({ "filter-project": "bluesky" }),
|
||||||
|
});
|
||||||
|
expect(chart.projects.map((p) => p.name)).toEqual(["Bluesky"]);
|
||||||
|
expect(chart.itemCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bars sort within a project by begin-date, then respect sort dir", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "late", body: ganttBody({ project: "p", "begin-date": "2026-07-10", "end-date": "2026-07-12" }) }),
|
||||||
|
makeNote({ id: "early", body: ganttBody({ project: "p", "begin-date": "2026-07-01", "end-date": "2026-07-03" }) }),
|
||||||
|
];
|
||||||
|
const asc = await collectGantt(adapterFor(notes), "host", "f", { ...parseGanttConfig({}) });
|
||||||
|
expect(asc.projects[0].bars.map((b) => b.id)).toEqual(["early", "late"]);
|
||||||
|
|
||||||
|
const desc = await collectGantt(adapterFor(notes), "host", "f", { ...parseGanttConfig({ sort: "desc" }) });
|
||||||
|
expect(desc.projects[0].bars.map((b) => b.id)).toEqual(["late", "early"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("same project name in different cases merges into one lane", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "a", body: ganttBody({ project: "Bluesky", "begin-date": "2026-07-01", "end-date": "2026-07-02" }) }),
|
||||||
|
makeNote({ id: "b", body: ganttBody({ project: "bluesky", "begin-date": "2026-07-03", "end-date": "2026-07-04" }) }),
|
||||||
|
];
|
||||||
|
const chart = await collectGantt(adapterFor(notes), "host", "f", { ...parseGanttConfig({}) });
|
||||||
|
expect(chart.projects).toHaveLength(1);
|
||||||
|
expect(chart.projects[0].bars).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
146
src/tests/Gtd/resolveNotebook.test.ts
Normal file
146
src/tests/Gtd/resolveNotebook.test.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import resolveNotebook, {
|
||||||
|
parseNotebookOption,
|
||||||
|
} from "../../Gtd/resolveNotebook";
|
||||||
|
import collectEvents from "../../Gtd/collectEvents";
|
||||||
|
import parseCalendarConfig from "../../Gtd/parseCalendarConfig";
|
||||||
|
import parseKanbanConfig from "../../Gtd/parseKanbanConfig";
|
||||||
|
import parseMatrixConfig from "../../Gtd/parseMatrixConfig";
|
||||||
|
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
|
||||||
|
|
||||||
|
// A small tree:
|
||||||
|
// Work (root)
|
||||||
|
// Projects
|
||||||
|
// Alpha
|
||||||
|
// Archive <- another "Archive" also lives under Personal
|
||||||
|
// Personal (root)
|
||||||
|
// Archive
|
||||||
|
const ID_WORK = "11111111111111111111111111111111";
|
||||||
|
const folders: RawFolder[] = [
|
||||||
|
{ id: ID_WORK, parent_id: "", title: "Work" },
|
||||||
|
{ id: "proj", parent_id: ID_WORK, title: "Projects" },
|
||||||
|
{ id: "alpha", parent_id: "proj", title: "Alpha" },
|
||||||
|
{ id: "work-archive", parent_id: ID_WORK, title: "Archive" },
|
||||||
|
{ id: "personal", parent_id: "", title: "Personal" },
|
||||||
|
{ id: "personal-archive", parent_id: "personal", title: "Archive" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HOST = "host-folder";
|
||||||
|
|
||||||
|
describe("resolveNotebook", () => {
|
||||||
|
test("resolves a raw 32-char hex id", () => {
|
||||||
|
const r = resolveNotebook(folders, ID_WORK, HOST);
|
||||||
|
expect(r).toEqual({ folderId: ID_WORK, warning: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown id → warn + host fallback", () => {
|
||||||
|
const missing = "99999999999999999999999999999999";
|
||||||
|
const r = resolveNotebook(folders, missing, HOST);
|
||||||
|
expect(r.folderId).toBe(HOST);
|
||||||
|
expect(r.warning).toMatch(/no notebook with id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves a unique bare title (case-insensitive)", () => {
|
||||||
|
const r = resolveNotebook(folders, "projects", HOST);
|
||||||
|
expect(r).toEqual({ folderId: "proj", warning: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ambiguous bare title → warn + host fallback", () => {
|
||||||
|
const r = resolveNotebook(folders, "Archive", HOST);
|
||||||
|
expect(r.folderId).toBe(HOST);
|
||||||
|
expect(r.warning).toMatch(/ambiguous/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing bare title → warn + host fallback", () => {
|
||||||
|
const r = resolveNotebook(folders, "Nope", HOST);
|
||||||
|
expect(r.folderId).toBe(HOST);
|
||||||
|
expect(r.warning).toMatch(/no notebook named/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves a Parent/Child path", () => {
|
||||||
|
const r = resolveNotebook(folders, "Work/Projects/Alpha", HOST);
|
||||||
|
expect(r).toEqual({ folderId: "alpha", warning: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("path disambiguates a name that is ambiguous as a bare title", () => {
|
||||||
|
const r = resolveNotebook(folders, "Personal/Archive", HOST);
|
||||||
|
expect(r).toEqual({ folderId: "personal-archive", warning: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unresolvable path → warn + host fallback", () => {
|
||||||
|
const r = resolveNotebook(folders, "Work/Nope", HOST);
|
||||||
|
expect(r.folderId).toBe(HOST);
|
||||||
|
expect(r.warning).toMatch(/not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty spec → host, no warning", () => {
|
||||||
|
expect(resolveNotebook(folders, "", HOST)).toEqual({
|
||||||
|
folderId: HOST,
|
||||||
|
warning: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseNotebookOption", () => {
|
||||||
|
test("trims and passes through", () => {
|
||||||
|
expect(parseNotebookOption(" Work/Projects ")).toBe("Work/Projects");
|
||||||
|
});
|
||||||
|
test("absent/empty → null", () => {
|
||||||
|
expect(parseNotebookOption(undefined)).toBeNull();
|
||||||
|
expect(parseNotebookOption(null)).toBeNull();
|
||||||
|
expect(parseNotebookOption(" ")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("notebook config parsing", () => {
|
||||||
|
test("calendar: default null, value passed through, known key", () => {
|
||||||
|
expect(parseCalendarConfig({}).notebook).toBeNull();
|
||||||
|
const c = parseCalendarConfig({ notebook: "Work/Projects" });
|
||||||
|
expect(c.notebook).toBe("Work/Projects");
|
||||||
|
expect(c.warnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
test("kanban: value passed through", () => {
|
||||||
|
expect(parseKanbanConfig({ notebook: "Work" }).notebook).toBe("Work");
|
||||||
|
expect(parseKanbanConfig({}).notebook).toBeNull();
|
||||||
|
});
|
||||||
|
test("matrix: value passed through", () => {
|
||||||
|
expect(parseMatrixConfig({ notebook: "Work" }).notebook).toBe("Work");
|
||||||
|
expect(parseMatrixConfig({}).notebook).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collector-level integration: resolving a notebook reroots the scan so
|
||||||
|
// collectEvents pulls from the resolved folder, not the host folder.
|
||||||
|
describe("notebook → collector integration", () => {
|
||||||
|
function makeNote(overrides: Partial<RawNote>): RawNote {
|
||||||
|
return {
|
||||||
|
id: "n",
|
||||||
|
title: "Note",
|
||||||
|
parent_id: "alpha",
|
||||||
|
is_todo: 0,
|
||||||
|
todo_due: 0,
|
||||||
|
todo_completed: 0,
|
||||||
|
updated_time: 0,
|
||||||
|
body: "```gtd\n```",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const notesByFolder: Record<string, RawNote[]> = {
|
||||||
|
alpha: [makeNote({ id: "in-alpha", title: "In Alpha", parent_id: "alpha" })],
|
||||||
|
[HOST]: [makeNote({ id: "in-host", title: "In Host", parent_id: HOST })],
|
||||||
|
};
|
||||||
|
|
||||||
|
const adapter: DataAdapter = {
|
||||||
|
getFolders: async () => folders,
|
||||||
|
getNotesInFolder: async (folderId: string) =>
|
||||||
|
notesByFolder[folderId] || [],
|
||||||
|
getNoteTagTitles: async () => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
test("scan roots at the resolved notebook folder", async () => {
|
||||||
|
const { folderId } = resolveNotebook(folders, "Work/Projects/Alpha", HOST);
|
||||||
|
const config = parseCalendarConfig({ notes: "all" });
|
||||||
|
const result = await collectEvents(adapter, "host-note", folderId, config);
|
||||||
|
expect(result.events.map((e) => e.id)).toEqual(["in-alpha"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
107
src/tests/Gtd/scopeAll.test.ts
Normal file
107
src/tests/Gtd/scopeAll.test.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import parseCalendarConfig from "../../Gtd/parseCalendarConfig";
|
||||||
|
import parseKanbanConfig from "../../Gtd/parseKanbanConfig";
|
||||||
|
import parseMatrixConfig from "../../Gtd/parseMatrixConfig";
|
||||||
|
import resolveScopedFolderIds from "../../Gtd/folderScope";
|
||||||
|
import collectEvents from "../../Gtd/collectEvents";
|
||||||
|
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// scope: all (SLICE3) — scan every notebook in the profile.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("scope: all — config parsing", () => {
|
||||||
|
test("calendar: scope: all sets scopeAll, leaves scopeDepth default", () => {
|
||||||
|
const c = parseCalendarConfig({ scope: "all" });
|
||||||
|
expect(c.scopeAll).toBe(true);
|
||||||
|
expect(c.scopeDepth).toBe(0);
|
||||||
|
expect(c.warnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default (no scope) → scopeAll false", () => {
|
||||||
|
expect(parseCalendarConfig({}).scopeAll).toBe(false);
|
||||||
|
expect(parseKanbanConfig({}).scopeAll).toBe(false);
|
||||||
|
expect(parseMatrixConfig({}).scopeAll).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("kanban & matrix: scope: all sets scopeAll", () => {
|
||||||
|
expect(parseKanbanConfig({ scope: "all" }).scopeAll).toBe(true);
|
||||||
|
expect(parseMatrixConfig({ scope: "all" }).scopeAll).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notebook + scope: all → warning (notebook ignored)", () => {
|
||||||
|
for (const parse of [
|
||||||
|
parseCalendarConfig,
|
||||||
|
parseKanbanConfig,
|
||||||
|
parseMatrixConfig,
|
||||||
|
]) {
|
||||||
|
const c = parse({ scope: "all", notebook: "Work" });
|
||||||
|
expect(c.scopeAll).toBe(true);
|
||||||
|
expect(c.warnings.some((w) => /notebook.*ignored/i.test(w))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notebook without scope: all → no conflict warning", () => {
|
||||||
|
const c = parseCalendarConfig({ notebook: "Work" });
|
||||||
|
expect(c.warnings.some((w) => /ignored/i.test(w))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveScopedFolderIds — scopeAll path", () => {
|
||||||
|
// Two disconnected roots so we can prove it ignores the tree structure.
|
||||||
|
const folders: RawFolder[] = [
|
||||||
|
{ id: "a", parent_id: "", title: "A" },
|
||||||
|
{ id: "a1", parent_id: "a", title: "A1" },
|
||||||
|
{ id: "b", parent_id: "", title: "B" },
|
||||||
|
{ id: "b1", parent_id: "b", title: "B1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("returns every folder id regardless of root/depth", () => {
|
||||||
|
const ids = resolveScopedFolderIds(folders, "a", 0, true);
|
||||||
|
expect(ids.sort()).toEqual(["a", "a1", "b", "b1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without scopeAll, honours root + depth (unchanged behaviour)", () => {
|
||||||
|
expect(resolveScopedFolderIds(folders, "a", 0, false)).toEqual(["a"]);
|
||||||
|
expect(resolveScopedFolderIds(folders, "a", 0).sort()).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scope: all — collector spans multiple roots", () => {
|
||||||
|
const folders: RawFolder[] = [
|
||||||
|
{ id: "work", parent_id: "", title: "Work" },
|
||||||
|
{ id: "personal", parent_id: "", title: "Personal" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function note(id: string, parent: string): RawNote {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
parent_id: parent,
|
||||||
|
is_todo: 0,
|
||||||
|
todo_due: 0,
|
||||||
|
todo_completed: 0,
|
||||||
|
updated_time: 0,
|
||||||
|
body: "```gtd\n```",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const notesByFolder: Record<string, RawNote[]> = {
|
||||||
|
work: [note("w1", "work")],
|
||||||
|
personal: [note("p1", "personal")],
|
||||||
|
};
|
||||||
|
|
||||||
|
const adapter: DataAdapter = {
|
||||||
|
getFolders: async () => folders,
|
||||||
|
getNotesInFolder: async (folderId: string) =>
|
||||||
|
notesByFolder[folderId] || [],
|
||||||
|
getNoteTagTitles: async () => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
test("scope: all collects from both disconnected notebooks", async () => {
|
||||||
|
const config = parseCalendarConfig({ scope: "all", notes: "all" });
|
||||||
|
// Host folder is "work"; a normal scan would miss "personal".
|
||||||
|
const result = await collectEvents(adapter, "host", "work", config);
|
||||||
|
expect(result.events.map((e) => e.id).sort()).toEqual(["p1", "w1"]);
|
||||||
|
expect(result.scannedFolders).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user