Compare commits
10 Commits
57eae2b7f8
...
1cc6f184f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cc6f184f0 | ||
|
|
a3d7acde7f | ||
|
|
d3c0f0ee7f | ||
|
|
bbde832b6d | ||
|
|
cdb5ad022c | ||
|
|
5d1173c78e | ||
|
|
3b9e799e86 | ||
|
|
2ec8d5f1c5 | ||
|
|
138fc3e1cb | ||
|
|
360e246262 |
271
README.md
271
README.md
@ -1,180 +1,155 @@
|
|||||||
# GTD Calendar — Project Specification (v1 Draft)
|
# GTD Calendar — a Joplin plugin
|
||||||
|
|
||||||
A fork of [WeMakeMachines/joplin-plugin-event-calendar](https://github.com/WeMakeMachines/joplin-plugin-event-calendar) (MIT) that inverts the plugin's data model: instead of events living as YAML inside the calendar note, the calendar is **populated by real Joplin notes and todos** in the surrounding folder tree, with clickable drilldown to the source note.
|
**Your notes and to-dos, on a calendar, inside a note.**
|
||||||
|
|
||||||
**Working name:** `joplin-plugin-gtd-calendar`
|
GTD Calendar renders day, week, and month calendars in any Joplin note — populated not by hand-maintained YAML, but by the actual notes and to-dos in the surrounding folder. Click an event and it opens the note. Items without a date collect in an **Unscheduled** bucket below the calendar, GTD-style.
|
||||||
**Proposed manifest id:** `com.victorwiebe.joplin.plugin.gtd-calendar` (must differ from upstream's id so both plugins can coexist during development)
|
|
||||||
|
|
||||||
---
|

|
||||||
|
|
||||||
## 1. Concept
|
## How it works
|
||||||
|
|
||||||
A note anywhere in a folder contains a ` ```gtd-calendar ` fenced block. That block renders as a Day/Week/Month calendar populated by notes and todos in scope. Items opt in (by default) via a ` ```gtd ` fenced block in their body. Clicking a calendar event opens the underlying note.
|
Put a `gtd-calendar` fenced code block in a note. That note becomes a calendar of the notes and to-dos around it:
|
||||||
|
|
||||||
Dateless items are not lost — they collect in an **Unscheduled** section below the calendar grid, functioning as a GTD next-actions / someday-maybe bucket.
|
```gtd-calendar
|
||||||
|
view: month-grid
|
||||||
|
title: GTD Calendar
|
||||||
|
todos: all
|
||||||
|
```
|
||||||
|
|
||||||
---
|

|
||||||
|
|
||||||
## 2. Schema Reference
|
Notes and to-dos opt in to the calendar with a `gtd` fenced code block — even an empty one. The block can also set a date (for plain notes), colours, an icon, and hover text:
|
||||||
|
|
||||||
### 2.1 The `gtd-calendar` block (lives in the calendar note)
|
```gtd
|
||||||
|
date: 2026-06-19
|
||||||
|
fg-colour: red
|
||||||
|
text: Seen on hover
|
||||||
|
```
|
||||||
|
|
||||||
```
|

|
||||||
```gtd-calendar
|
|
||||||
view: month # day | week | month (default: day, matching upstream)
|
|
||||||
title: Editorial Schedule
|
|
||||||
scope: this-folder # this-folder | children | <integer depth> (default: this-folder)
|
|
||||||
notes: 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-type: title # title | modified_date (default: title)
|
|
||||||
```
|
|
||||||
|
|
||||||
| Key | Values | Default | Notes |
|
To-dos don't need any of that for scheduling — their normal Joplin **due date** places them on the calendar automatically (when admitted by your `todos:` setting):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Search for **GTD Calendar** in *Tools → Options → Plugins* (desktop).
|
||||||
|
|
||||||
|
Requires Joplin **2.7+** on desktop.
|
||||||
|
|
||||||
|
## The `gtd-calendar` block
|
||||||
|
|
||||||
|
Goes in the note that should display the calendar. All options are optional.
|
||||||
|
|
||||||
|
| Option | Values | Default | What it does |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `view` | `day`, `week`, `month` (also `d`/`w`/`m`, as upstream) | `day` | Grouping granularity of the grid. |
|
| `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` | string | none | Rendered as a heading above the calendar. |
|
| `title` | text | — | 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`, 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. |
|
||||||
| `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 appear: only those with a `gtd` block, every note in scope, or none. |
|
||||||
| `todos` | `gtd-only`, `all`, `none` | `gtd-only` | Same semantics for todos. |
|
| `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 grouping and within Unscheduled. Groupings themselves always run chronologically. |
|
| `sort` | `asc`, `desc` | `asc` | Order of events *within* each day/week/month and in Unscheduled. The timeline itself is always chronological. |
|
||||||
| `sort-type` | `title`, `modified_date` | `title` | Field used by `sort`. `modified_date` maps to Joplin's `updated_time`. |
|
| `sort-type` | `title`, `modified_date` | `title` | What `sort` sorts by. |
|
||||||
|
|
||||||
Unknown keys are ignored with a console warning. A malformed block renders an inline error box rather than failing silently (improvement over upstream, which swallows parse errors into the console).
|
Invalid values fall back to defaults and show a ⚠ warning above the calendar.
|
||||||
|
|
||||||
### 2.2 The `gtd` block (lives in participating notes/todos)
|
## The `gtd` block
|
||||||
|
|
||||||
```
|
Goes in any note or to-do that should appear on the calendar. **An empty block is valid** — it's the minimal opt-in. All properties are optional:
|
||||||
```gtd
|
|
||||||
date: 2026-07-04 # required for notes to be scheduled; optional override for todos
|
|
||||||
bg-colour: teal # any CSS color value
|
|
||||||
fg-colour: white
|
|
||||||
icon: 🦴
|
|
||||||
title: Override title # optional; defaults to the note's own title
|
|
||||||
text: Hover text # optional; tooltip detail, as upstream
|
|
||||||
```
|
|
||||||
|
|
||||||
| Key | Applies to | Behavior |
|
| Property | Example | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `date` | notes | Required for the item to appear **in the grid**; without it the note goes to Unscheduled. Accepts `yyyy-mm-dd` (and `mm-dd-yyyy` for upstream parity). |
|
| `date` | `2026-07-04` | Schedules the item (`yyyy-mm-dd` or `mm-dd-yyyy`). Required for plain notes to appear in the grid; for to-dos it *overrides* the Joplin due date. |
|
||||||
| `date` | todos | Optional. If present, **overrides** `todo_due` (explicit beats implicit). |
|
| `bg-colour` | `teal`, `#264653` | Tile/chip background. Any CSS colour. (`bgColor` also accepted.) Without it, the item gets a stable pastel derived from its note ID. |
|
||||||
| `bg-colour` / `bgColor` | both | Event tile background. Both spellings accepted; `bg-colour` is canonical for this fork. |
|
| `fg-colour` | `white` | Tile/chip text colour. |
|
||||||
| `fg-colour` | both | Event tile text color (new vs. upstream). |
|
| `icon` | `🦴` | Shown on the tile/chip instead of the first two characters of the title. |
|
||||||
| `icon` | both | Emoji/short string, as upstream. Defaults: type indicator (see §4). |
|
| `title` | `Launch day` | Replaces the note's title on the calendar only. |
|
||||||
| `title` | both | Overrides the note title on the tile. |
|
| `text` | `Seen on hover` | Detail line in the hover card / tooltip. |
|
||||||
| `text` | both | Tooltip body. |
|
|
||||||
|
|
||||||
An **empty** ` ```gtd ` block is valid and meaningful: it is the opt-in signal with all defaults.
|
### Where do dates come from?
|
||||||
|
|
||||||
---
|
1. A `date:` in the `gtd` block always wins.
|
||||||
|
2. Otherwise, a to-do uses its Joplin **due date**.
|
||||||
|
3. No date at all → the item appears in the **Unscheduled** section below the calendar: your dateless next-actions, always in view, one click from their notes.
|
||||||
|
|
||||||
## 3. Data Model Rules
|
To-dos keep being real Joplin to-dos throughout — due dates still sort, alarm, and sync exactly as before. Completed to-dos render checked and struck through.
|
||||||
|
|
||||||
### 3.1 Date resolution
|
## Repeating to-dos
|
||||||
|
|
||||||
1. **Todos:** `todo_due` from the Joplin API. A `date:` in the `gtd` block overrides it.
|
GTD Calendar plays nicely with the **[Repeating To-Dos](https://joplinapp.org/plugins/plugin/com.github.TheScriptingGuy.joplin-plugin-repeating-todos/)** plugin (v2). Install it, set a to-do to repeat (e.g. every Tuesday), and that to-do appears on the calendar at its next due date like any other — no extra setup. Recurring to-dos are marked with a ↻ symbol so you can tell them apart at a glance.
|
||||||
2. **Notes:** `date:` from the `gtd` block only. No fallback to created/updated time — those are not event dates and would produce misleading calendars.
|
|
||||||
3. **No resolvable date** → item is rendered in the **Unscheduled** section below the grid.
|
|
||||||
|
|
||||||
### 3.2 Inclusion matrix
|
Because that plugin keeps a recurring to-do as a single item at its *next* due date, the calendar shows the next occurrence rather than every future one. Recurrence is detected via the `recurring` tag the plugin maintains; if you use a different recurrence tool that tags its to-dos `recurring`, the ↻ will appear for those too.
|
||||||
|
|
||||||
An item appears on the calendar iff its type's mode admits it:
|
## Kanban board
|
||||||
|
|
||||||
| Mode | Has `gtd` block | No `gtd` block |
|
Alongside calendars, GTD Calendar can render a **kanban board** from your to-dos. Add a `gtd-kanban` block — on its own, or stacked beneath a calendar in the same dashboard note:
|
||||||
|---|---|---|
|
|
||||||
| `gtd-only` (default) | ✅ included | ❌ excluded |
|
|
||||||
| `all` | ✅ included | ✅ included (default styling) |
|
|
||||||
| `none` | ❌ excluded | ❌ excluded |
|
|
||||||
|
|
||||||
The calendar note itself is always excluded from results, as is any note whose only `gtd-calendar` block makes it a calendar (a note may, however, be both an event and contain a calendar if it has both block types — edge case, supported, documented).
|
|
||||||
|
|
||||||
### 3.3 Completed todos
|
|
||||||
|
|
||||||
**v1 decision:** completed todos (`todo_completed > 0`) render with strikethrough + checked indicator, and a future `completed: show | hide` calendar option is reserved. (Logged as decision D3 below; cheap to change.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Rendering
|
|
||||||
|
|
||||||
- Reuses upstream's grouping/renderer architecture (`DayGrouping`/`WeekGrouping`/`MonthGrouping` + DOM renderers + CSS asset), including the empty-grouping placeholders and current-date highlight.
|
|
||||||
- **Fixes upstream bug:** `Calendar/index.ts` imports `WeekGrouping` from `Month/MonthGrouping`, so the week view silently groups by month. Corrected in the fork.
|
|
||||||
- **Type distinction:** todos render with a checkbox glyph (☐ / ☑ when completed); notes render with a document glyph. Glyphs are suppressed if the item supplies its own `icon`. A subtle border-style difference (solid vs. dashed, TBD in CSS) keeps the distinction visible under custom colours.
|
|
||||||
- **Unscheduled section:** pinned below the grid, with its own header, honoring `sort` / `sort-type`.
|
|
||||||
- **Drilldown:** each tile is clickable and opens the source note.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Architecture
|
|
||||||
|
|
||||||
Upstream is a single synchronous markdown-it content script with no data API access. The fork becomes a three-part plugin:
|
|
||||||
|
|
||||||
|
````
|
||||||
|
```gtd-kanban
|
||||||
|
title: Editorial Board
|
||||||
|
scope: this-folder
|
||||||
|
todos: all
|
||||||
|
sort-type: due-date
|
||||||
|
in-progress-tag: in-progress
|
||||||
|
card-detail: hover
|
||||||
|
done-window: 7
|
||||||
```
|
```
|
||||||
┌─────────────────────────────┐
|
````
|
||||||
│ 1. Markdown-it content │ Intercepts ```gtd-calendar fences.
|
|
||||||
│ script (renderer) │ Parses YAML config, emits a placeholder
|
Three columns, driven by your to-dos' state:
|
||||||
│ │ <div> carrying the config as data-attrs.
|
|
||||||
└──────────────┬──────────────┘
|
- **Backlog** — every uncompleted to-do without the in-progress tag.
|
||||||
│ (rendered HTML)
|
- **In Progress** — to-dos carrying the in-progress tag (default tag name `in-progress`; configurable).
|
||||||
┌──────────────▼──────────────┐
|
- **Done** — completed to-dos, limited to those finished within `done-window` days (default 7; set `all` for the full history).
|
||||||
│ 2. Webview asset script │ Finds placeholders, calls
|
|
||||||
│ (runs in rendered note) │ webviewApi.postMessage(config).
|
A completed to-do always lands in **Done**, even if it still carries the in-progress tag.
|
||||||
│ │ Receives event data, builds the
|
|
||||||
│ │ calendar DOM via the (reused)
|
| Option | Values | Default | Description |
|
||||||
│ │ grouping/renderer classes.
|
|---|---|---|---|
|
||||||
│ │ Click → postMessage({open: noteId}).
|
| `title` | text | — | Heading above the board. |
|
||||||
└──────────────┬──────────────┘
|
| `scope` | `this-folder`, `children`, integer | `this-folder` | Same folder-scanning rules as the calendar. |
|
||||||
│ postMessage / onMessage
|
| `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. |
|
||||||
│ 3. Main plugin process │ joplin.contentScripts.onMessage:
|
| `sort` | `asc`, `desc` | `asc` | Sort direction. |
|
||||||
│ (index.ts) │ • resolve current note's folder
|
| `in-progress-tag` | text | `in-progress` | The tag that places a to-do in the In Progress column. |
|
||||||
│ │ (joplin.workspace.selectedNote)
|
| `card-detail` | `hover`, `always`, `none` | `hover` | Whether each card's due date / hover text shows on hover, always, or never. |
|
||||||
│ │ • build folder tree, apply scope
|
| `done-window` | integer or `all` | `7` | How many days back the Done column reaches. |
|
||||||
│ │ • fetch notes/todos (id, title,
|
|
||||||
│ │ is_todo, todo_due, todo_completed,
|
Cards are compact (title, recurrence ↻ if applicable) and click through to the to-do. The board is **read-only** in this version — it reflects your to-dos' state but doesn't change it. (Drag-and-drop to move cards between columns is planned.)
|
||||||
│ │ updated_time, body)
|
|
||||||
│ │ • parse ```gtd blocks from bodies
|
## Drilldown
|
||||||
│ │ • apply inclusion matrix, return
|
|
||||||
│ │ structured event list
|
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.
|
||||||
│ │ • handle open: joplin.commands
|
|
||||||
│ │ .execute('openNote', noteId)
|
## Limitations
|
||||||
└─────────────────────────────┘
|
|
||||||
|
- **Desktop only.** See the note below.
|
||||||
|
- `month-grid` always shows the current month; there is no month navigation yet.
|
||||||
|
- The calendar refreshes when the note re-renders (e.g. switching notes); edits to *other* notes don't live-update an open calendar.
|
||||||
|
|
||||||
|
### Mobile / Android
|
||||||
|
|
||||||
|
This plugin is built for **Joplin desktop** and is not expected to work on the Android or iOS apps. It depends on rendering custom code blocks in the note viewer and on two-way messaging between that view and the plugin — capabilities Joplin's mobile apps have historically not supported, or supported only partially. On mobile, a `gtd-calendar` block will most likely render as plain text or not at all, and drilldown won't work. The companion *Repeating To-Dos* plugin is desktop-only as well.
|
||||||
|
|
||||||
|
Your data is unaffected, though: the calendar is only a *view*. Your to-dos and their due dates are ordinary Joplin items that sync to mobile normally and appear in Joplin's built-in to-do list and search, with alarms intact — you just won't see the calendar rendering there. If you want a quick scheduled-items view on mobile, pair the calendar with Joplin's native to-do view or the *Embed Search* plugin on desktop.
|
||||||
|
|
||||||
|
## Bugs & feedback
|
||||||
|
|
||||||
|
Issues and ideas: [the repository](https://gitea.skeletonworks.online/vwiebe/joplin-plugin-gtd-calendar/issues), or find me on the Joplin forum.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm test # 47 unit tests
|
||||||
|
npm run dist # builds publish/*.jpl
|
||||||
```
|
```
|
||||||
|
|
||||||
### Performance notes
|
Design notes and the full v1 specification live in [SPEC.md](SPEC.md).
|
||||||
|
|
||||||
- Folder tree and note list fetched per render request; paginated API calls (`page`/`has_more`).
|
## Credits & license
|
||||||
- Body scanning is limited to items in scope. For large scopes, pre-filter with Joplin's search API (`body:"```gtd"`-style query) before fetching full bodies. Cache by `updated_time` if needed; not a v1 requirement.
|
|
||||||
- Refresh model: re-render occurs on note switch (Joplin behavior). `joplin.workspace.onNoteChange` live refresh is a stretch goal, not v1.
|
|
||||||
|
|
||||||
### Platform caveats
|
A fork of [Event Calendar](https://github.com/WeMakeMachines/joplin-plugin-event-calendar) by **Franco Speziali** (WeMakeMachines), whose tile-strip design this plugin proudly keeps. Forked with gratitude under the MIT license; this plugin is MIT as well.
|
||||||
|
|
||||||
- Desktop is the target. Mobile plugin support is partial; `openNote` behavior there is unverified. The "embed search" pairing remains a documented fallback for mobile users.
|
Made by [Victor Wiebe](https://victorwiebe.com). Creative work for an uncaring world.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Implementation Phases
|
|
||||||
|
|
||||||
1. **Phase 0 — Scaffolding.** Fork, rename (manifest id, plugin name, file names), build pipeline verified (`npm install && npm run dist`), upstream week-view bug fixed, upstream tests green.
|
|
||||||
2. **Phase 1 — Plumbing.** Placeholder rendering for `gtd-calendar` fences; webview ⇄ plugin message round-trip proven with a hardcoded payload.
|
|
||||||
3. **Phase 2 — Data layer.** Folder tree + scope resolution; note/todo fetching; `gtd` block parsing; inclusion matrix; date resolution; unit tests for all of it (the upstream Jest setup carries over).
|
|
||||||
4. **Phase 3 — Rendering.** Wire real data into the reused renderers; Unscheduled section; type glyphs; fg-colour support; sort options.
|
|
||||||
5. **Phase 4 — Drilldown & polish.** Click-to-open; error boxes for malformed YAML; docs/README; scope-performance warnings.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Decision Log / Open Items
|
|
||||||
|
|
||||||
| # | Decision | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| D1 | Defaults: `notes: gtd-only`, `todos: gtd-only` | ⚠️ **Confirm** — earlier discussion said todos are *always* included (i.e. `todos: all` default); the final message specified `gtd-only` as default for both. Spec follows the final message. |
|
|
||||||
| D2 | `gtd` `date:` overrides `todo_due` (vs. emitting a warning) | Adopted; revisit if confusing in practice. |
|
|
||||||
| D3 | Completed todos: shown struck-through in v1; `completed:` option reserved | Proposed, unconfirmed. |
|
|
||||||
| D4 | `sort`/`sort-type` act within groupings only; grid is always chronological | Confirmed. |
|
|
||||||
| D5 | Colour key spelling: `bg-colour`/`fg-colour` canonical, `bgColor` accepted for upstream parity | Proposed. |
|
|
||||||
| D6 | Future options parked: `completed:`, tag-based filtering, week-start day, live refresh | Backlog. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Licensing & Attribution
|
|
||||||
|
|
||||||
Upstream is MIT (© Franco Speziali / WeMakeMachines). The fork retains the MIT license, preserves the original copyright notice, and credits upstream in the README per standard practice.
|
|
||||||
|
|||||||
74
SPEC.md
74
SPEC.md
@ -21,7 +21,7 @@ Dateless items are not lost — they collect in an **Unscheduled** section below
|
|||||||
|
|
||||||
```
|
```
|
||||||
```gtd-calendar
|
```gtd-calendar
|
||||||
view: month # day | week | month (default: day, matching upstream)
|
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> (default: this-folder)
|
||||||
notes: gtd-only # gtd-only | all | none (default: gtd-only)
|
notes: gtd-only # gtd-only | all | none (default: gtd-only)
|
||||||
@ -32,7 +32,7 @@ sort-type: title # title | modified_date (default: title)
|
|||||||
|
|
||||||
| Key | Values | Default | Notes |
|
| Key | Values | Default | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `view` | `day`, `week`, `month` (also `d`/`w`/`m`, as upstream) | `day` | Grouping granularity of the grid. |
|
| `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`, 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. |
|
||||||
| `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). |
|
||||||
@ -177,4 +177,72 @@ Upstream is a single synchronous markdown-it content script with no data API acc
|
|||||||
|
|
||||||
## 8. Licensing & Attribution
|
## 8. Licensing & Attribution
|
||||||
|
|
||||||
Upstream is MIT (© Franco Speziali / WeMakeMachines). The fork retains the MIT license, preserves the original copyright notice, and credits upstream in the README per standard practice.
|
Upstream is MIT (© Franco Speziali / WeMakeMachines). The fork retains the MIT license, preserves the original copyright notice, and credits upstream in the README per standard practice.
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Roadmap (post-1.0 / post-launch)
|
||||||
|
|
||||||
|
Recorded direction, not commitments. Each gets a focused design pass before work begins; real-world use of the shipped plugin may reorder this.
|
||||||
|
|
||||||
|
### v0.2.0 — Repeating to-dos (visualisation) + configurable strip horizon
|
||||||
|
|
||||||
|
Recurrence itself is delegated to an existing community plugin (open-environment approach; we don't reimplement it). Candidates evaluated: BeatLink's original *Repeating To-Dos* (unmaintained), the *[Revived]* fork (maintained, GitHub issues only), and *Repeating To-dos v2* by TheScriptingGuy (`com.github.TheScriptingGuy.joplin-plugin-repeating-todos`, best-documented). All share the same mechanic: on completion the to-do's alarm date is reset to the next occurrence and the to-do is unmarked — so at any moment a recurring to-do is a **single** to-do at its next due date, with the recurrence rule stored in the note body.
|
||||||
|
|
||||||
|
- **Option A (shipped in 0.2.0):** detect recurrence and mark those events with a ↻ glyph. *Implementation note:* v2 of the plugin stores recurrence in Joplin's `userData` (not the note body) and maintains a `recurring` **tag** as its query index — so detection is by tag presence on to-dos (constant `RECURRING_TAG`), not body-scanning. Cleaner than the originally-planned body parse, and reuses the tag-fetching the kanban work will need. No series projection — the to-do shows at its next due date.
|
||||||
|
- **Option B (in pocket):** parse the recurrence rule and project all future occurrences across the visible window. Deferred because the body format has proven to be a moving target across forks; promote to a 0.2.x only if Option A's single-occurrence view proves insufficient, and target a specific fork's format at that time.
|
||||||
|
- **README:** recommend the maintained fork (v2 first, Revived as fallback); warn about the known "recurrence block injected into all notes" failure mode; note coexistence (their plugin moves the anchor, ours draws the marker).
|
||||||
|
|
||||||
|
**Configurable strip horizon.** `day`/`week`/`month` strip views currently span first-event to last-event (plus today). Add a horizon option to `gtd-calendar` (working name `weeks:` or `horizon:`, default ~8 weeks forward) controlling how far ahead the strip extends — relevant both generally and for how many future occurrences Option B would draw. **`month-grid` is explicitly excluded**: it always shows the entire current calendar month, by design, and that is not configurable.
|
||||||
|
|
||||||
|
*Note: multiple `gtd-calendar` blocks in one note already work as of 0.1.0 (each fence renders independently) — e.g. a "week ahead" strip plus a "month grid" in a single dashboard note. No work required; document as a recommended pattern.*
|
||||||
|
|
||||||
|
### v0.3.0 — Kanban board (read-only) — SHIPPED
|
||||||
|
|
||||||
|
Decision: a **second block type within this plugin**, not a separate plugin. Rationale: a kanban needs the entire existing data layer (`folderScope`, `collectEvents` inclusion matrix, `getNoteTagTitles`, config parsing, recurrence detection, webview messaging); a separate plugin would duplicate all of it or require a shared-library split whose overhead isn't justified for "three tag-driven columns." One plugin also serves the intended use — a calendar and a kanban in one dashboard note — with a single install and a unified config/tag vocabulary.
|
||||||
|
|
||||||
|
**The `gtd-kanban` block.** Parallel to `gtd-calendar`; lives in its own note or stacked beneath a calendar (multiple blocks already render independently).
|
||||||
|
|
||||||
|
```
|
||||||
|
```gtd-kanban
|
||||||
|
title: Editorial Board
|
||||||
|
scope: this-folder # this-folder | children | N — same semantics as gtd-calendar
|
||||||
|
todos: gtd-only # gtd-only (default) | all | none
|
||||||
|
sort-type: due-date # due-date (default) | title | modified-date
|
||||||
|
sort: asc # asc | desc
|
||||||
|
in-progress-tag: in-progress # configurable; default "in-progress"
|
||||||
|
card-detail: hover # hover (default) | always | none
|
||||||
|
done-window: 7 # days; integer or "all"; default 7
|
||||||
|
```
|
||||||
|
|
||||||
|
**Columns (always three, always shown even when empty):** Backlog · In Progress · Done.
|
||||||
|
|
||||||
|
**Bucketing rules:**
|
||||||
|
- **Done wins.** Any to-do with `todo_completed > 0` → *Done*, regardless of tags.
|
||||||
|
- Else, carrying the in-progress tag → *In Progress*.
|
||||||
|
- Else → *Backlog*.
|
||||||
|
- **Backlog shows everything** uncompleted and untagged — dated or not, near or far. (Acknowledged: can grow long; compact cards mitigate.)
|
||||||
|
|
||||||
|
**Sorting (within each column):**
|
||||||
|
- `sort-type: due-date | title | modified-date`, with `sort: asc | desc`.
|
||||||
|
- Under `due-date`, to-dos **without** a due date sort *after* all dated ones.
|
||||||
|
|
||||||
|
**Done column window:** shows only to-dos completed within `done-window` days (default 7); `all` shows the full history. Prevents unbounded growth.
|
||||||
|
|
||||||
|
**Cards:** compact by default — title, due date, ↻ if recurring, click-to-open. `card-detail` controls the hover panel: `hover` (default), `always` (details inline), `none` (no panel). Notes are excluded entirely (to-dos only).
|
||||||
|
|
||||||
|
**Read-only in 0.3.0:** reflects state, never mutates it. Proves columns/bucketing cheaply before write-back.
|
||||||
|
|
||||||
|
**Architecture:** reuses the calendar's pipeline. New `gtd-kanban` markdown-it fence → placeholder → webview requests data → plugin runs scope + inclusion + tag fetch (already built) + bucketing → returns three columns → webview renders. `IN_PROGRESS_TAG` constant mirrors `RECURRING_TAG`, exposed via `in-progress-tag:` config from the start.
|
||||||
|
|
||||||
|
**`sort-type` vocabulary reconciliation:** the calendar block's `sort-type` (currently `title | modified_date`) is unified toward `due-date | title | modified-date` across both blocks so they read consistently. For the calendar, `due-date` is accepted but falls back to chronological where less meaningful. (Note the spelling shift `modified_date` → `modified-date` for cross-block consistency; the old spelling stays accepted as an alias to avoid breaking existing calendars.)
|
||||||
|
|
||||||
|
### v0.4.0 — Drag-and-drop kanban (write-back)
|
||||||
|
|
||||||
|
The first feature that makes the plugin a **controller** of data rather than a viewer — warrants its own design pass.
|
||||||
|
|
||||||
|
- Dragging a card writes back to Joplin via the existing webview→plugin channel:
|
||||||
|
- → *In Progress*: add the `in-progress` tag (create the tag if absent; resolve its id; associate).
|
||||||
|
- → *Done*: set `todo_completed`.
|
||||||
|
- → *Backlog*: remove the tag / clear completion.
|
||||||
|
- **Open design questions for that pass:** optimistic UI vs. wait-for-confirmation; behaviour on write failure; whether drops need undo; re-query/refresh of kanban and any affected calendars after a successful write; the trust implications of a rendered view mutating other notes.
|
||||||
|
- Feasibility: confirmed possible (we already execute `openNote` from the webview; data writes use the same channel with more responsibility). Complexity lives in tag management and the write/refresh cycle, not the dragging.
|
||||||
|
|||||||
BIN
docs/screenshots/gtd-block.png
Normal file
BIN
docs/screenshots/gtd-block.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/screenshots/month-grid-config.png
Normal file
BIN
docs/screenshots/month-grid-config.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
docs/screenshots/month-grid.png
Normal file
BIN
docs/screenshots/month-grid.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/screenshots/todo-due-date.png
Normal file
BIN
docs/screenshots/todo-due-date.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "joplin-plugin-gtd-calendar",
|
"name": "joplin-plugin-gtd-calendar",
|
||||||
"version": "0.1.0",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "joplin-plugin-gtd-calendar",
|
"name": "joplin-plugin-gtd-calendar",
|
||||||
"version": "0.1.0",
|
"version": "0.3.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "joplin-plugin-gtd-calendar",
|
"name": "joplin-plugin-gtd-calendar",
|
||||||
"version": "0.1.0",
|
"version": "0.3.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"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",
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { CalendarConfig, DataAdapter, GtdEvent, RawNote } from "./types";
|
import {
|
||||||
|
CalendarConfig,
|
||||||
|
DataAdapter,
|
||||||
|
GtdEvent,
|
||||||
|
RawNote,
|
||||||
|
RECURRING_TAG,
|
||||||
|
} from "./types";
|
||||||
import resolveScopedFolderIds from "./folderScope";
|
import resolveScopedFolderIds from "./folderScope";
|
||||||
import extractGtdBlock from "./gtdBlock";
|
import extractGtdBlock from "./gtdBlock";
|
||||||
import { resolveEventDate } from "./resolveDate";
|
import { resolveEventDate } from "./resolveDate";
|
||||||
@ -44,7 +50,7 @@ export default async function collectEvents(
|
|||||||
scannedNotes += 1;
|
scannedNotes += 1;
|
||||||
if (note.id === calendarNoteId) continue;
|
if (note.id === calendarNoteId) continue;
|
||||||
|
|
||||||
const event = noteToEvent(note, config, warnings);
|
const event = await noteToEvent(adapter, note, config, warnings);
|
||||||
if (event) events.push(event);
|
if (event) events.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -59,11 +65,12 @@ export default async function collectEvents(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function noteToEvent(
|
async function noteToEvent(
|
||||||
|
adapter: DataAdapter,
|
||||||
note: RawNote,
|
note: RawNote,
|
||||||
config: CalendarConfig,
|
config: CalendarConfig,
|
||||||
warnings: string[]
|
warnings: string[]
|
||||||
): GtdEvent | null {
|
): Promise<GtdEvent | null> {
|
||||||
const mode = note.is_todo ? config.todos : config.notes;
|
const mode = note.is_todo ? config.todos : config.notes;
|
||||||
if (mode === "none") return null;
|
if (mode === "none") return null;
|
||||||
|
|
||||||
@ -81,6 +88,13 @@ function noteToEvent(
|
|||||||
warnings.push(`"${note.title}": unparseable date "${block.date}"`);
|
warnings.push(`"${note.title}": unparseable date "${block.date}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only to-dos can recur; skip the tag lookup for plain notes.
|
||||||
|
let isRecurring = false;
|
||||||
|
if (note.is_todo) {
|
||||||
|
const tags = await adapter.getNoteTagTitles(note.id);
|
||||||
|
isRecurring = tags.includes(RECURRING_TAG);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: note.id,
|
id: note.id,
|
||||||
title: block && block.title ? block.title : note.title,
|
title: block && block.title ? block.title : note.title,
|
||||||
@ -92,6 +106,7 @@ function noteToEvent(
|
|||||||
icon: block ? block.icon : null,
|
icon: block ? block.icon : null,
|
||||||
text: block ? block.text : null,
|
text: block ? block.text : null,
|
||||||
updatedTime: note.updated_time,
|
updatedTime: note.updated_time,
|
||||||
|
isRecurring,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
152
src/Gtd/collectKanban.ts
Normal file
152
src/Gtd/collectKanban.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import {
|
||||||
|
DataAdapter,
|
||||||
|
KanbanBoard,
|
||||||
|
KanbanCard,
|
||||||
|
KanbanConfig,
|
||||||
|
RawNote,
|
||||||
|
RECURRING_TAG,
|
||||||
|
} from "./types";
|
||||||
|
import resolveScopedFolderIds from "./folderScope";
|
||||||
|
import extractGtdBlock from "./gtdBlock";
|
||||||
|
import { resolveEventDate } from "./resolveDate";
|
||||||
|
|
||||||
|
export interface KanbanResult {
|
||||||
|
board: KanbanBoard;
|
||||||
|
warnings: string[];
|
||||||
|
scannedFolders: number;
|
||||||
|
scannedNotes: number;
|
||||||
|
cardCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a three-column kanban board from to-dos in scope.
|
||||||
|
*
|
||||||
|
* - Notes are excluded entirely (to-dos only).
|
||||||
|
* - Bucketing: Done wins (todo_completed > 0); else in-progress tag;
|
||||||
|
* else Backlog.
|
||||||
|
* - Done column filtered to the done-window (days).
|
||||||
|
* - Each column sorted per config.
|
||||||
|
*
|
||||||
|
* The kanban note itself is always excluded.
|
||||||
|
*/
|
||||||
|
export default async function collectKanban(
|
||||||
|
adapter: DataAdapter,
|
||||||
|
kanbanNoteId: string,
|
||||||
|
kanbanFolderId: string,
|
||||||
|
config: KanbanConfig,
|
||||||
|
now: Date = new Date()
|
||||||
|
): Promise<KanbanResult> {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
const folders = await adapter.getFolders();
|
||||||
|
const folderIds = resolveScopedFolderIds(
|
||||||
|
folders,
|
||||||
|
kanbanFolderId,
|
||||||
|
config.scopeDepth
|
||||||
|
);
|
||||||
|
|
||||||
|
let scannedNotes = 0;
|
||||||
|
const backlog: KanbanCard[] = [];
|
||||||
|
const inProgress: KanbanCard[] = [];
|
||||||
|
const done: KanbanCard[] = [];
|
||||||
|
|
||||||
|
const doneCutoff =
|
||||||
|
config.doneWindow === Infinity
|
||||||
|
? -Infinity
|
||||||
|
: now.getTime() - config.doneWindow * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
for (const folderId of folderIds) {
|
||||||
|
const notes = await adapter.getNotesInFolder(folderId);
|
||||||
|
for (const note of notes) {
|
||||||
|
scannedNotes += 1;
|
||||||
|
if (note.id === kanbanNoteId) continue;
|
||||||
|
if (!note.is_todo) continue; // to-dos only
|
||||||
|
|
||||||
|
if (config.todos === "none") continue;
|
||||||
|
|
||||||
|
const result = extractGtdBlock(note.body);
|
||||||
|
if (config.todos === "gtd-only" && !result.found) continue;
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
warnings.push(
|
||||||
|
`"${note.title}": gtd block problem — ${result.error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = await adapter.getNoteTagTitles(note.id);
|
||||||
|
const card = buildCard(note, result.block, tags);
|
||||||
|
|
||||||
|
// Bucketing — Done wins.
|
||||||
|
if (card.completed) {
|
||||||
|
if (card.completedTime >= doneCutoff) done.push(card);
|
||||||
|
// completed but outside the window: dropped from the board
|
||||||
|
} else if (tags.includes(config.inProgressTag)) {
|
||||||
|
inProgress.push(card);
|
||||||
|
} else {
|
||||||
|
backlog.push(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sortColumn(backlog, config);
|
||||||
|
sortColumn(inProgress, config);
|
||||||
|
sortColumn(done, config);
|
||||||
|
|
||||||
|
return {
|
||||||
|
board: { backlog, inProgress, done },
|
||||||
|
warnings,
|
||||||
|
scannedFolders: folderIds.length,
|
||||||
|
scannedNotes,
|
||||||
|
cardCount: backlog.length + inProgress.length + done.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCard(
|
||||||
|
note: RawNote,
|
||||||
|
block: ReturnType<typeof extractGtdBlock>["block"],
|
||||||
|
tags: string[]
|
||||||
|
): KanbanCard {
|
||||||
|
return {
|
||||||
|
id: note.id,
|
||||||
|
title: block && block.title ? block.title : note.title,
|
||||||
|
date: resolveEventDate(note, block),
|
||||||
|
completed: note.todo_completed > 0,
|
||||||
|
completedTime: note.todo_completed || 0,
|
||||||
|
isRecurring: tags.includes(RECURRING_TAG),
|
||||||
|
bgColour: block ? block.bgColour : null,
|
||||||
|
fgColour: block ? block.fgColour : null,
|
||||||
|
icon: block ? block.icon : null,
|
||||||
|
text: block ? block.text : null,
|
||||||
|
updatedTime: note.updated_time,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortColumn(cards: KanbanCard[], config: KanbanConfig): void {
|
||||||
|
const direction = config.sort === "desc" ? -1 : 1;
|
||||||
|
|
||||||
|
cards.sort((a, b) => {
|
||||||
|
let comparison: number;
|
||||||
|
|
||||||
|
if (config.sortType === "due-date") {
|
||||||
|
// Dateless cards always sort after dated ones, regardless of
|
||||||
|
// direction.
|
||||||
|
const aHas = a.date !== null;
|
||||||
|
const bHas = b.date !== null;
|
||||||
|
if (aHas && !bHas) return -1;
|
||||||
|
if (!aHas && bHas) return 1;
|
||||||
|
if (!aHas && !bHas) {
|
||||||
|
comparison = 0;
|
||||||
|
} else {
|
||||||
|
comparison = (a.date as string).localeCompare(b.date as string);
|
||||||
|
}
|
||||||
|
} else if (config.sortType === "modified-date") {
|
||||||
|
comparison = a.updatedTime - b.updatedTime;
|
||||||
|
} else {
|
||||||
|
comparison = a.title.localeCompare(b.title, undefined, {
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return comparison * direction;
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,5 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
|
startOfMonth,
|
||||||
|
endOfMonth,
|
||||||
|
startOfWeek,
|
||||||
|
endOfWeek,
|
||||||
addWeeks,
|
addWeeks,
|
||||||
addMonths,
|
addMonths,
|
||||||
differenceInCalendarDays,
|
differenceInCalendarDays,
|
||||||
@ -27,9 +31,29 @@ export interface CalendarGroup {
|
|||||||
events: GtdEvent[];
|
events: GtdEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MonthGridCell {
|
||||||
|
dateISO: string;
|
||||||
|
dayNumber: number;
|
||||||
|
/** False for leading/trailing days belonging to adjacent months. */
|
||||||
|
inMonth: boolean;
|
||||||
|
isToday: boolean;
|
||||||
|
events: GtdEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthGrid {
|
||||||
|
/** Localised short weekday names, one per column. */
|
||||||
|
weekdays: string[];
|
||||||
|
cells: MonthGridCell[];
|
||||||
|
/** e.g. "June 2026". */
|
||||||
|
monthLabel: string;
|
||||||
|
/** Scheduled events falling outside the displayed window. */
|
||||||
|
outsideCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupedCalendar {
|
export interface GroupedCalendar {
|
||||||
groups: CalendarGroup[];
|
groups: CalendarGroup[];
|
||||||
unscheduled: GtdEvent[];
|
unscheduled: GtdEvent[];
|
||||||
|
grid: MonthGrid | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse canonical yyyy-mm-dd as a local Date. */
|
/** Parse canonical yyyy-mm-dd as a local Date. */
|
||||||
@ -55,7 +79,9 @@ interface ViewStrategy {
|
|||||||
label(date: Date): { text: string; primary: boolean };
|
label(date: Date): { text: string; primary: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
const STRATEGIES: Record<CalendarConfig["view"], ViewStrategy> = {
|
type StripView = "day" | "week" | "month";
|
||||||
|
|
||||||
|
const STRATEGIES: Record<StripView, ViewStrategy> = {
|
||||||
day: {
|
day: {
|
||||||
diff: differenceInCalendarDays,
|
diff: differenceInCalendarDays,
|
||||||
add: addDays,
|
add: addDays,
|
||||||
@ -119,8 +145,16 @@ export default function groupEvents(
|
|||||||
const scheduled = events.filter((event) => event.date !== null);
|
const scheduled = events.filter((event) => event.date !== null);
|
||||||
const unscheduled = events.filter((event) => event.date === null);
|
const unscheduled = events.filter((event) => event.date === null);
|
||||||
|
|
||||||
|
if (view === "month-grid") {
|
||||||
|
return {
|
||||||
|
groups: [],
|
||||||
|
unscheduled,
|
||||||
|
grid: buildMonthGrid(scheduled, today),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (scheduled.length === 0) {
|
if (scheduled.length === 0) {
|
||||||
return { groups: [], unscheduled };
|
return { groups: [], unscheduled, grid: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const strategy = STRATEGIES[view];
|
const strategy = STRATEGIES[view];
|
||||||
@ -152,5 +186,62 @@ export default function groupEvents(
|
|||||||
groups[index].events.push(event);
|
groups[index].events.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { groups, unscheduled };
|
return { groups, unscheduled, grid: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a classic 7-column grid covering the entire current calendar
|
||||||
|
* month (padded to full weeks with dimmed adjacent-month days).
|
||||||
|
*/
|
||||||
|
function buildMonthGrid(scheduled: GtdEvent[], today: Date): MonthGrid {
|
||||||
|
const windowStart = startOfWeek(startOfMonth(today));
|
||||||
|
const windowEnd = endOfWeek(endOfMonth(today));
|
||||||
|
|
||||||
|
const cells: MonthGridCell[] = [];
|
||||||
|
const cellIndexByISO = new Map<string, number>();
|
||||||
|
|
||||||
|
for (
|
||||||
|
let date = windowStart;
|
||||||
|
date <= windowEnd;
|
||||||
|
date = addDays(date, 1)
|
||||||
|
) {
|
||||||
|
const iso = localDateToISO(date);
|
||||||
|
cellIndexByISO.set(iso, cells.length);
|
||||||
|
cells.push({
|
||||||
|
dateISO: iso,
|
||||||
|
dayNumber: date.getDate(),
|
||||||
|
inMonth: isSameMonth(date, today),
|
||||||
|
isToday: isSameDay(date, today),
|
||||||
|
events: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let outsideCount = 0;
|
||||||
|
for (const event of scheduled) {
|
||||||
|
const index = cellIndexByISO.get(event.date!);
|
||||||
|
if (index === undefined) {
|
||||||
|
outsideCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cells[index].events.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdays: string[] = [];
|
||||||
|
for (let i = 0; i < 7; i += 1) {
|
||||||
|
weekdays.push(
|
||||||
|
addDays(windowStart, i).toLocaleDateString(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekdays,
|
||||||
|
cells,
|
||||||
|
monthLabel: today.toLocaleDateString(undefined, {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}),
|
||||||
|
outsideCount,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ const VIEW_ALIASES: Record<string, CalendarConfig["view"]> = {
|
|||||||
w: "week",
|
w: "week",
|
||||||
month: "month",
|
month: "month",
|
||||||
m: "month",
|
m: "month",
|
||||||
|
"month-grid": "month-grid",
|
||||||
|
grid: "month-grid",
|
||||||
};
|
};
|
||||||
|
|
||||||
const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"];
|
const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"];
|
||||||
@ -28,6 +30,8 @@ export default function parseCalendarConfig(raw: any): CalendarConfig {
|
|||||||
"todos",
|
"todos",
|
||||||
"sort",
|
"sort",
|
||||||
"sort-type",
|
"sort-type",
|
||||||
|
"unscheduled-todos",
|
||||||
|
"unscheduled-notes",
|
||||||
];
|
];
|
||||||
for (const key of Object.keys(input)) {
|
for (const key of Object.keys(input)) {
|
||||||
if (!knownKeys.includes(key)) warnings.push(`Unknown option "${key}"`);
|
if (!knownKeys.includes(key)) warnings.push(`Unknown option "${key}"`);
|
||||||
@ -93,7 +97,49 @@ export default function parseCalendarConfig(raw: any): CalendarConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { view, title, scopeDepth, notes, todos, sort, sortType, warnings };
|
// Unscheduled visibility toggles (default: shown).
|
||||||
|
const unscheduledTodos = parseBoolean(
|
||||||
|
input["unscheduled-todos"],
|
||||||
|
"unscheduled-todos",
|
||||||
|
warnings
|
||||||
|
);
|
||||||
|
const unscheduledNotes = parseBoolean(
|
||||||
|
input["unscheduled-notes"],
|
||||||
|
"unscheduled-notes",
|
||||||
|
warnings
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
view,
|
||||||
|
title,
|
||||||
|
scopeDepth,
|
||||||
|
notes,
|
||||||
|
todos,
|
||||||
|
sort,
|
||||||
|
sortType,
|
||||||
|
unscheduledTodos,
|
||||||
|
unscheduledNotes,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a permissive boolean: yes/no, true/false, show/hide (case-
|
||||||
|
* insensitive). Absent → true (sections shown by default). Invalid →
|
||||||
|
* true, with a warning.
|
||||||
|
*/
|
||||||
|
function parseBoolean(
|
||||||
|
value: any,
|
||||||
|
optionName: string,
|
||||||
|
warnings: string[]
|
||||||
|
): boolean {
|
||||||
|
if (value === undefined || value === null) return true;
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
const candidate = String(value).trim().toLowerCase();
|
||||||
|
if (["yes", "true", "show", "on"].includes(candidate)) return true;
|
||||||
|
if (["no", "false", "hide", "off"].includes(candidate)) return false;
|
||||||
|
warnings.push(`Invalid ${optionName} "${value}" (using "yes")`);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInclusion(
|
function parseInclusion(
|
||||||
|
|||||||
132
src/Gtd/parseKanbanConfig.ts
Normal file
132
src/Gtd/parseKanbanConfig.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { KanbanConfig, InclusionMode, KanbanSortType } from "./types";
|
||||||
|
|
||||||
|
const INCLUSION_MODES: InclusionMode[] = ["gtd-only", "all", "none"];
|
||||||
|
const SORT_TYPES: KanbanSortType[] = ["due-date", "title", "modified-date"];
|
||||||
|
const CARD_DETAILS = ["hover", "always", "none"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a parsed gtd-kanban YAML object into a KanbanConfig with
|
||||||
|
* SPEC defaults. Invalid values produce warnings, not failures.
|
||||||
|
*/
|
||||||
|
export default function parseKanbanConfig(raw: any): KanbanConfig {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const input = raw && typeof raw === "object" ? raw : {};
|
||||||
|
|
||||||
|
const knownKeys = [
|
||||||
|
"title",
|
||||||
|
"scope",
|
||||||
|
"todos",
|
||||||
|
"sort-type",
|
||||||
|
"sort",
|
||||||
|
"in-progress-tag",
|
||||||
|
"card-detail",
|
||||||
|
"done-window",
|
||||||
|
];
|
||||||
|
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 gtd-calendar)
|
||||||
|
let scopeDepth = 0;
|
||||||
|
if (input.scope !== undefined) {
|
||||||
|
const value = input.scope;
|
||||||
|
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")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// todos inclusion
|
||||||
|
let todos: InclusionMode = "gtd-only";
|
||||||
|
if (input.todos !== undefined) {
|
||||||
|
const candidate = String(input.todos).toLowerCase() as InclusionMode;
|
||||||
|
if (INCLUSION_MODES.includes(candidate)) {
|
||||||
|
todos = candidate;
|
||||||
|
} else {
|
||||||
|
warnings.push(`Invalid todos "${input.todos}" (using "gtd-only")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort-type
|
||||||
|
let sortType: KanbanSortType = "due-date";
|
||||||
|
if (input["sort-type"] !== undefined) {
|
||||||
|
let candidate = String(input["sort-type"]).toLowerCase();
|
||||||
|
if (candidate === "modified_date") candidate = "modified-date"; // alias
|
||||||
|
if (SORT_TYPES.includes(candidate as KanbanSortType)) {
|
||||||
|
sortType = candidate as KanbanSortType;
|
||||||
|
} else {
|
||||||
|
warnings.push(
|
||||||
|
`Invalid sort-type "${input["sort-type"]}" (using "due-date")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort direction
|
||||||
|
let sort: KanbanConfig["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")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// in-progress tag (configurable; lowercased to match tag comparison)
|
||||||
|
let inProgressTag = "in-progress";
|
||||||
|
if (
|
||||||
|
input["in-progress-tag"] !== undefined &&
|
||||||
|
input["in-progress-tag"] !== null &&
|
||||||
|
String(input["in-progress-tag"]).trim() !== ""
|
||||||
|
) {
|
||||||
|
inProgressTag = String(input["in-progress-tag"]).trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// card-detail
|
||||||
|
let cardDetail: KanbanConfig["cardDetail"] = "hover";
|
||||||
|
if (input["card-detail"] !== undefined) {
|
||||||
|
const candidate = String(input["card-detail"]).toLowerCase();
|
||||||
|
if (CARD_DETAILS.includes(candidate)) {
|
||||||
|
cardDetail = candidate as KanbanConfig["cardDetail"];
|
||||||
|
} else {
|
||||||
|
warnings.push(
|
||||||
|
`Invalid card-detail "${input["card-detail"]}" (using "hover")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// done-window (days; "all" => Infinity)
|
||||||
|
let doneWindow = 7;
|
||||||
|
if (input["done-window"] !== undefined) {
|
||||||
|
const value = input["done-window"];
|
||||||
|
if (String(value).toLowerCase() === "all") {
|
||||||
|
doneWindow = Infinity;
|
||||||
|
} else if (Number.isInteger(Number(value)) && Number(value) >= 0) {
|
||||||
|
doneWindow = Number(value);
|
||||||
|
} else {
|
||||||
|
warnings.push(`Invalid done-window "${value}" (using 7)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
scopeDepth,
|
||||||
|
todos,
|
||||||
|
sortType,
|
||||||
|
sort,
|
||||||
|
inProgressTag,
|
||||||
|
cardDetail,
|
||||||
|
doneWindow,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
/** Parsed and normalised ```gtd-calendar config. */
|
/** Parsed and normalised ```gtd-calendar config. */
|
||||||
export interface CalendarConfig {
|
export interface CalendarConfig {
|
||||||
view: "day" | "week" | "month";
|
view: "day" | "week" | "month" | "month-grid";
|
||||||
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;
|
||||||
@ -8,6 +8,9 @@ export interface CalendarConfig {
|
|||||||
todos: InclusionMode;
|
todos: InclusionMode;
|
||||||
sort: "asc" | "desc";
|
sort: "asc" | "desc";
|
||||||
sortType: "title" | "modified_date";
|
sortType: "title" | "modified_date";
|
||||||
|
/** Whether to show the Unscheduled to-dos / notes sub-sections. */
|
||||||
|
unscheduledTodos: boolean;
|
||||||
|
unscheduledNotes: boolean;
|
||||||
/** Non-fatal problems found while parsing, surfaced in the UI. */
|
/** Non-fatal problems found while parsing, surfaced in the UI. */
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
@ -55,6 +58,8 @@ export interface GtdEvent {
|
|||||||
icon: string | null;
|
icon: string | null;
|
||||||
text: string | null;
|
text: string | null;
|
||||||
updatedTime: number;
|
updatedTime: number;
|
||||||
|
/** True when the to-do carries the recurrence index tag (see RECURRING_TAG). */
|
||||||
|
isRecurring: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RawFolder {
|
export interface RawFolder {
|
||||||
@ -66,4 +71,58 @@ export interface RawFolder {
|
|||||||
export interface DataAdapter {
|
export interface DataAdapter {
|
||||||
getFolders(): Promise<RawFolder[]>;
|
getFolders(): Promise<RawFolder[]>;
|
||||||
getNotesInFolder(folderId: string): Promise<RawNote[]>;
|
getNotesInFolder(folderId: string): Promise<RawNote[]>;
|
||||||
|
/** Lowercased tag titles for a note. Used to detect recurrence. */
|
||||||
|
getNoteTagTitles(noteId: string): Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag maintained by the "Repeating To-Dos" plugin family as a query index
|
||||||
|
* for recurring to-dos. We treat its presence as the recurrence signal.
|
||||||
|
* Kept as a constant so a future `recurring-tag:` config option (or a
|
||||||
|
* switch to a different recurrence plugin) is a one-line change.
|
||||||
|
*/
|
||||||
|
export const RECURRING_TAG = "recurring";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default tag that places a to-do in the kanban "In Progress" column.
|
||||||
|
* Overridable via the gtd-kanban `in-progress-tag:` option. Constant
|
||||||
|
* mirrors RECURRING_TAG.
|
||||||
|
*/
|
||||||
|
export const IN_PROGRESS_TAG = "in-progress";
|
||||||
|
|
||||||
|
export type KanbanSortType = "due-date" | "title" | "modified-date";
|
||||||
|
|
||||||
|
/** Parsed and normalised ```gtd-kanban config. */
|
||||||
|
export interface KanbanConfig {
|
||||||
|
title: string | null;
|
||||||
|
scopeDepth: number;
|
||||||
|
todos: InclusionMode;
|
||||||
|
sortType: KanbanSortType;
|
||||||
|
sort: "asc" | "desc";
|
||||||
|
inProgressTag: string;
|
||||||
|
cardDetail: "hover" | "always" | "none";
|
||||||
|
/** Days; Infinity means "all". */
|
||||||
|
doneWindow: number;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A to-do as a kanban card. */
|
||||||
|
export interface KanbanCard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
date: string | null;
|
||||||
|
completed: boolean;
|
||||||
|
completedTime: number;
|
||||||
|
isRecurring: boolean;
|
||||||
|
bgColour: string | null;
|
||||||
|
fgColour: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
text: string | null;
|
||||||
|
updatedTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanbanBoard {
|
||||||
|
backlog: KanbanCard[];
|
||||||
|
inProgress: KanbanCard[];
|
||||||
|
done: KanbanCard[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -230,3 +230,133 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
margin: 0.5em 0 0 0;
|
margin: 0.5em 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* view: month-grid */
|
||||||
|
|
||||||
|
.gtd-month-grid-label {
|
||||||
|
margin: 0 0 0.3em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-month-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-month-grid .gtd-grid-head {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0.8;
|
||||||
|
padding: 0.2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-month-grid .gtd-grid-cell {
|
||||||
|
min-height: 4.5em;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.35);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.15em 0.25em;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-month-grid .gtd-grid-cell.outside {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-month-grid .gtd-grid-cell.today {
|
||||||
|
border: 2px solid rgba(255, 200, 0, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-month-grid .gtd-grid-day {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.75em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-month-grid .gtd-grid-chip {
|
||||||
|
font-size: 0.72em;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 0.3em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
color: black;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* gtd-kanban board */
|
||||||
|
|
||||||
|
.gtd-kanban {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-column {
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: rgba(128, 128, 128, 0.06);
|
||||||
|
min-height: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-column-header {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
border-bottom: 1px solid rgba(128, 128, 128, 0.3);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-cards {
|
||||||
|
padding: 0.4em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-card {
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.35em 0.5em;
|
||||||
|
background-color: aliceblue;
|
||||||
|
color: black;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-card:hover {
|
||||||
|
border-color: rgba(20, 20, 20, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-card-title {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-card-detail {
|
||||||
|
font-size: 0.85em;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When card-detail is "hover", the detail line is hidden until hover. */
|
||||||
|
.gtd-kanban-card-hover .gtd-kanban-card-detail {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtd-kanban-card-hover:hover .gtd-kanban-card-detail {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unscheduled sub-sections (todos / notes split) */
|
||||||
|
.gtd-unscheduled-group + .gtd-unscheduled-group {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
const FENCE_INFO = "gtd-calendar";
|
const CALENDAR_FENCE = "gtd-calendar";
|
||||||
|
const KANBAN_FENCE = "gtd-kanban";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markdown-it content script.
|
* Markdown-it content script.
|
||||||
@ -35,16 +36,23 @@ export default function (context: { contentScriptId: string }) {
|
|||||||
self: any
|
self: any
|
||||||
) {
|
) {
|
||||||
const token = tokens[idx];
|
const token = tokens[idx];
|
||||||
if (token.info !== FENCE_INFO)
|
const isCalendar = token.info === CALENDAR_FENCE;
|
||||||
|
const isKanban = token.info === KANBAN_FENCE;
|
||||||
|
if (!isCalendar && !isKanban)
|
||||||
return defaultRender(tokens, idx, options, env, self);
|
return defaultRender(tokens, idx, options, env, self);
|
||||||
|
|
||||||
const encodedConfig = encodeURIComponent(token.content);
|
const encodedConfig = encodeURIComponent(token.content);
|
||||||
|
const blockType = isCalendar ? "calendar" : "kanban";
|
||||||
|
const label = isCalendar
|
||||||
|
? "Loading GTD Calendar…"
|
||||||
|
: "Loading GTD Kanban…";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
`<div class="gtd-calendar-placeholder"` +
|
`<div class="gtd-calendar-placeholder"` +
|
||||||
` data-content-script-id="${context.contentScriptId}"` +
|
` data-content-script-id="${context.contentScriptId}"` +
|
||||||
|
` data-block-type="${blockType}"` +
|
||||||
` data-config="${encodedConfig}">` +
|
` data-config="${encodedConfig}">` +
|
||||||
`<p class="gtd-calendar-loading">Loading GTD Calendar…</p>` +
|
`<p class="gtd-calendar-loading">${label}</p>` +
|
||||||
`</div>`
|
`</div>`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
el.setAttribute("data-gtd-processed", "true");
|
el.setAttribute("data-gtd-processed", "true");
|
||||||
|
|
||||||
const contentScriptId = el.getAttribute("data-content-script-id");
|
const contentScriptId = el.getAttribute("data-content-script-id");
|
||||||
|
const blockType = el.getAttribute("data-block-type") || "calendar";
|
||||||
const rawConfig = decodeURIComponent(
|
const rawConfig = decodeURIComponent(
|
||||||
el.getAttribute("data-config") || ""
|
el.getAttribute("data-config") || ""
|
||||||
);
|
);
|
||||||
@ -38,9 +39,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageType =
|
||||||
|
blockType === "kanban" ? "getKanban" : "getEvents";
|
||||||
|
|
||||||
webviewApi
|
webviewApi
|
||||||
.postMessage(contentScriptId, {
|
.postMessage(contentScriptId, {
|
||||||
type: "getEvents",
|
type: messageType,
|
||||||
rawConfig: rawConfig,
|
rawConfig: rawConfig,
|
||||||
})
|
})
|
||||||
.then(function (payload) {
|
.then(function (payload) {
|
||||||
@ -48,7 +52,11 @@
|
|||||||
renderError(el, "GTD Calendar: empty response from plugin.");
|
renderError(el, "GTD Calendar: empty response from plugin.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderPayload(el, payload, contentScriptId);
|
if (blockType === "kanban") {
|
||||||
|
renderKanban(el, payload, contentScriptId);
|
||||||
|
} else {
|
||||||
|
renderPayload(el, payload, contentScriptId);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function (error) {
|
.catch(function (error) {
|
||||||
renderError(el, "GTD Calendar: " + String(error));
|
renderError(el, "GTD Calendar: " + String(error));
|
||||||
@ -86,6 +94,12 @@
|
|||||||
return event.completed ? "☑" : "☐";
|
return event.completed ? "☑" : "☐";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recurrence mark, shown to the right of any icon/glyph.
|
||||||
|
var RECUR_MARK = "↻";
|
||||||
|
function recurSuffix(event) {
|
||||||
|
return event.isRecurring ? " " + RECUR_MARK : "";
|
||||||
|
}
|
||||||
|
|
||||||
function renderPayload(el, payload, contentScriptId) {
|
function renderPayload(el, payload, contentScriptId) {
|
||||||
el.innerHTML = "";
|
el.innerHTML = "";
|
||||||
|
|
||||||
@ -113,9 +127,17 @@
|
|||||||
wrapper.appendChild(warn);
|
wrapper.appendChild(warn);
|
||||||
});
|
});
|
||||||
|
|
||||||
const calendar = payload.calendar || { groups: [], unscheduled: [] };
|
const calendar = payload.calendar || {
|
||||||
|
groups: [],
|
||||||
|
unscheduled: [],
|
||||||
|
grid: null,
|
||||||
|
};
|
||||||
|
|
||||||
if (calendar.groups.length > 0) {
|
if (calendar.grid) {
|
||||||
|
wrapper.appendChild(
|
||||||
|
renderMonthGrid(calendar.grid, contentScriptId)
|
||||||
|
);
|
||||||
|
} else if (calendar.groups.length > 0) {
|
||||||
wrapper.appendChild(
|
wrapper.appendChild(
|
||||||
renderStrip(calendar.groups, contentScriptId)
|
renderStrip(calendar.groups, contentScriptId)
|
||||||
);
|
);
|
||||||
@ -126,9 +148,19 @@
|
|||||||
wrapper.appendChild(empty);
|
wrapper.appendChild(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (calendar.unscheduled.length > 0) {
|
const showTodos = payload.unscheduledTodos !== false;
|
||||||
|
const showNotes = payload.unscheduledNotes !== false;
|
||||||
|
if (
|
||||||
|
calendar.unscheduled.length > 0 &&
|
||||||
|
(showTodos || showNotes)
|
||||||
|
) {
|
||||||
wrapper.appendChild(
|
wrapper.appendChild(
|
||||||
renderUnscheduled(calendar.unscheduled, contentScriptId)
|
renderUnscheduled(
|
||||||
|
calendar.unscheduled,
|
||||||
|
contentScriptId,
|
||||||
|
showTodos,
|
||||||
|
showNotes
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,11 +212,11 @@
|
|||||||
const icon = document.createElement("span");
|
const icon = document.createElement("span");
|
||||||
icon.className = "event";
|
icon.className = "event";
|
||||||
if (first.fgColour) icon.style.color = first.fgColour;
|
if (first.fgColour) icon.style.color = first.fgColour;
|
||||||
icon.textContent = first.icon
|
icon.textContent =
|
||||||
? first.icon
|
(first.icon ? first.icon : first.title.slice(0, 2)) +
|
||||||
: first.title.slice(0, 2);
|
recurSuffix(first);
|
||||||
if (group.events.length > 1) {
|
if (group.events.length > 1) {
|
||||||
icon.textContent += "+" + (group.events.length - 1);
|
icon.textContent += " +" + (group.events.length - 1);
|
||||||
}
|
}
|
||||||
tile.appendChild(icon);
|
tile.appendChild(icon);
|
||||||
|
|
||||||
@ -229,7 +261,8 @@
|
|||||||
|
|
||||||
const title = document.createElement("p");
|
const title = document.createElement("p");
|
||||||
title.className = "title";
|
title.className = "title";
|
||||||
title.textContent = typeGlyph(event) + " " + event.title;
|
title.textContent =
|
||||||
|
typeGlyph(event) + recurSuffix(event) + " " + event.title;
|
||||||
if (event.completed) title.style.textDecoration = "line-through";
|
if (event.completed) title.style.textDecoration = "line-through";
|
||||||
row.appendChild(title);
|
row.appendChild(title);
|
||||||
|
|
||||||
@ -272,18 +305,125 @@
|
|||||||
return new Date(parts[0], parts[1] - 1, parts[2]);
|
return new Date(parts[0], parts[1] - 1, parts[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Month grid (view: month-grid)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderMonthGrid(grid, contentScriptId) {
|
||||||
|
const section = document.createElement("div");
|
||||||
|
section.className = "gtd-month-grid-wrapper";
|
||||||
|
|
||||||
|
const monthHeading = document.createElement("h4");
|
||||||
|
monthHeading.className = "gtd-month-grid-label";
|
||||||
|
monthHeading.textContent = grid.monthLabel;
|
||||||
|
section.appendChild(monthHeading);
|
||||||
|
|
||||||
|
const table = document.createElement("div");
|
||||||
|
table.className = "gtd-month-grid";
|
||||||
|
|
||||||
|
grid.weekdays.forEach(function (weekday) {
|
||||||
|
const head = document.createElement("div");
|
||||||
|
head.className = "gtd-grid-head";
|
||||||
|
head.textContent = weekday;
|
||||||
|
table.appendChild(head);
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.cells.forEach(function (cell) {
|
||||||
|
const cellEl = document.createElement("div");
|
||||||
|
cellEl.className = "gtd-grid-cell";
|
||||||
|
if (!cell.inMonth) cellEl.className += " outside";
|
||||||
|
if (cell.isToday) cellEl.className += " today";
|
||||||
|
|
||||||
|
const dayNumber = document.createElement("span");
|
||||||
|
dayNumber.className = "gtd-grid-day";
|
||||||
|
dayNumber.textContent = cell.dayNumber;
|
||||||
|
cellEl.appendChild(dayNumber);
|
||||||
|
|
||||||
|
cell.events.forEach(function (event) {
|
||||||
|
const chip = document.createElement("div");
|
||||||
|
chip.className = "gtd-grid-chip gtd-calendar-clickable";
|
||||||
|
chip.textContent =
|
||||||
|
(event.icon ? event.icon : typeGlyph(event)) +
|
||||||
|
recurSuffix(event) +
|
||||||
|
" " +
|
||||||
|
event.title;
|
||||||
|
chip.style.backgroundColor =
|
||||||
|
event.bgColour || colourFromId(event.id);
|
||||||
|
if (event.fgColour) chip.style.color = event.fgColour;
|
||||||
|
if (event.completed)
|
||||||
|
chip.style.textDecoration = "line-through";
|
||||||
|
chip.title = event.title + (event.text ? " — " + event.text : "");
|
||||||
|
chip.addEventListener("click", function () {
|
||||||
|
openNote(contentScriptId, event.id);
|
||||||
|
});
|
||||||
|
cellEl.appendChild(chip);
|
||||||
|
});
|
||||||
|
|
||||||
|
table.appendChild(cellEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
section.appendChild(table);
|
||||||
|
|
||||||
|
if (grid.outsideCount > 0) {
|
||||||
|
const note = document.createElement("p");
|
||||||
|
note.className = "gtd-calendar-debug-meta";
|
||||||
|
note.textContent =
|
||||||
|
grid.outsideCount +
|
||||||
|
" scheduled event(s) outside the displayed month";
|
||||||
|
section.appendChild(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Unscheduled section (GTD bucket)
|
// Unscheduled section (GTD bucket)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
function renderUnscheduled(events, contentScriptId) {
|
function renderUnscheduled(events, contentScriptId, showTodos, showNotes) {
|
||||||
const section = document.createElement("div");
|
const section = document.createElement("div");
|
||||||
section.className = "gtd-unscheduled";
|
section.className = "gtd-unscheduled";
|
||||||
|
|
||||||
|
const todos = events.filter(function (e) {
|
||||||
|
return e.isTodo;
|
||||||
|
});
|
||||||
|
const notes = events.filter(function (e) {
|
||||||
|
return !e.isTodo;
|
||||||
|
});
|
||||||
|
|
||||||
|
// To-dos first (actionable), notes second (reference). Each
|
||||||
|
// sub-section is omitted when empty OR when toggled off via the
|
||||||
|
// unscheduled-todos / unscheduled-notes config options.
|
||||||
|
if (showTodos && todos.length > 0) {
|
||||||
|
section.appendChild(
|
||||||
|
renderUnscheduledGroup(
|
||||||
|
"Unscheduled to-dos",
|
||||||
|
todos,
|
||||||
|
contentScriptId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (showNotes && notes.length > 0) {
|
||||||
|
section.appendChild(
|
||||||
|
renderUnscheduledGroup(
|
||||||
|
"Unscheduled notes",
|
||||||
|
notes,
|
||||||
|
contentScriptId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUnscheduledGroup(label, events, contentScriptId) {
|
||||||
|
const group = document.createElement("div");
|
||||||
|
group.className = "gtd-unscheduled-group";
|
||||||
|
|
||||||
const heading = document.createElement("h4");
|
const heading = document.createElement("h4");
|
||||||
heading.className = "gtd-unscheduled-title";
|
heading.className = "gtd-unscheduled-title";
|
||||||
heading.textContent = "Unscheduled (" + events.length + ")";
|
heading.textContent = label + " (" + events.length + ")";
|
||||||
section.appendChild(heading);
|
group.appendChild(heading);
|
||||||
|
|
||||||
const list = document.createElement("ul");
|
const list = document.createElement("ul");
|
||||||
list.className = "gtd-unscheduled-list";
|
list.className = "gtd-unscheduled-list";
|
||||||
@ -293,6 +433,7 @@
|
|||||||
item.className = "gtd-calendar-clickable";
|
item.className = "gtd-calendar-clickable";
|
||||||
item.textContent =
|
item.textContent =
|
||||||
(event.icon ? event.icon : typeGlyph(event)) +
|
(event.icon ? event.icon : typeGlyph(event)) +
|
||||||
|
recurSuffix(event) +
|
||||||
" " +
|
" " +
|
||||||
event.title;
|
event.title;
|
||||||
if (event.bgColour) item.style.backgroundColor = event.bgColour;
|
if (event.bgColour) item.style.backgroundColor = event.bgColour;
|
||||||
@ -305,8 +446,148 @@
|
|||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
section.appendChild(list);
|
group.appendChild(list);
|
||||||
return section;
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Kanban board (gtd-kanban block)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderKanban(el, payload, contentScriptId) {
|
||||||
|
el.innerHTML = "";
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "gtd-kanban";
|
||||||
|
|
||||||
|
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 = "\u26A0 " + warning;
|
||||||
|
wrapper.appendChild(warn);
|
||||||
|
});
|
||||||
|
|
||||||
|
const board = payload.board || {
|
||||||
|
backlog: [],
|
||||||
|
inProgress: [],
|
||||||
|
done: [],
|
||||||
|
};
|
||||||
|
const detail = payload.cardDetail || "hover";
|
||||||
|
|
||||||
|
const columns = document.createElement("div");
|
||||||
|
columns.className = "gtd-kanban-columns";
|
||||||
|
|
||||||
|
columns.appendChild(
|
||||||
|
renderColumn("Backlog", board.backlog, detail, contentScriptId)
|
||||||
|
);
|
||||||
|
columns.appendChild(
|
||||||
|
renderColumn(
|
||||||
|
"In Progress",
|
||||||
|
board.inProgress,
|
||||||
|
detail,
|
||||||
|
contentScriptId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
columns.appendChild(
|
||||||
|
renderColumn("Done", board.done, detail, contentScriptId)
|
||||||
|
);
|
||||||
|
|
||||||
|
wrapper.appendChild(columns);
|
||||||
|
|
||||||
|
if (payload.stats) {
|
||||||
|
const meta = document.createElement("p");
|
||||||
|
meta.className = "gtd-calendar-debug-meta";
|
||||||
|
meta.textContent =
|
||||||
|
payload.stats.cardCount +
|
||||||
|
" card(s) from " +
|
||||||
|
payload.stats.scannedNotes +
|
||||||
|
" note(s) in " +
|
||||||
|
payload.stats.scannedFolders +
|
||||||
|
" folder(s)";
|
||||||
|
wrapper.appendChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderColumn(title, cards, detail, contentScriptId) {
|
||||||
|
const column = document.createElement("div");
|
||||||
|
column.className = "gtd-kanban-column";
|
||||||
|
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "gtd-kanban-column-header";
|
||||||
|
header.textContent = title + " (" + cards.length + ")";
|
||||||
|
column.appendChild(header);
|
||||||
|
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "gtd-kanban-cards";
|
||||||
|
|
||||||
|
cards.forEach(function (card) {
|
||||||
|
list.appendChild(renderCard(card, detail, contentScriptId));
|
||||||
|
});
|
||||||
|
|
||||||
|
column.appendChild(list);
|
||||||
|
return column;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(card, detail, contentScriptId) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "gtd-kanban-card gtd-calendar-clickable";
|
||||||
|
if (detail === "hover") el.className += " gtd-kanban-card-hover";
|
||||||
|
if (card.bgColour) el.style.backgroundColor = card.bgColour;
|
||||||
|
if (card.fgColour) el.style.color = card.fgColour;
|
||||||
|
|
||||||
|
const titleRow = document.createElement("div");
|
||||||
|
titleRow.className = "gtd-kanban-card-title";
|
||||||
|
const glyph = card.icon ? card.icon : card.completed ? "\u2611" : "\u2610";
|
||||||
|
const recur = card.isRecurring ? " \u21BB" : "";
|
||||||
|
titleRow.textContent = glyph + recur + " " + card.title;
|
||||||
|
if (card.completed) titleRow.style.textDecoration = "line-through";
|
||||||
|
el.appendChild(titleRow);
|
||||||
|
|
||||||
|
// Detail line: due date (+ hover text). Shown inline when
|
||||||
|
// card-detail is "always"; in a hover panel when "hover";
|
||||||
|
// suppressed entirely when "none".
|
||||||
|
if (detail !== "none") {
|
||||||
|
const hasDetail = card.date || card.text;
|
||||||
|
if (hasDetail) {
|
||||||
|
const detailEl = document.createElement("div");
|
||||||
|
detailEl.className = "gtd-kanban-card-detail";
|
||||||
|
const bits = [];
|
||||||
|
if (card.date) bits.push(formatCardDate(card.date));
|
||||||
|
if (card.text) bits.push(card.text);
|
||||||
|
detailEl.textContent = bits.join(" \u00B7 ");
|
||||||
|
el.appendChild(detailEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener("click", function () {
|
||||||
|
openNote(contentScriptId, card.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCardDate(dateISO) {
|
||||||
|
const date = isoToLocalDate(dateISO);
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
processPlaceholders();
|
processPlaceholders();
|
||||||
|
|||||||
73
src/index.ts
73
src/index.ts
@ -6,6 +6,8 @@ const YAML = require("yaml");
|
|||||||
import parseCalendarConfig from "./Gtd/parseCalendarConfig";
|
import parseCalendarConfig from "./Gtd/parseCalendarConfig";
|
||||||
import collectEvents from "./Gtd/collectEvents";
|
import collectEvents from "./Gtd/collectEvents";
|
||||||
import groupEvents from "./Gtd/groupEvents";
|
import groupEvents from "./Gtd/groupEvents";
|
||||||
|
import parseKanbanConfig from "./Gtd/parseKanbanConfig";
|
||||||
|
import collectKanban from "./Gtd/collectKanban";
|
||||||
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";
|
||||||
@ -34,6 +36,14 @@ const joplinAdapter: DataAdapter = {
|
|||||||
fields: NOTE_FIELDS,
|
fields: NOTE_FIELDS,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getNoteTagTitles(noteId: string): Promise<string[]> {
|
||||||
|
const tags = await fetchAllPages<{ title: string }>(
|
||||||
|
["notes", noteId, "tags"],
|
||||||
|
{ fields: ["title"] }
|
||||||
|
);
|
||||||
|
return tags.map((tag) => (tag.title || "").toLowerCase());
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchAllPages<T>(path: string[], query: any): Promise<T[]> {
|
async function fetchAllPages<T>(path: string[], query: any): Promise<T[]> {
|
||||||
@ -64,6 +74,8 @@ joplin.plugins.register({
|
|||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "getEvents":
|
case "getEvents":
|
||||||
return handleGetEvents(message);
|
return handleGetEvents(message);
|
||||||
|
case "getKanban":
|
||||||
|
return handleGetKanban(message);
|
||||||
case "openNote":
|
case "openNote":
|
||||||
return handleOpenNote(message);
|
return handleOpenNote(message);
|
||||||
default:
|
default:
|
||||||
@ -132,6 +144,8 @@ async function handleGetEvents(message: { rawConfig?: string }) {
|
|||||||
warnings: [...config.warnings, ...result.warnings],
|
warnings: [...config.warnings, ...result.warnings],
|
||||||
sourceNote,
|
sourceNote,
|
||||||
calendar,
|
calendar,
|
||||||
|
unscheduledTodos: config.unscheduledTodos,
|
||||||
|
unscheduledNotes: config.unscheduledNotes,
|
||||||
stats: {
|
stats: {
|
||||||
scannedFolders: result.scannedFolders,
|
scannedFolders: result.scannedFolders,
|
||||||
scannedNotes: result.scannedNotes,
|
scannedNotes: result.scannedNotes,
|
||||||
@ -140,6 +154,65 @@ async function handleGetEvents(message: { rawConfig?: string }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGetKanban(message: { rawConfig?: string }) {
|
||||||
|
let rawParsed: any = {};
|
||||||
|
let configError: string | null = null;
|
||||||
|
try {
|
||||||
|
rawParsed = YAML.parse(message.rawConfig || "") || {};
|
||||||
|
} catch (error) {
|
||||||
|
configError = String(error);
|
||||||
|
}
|
||||||
|
const config = parseKanbanConfig(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-kanban: could not resolve selected note", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceNote) {
|
||||||
|
return {
|
||||||
|
title: config.title,
|
||||||
|
cardDetail: config.cardDetail,
|
||||||
|
configError:
|
||||||
|
configError ||
|
||||||
|
"Could not determine which note contains this kanban.",
|
||||||
|
warnings: config.warnings,
|
||||||
|
board: { backlog: [], inProgress: [], done: [] },
|
||||||
|
stats: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await collectKanban(
|
||||||
|
joplinAdapter,
|
||||||
|
sourceNote.id,
|
||||||
|
sourceNote.parentId,
|
||||||
|
config
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: config.title,
|
||||||
|
cardDetail: config.cardDetail,
|
||||||
|
configError,
|
||||||
|
warnings: [...config.warnings, ...result.warnings],
|
||||||
|
board: result.board,
|
||||||
|
stats: {
|
||||||
|
scannedFolders: result.scannedFolders,
|
||||||
|
scannedNotes: result.scannedNotes,
|
||||||
|
cardCount: result.cardCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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,11 +2,12 @@
|
|||||||
"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.1.0",
|
"version": "0.3.2",
|
||||||
"name": "GTD Calendar",
|
"name": "GTD Calendar",
|
||||||
"description": "A GTD-oriented calendar populated by Joplin notes and todos. 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",
|
||||||
"homepage_url": "https://gitea.skeletonworks.online/vwiebe/joplin-plugin-gtd-calendar",
|
"homepage_url": "https://gitea.skeletonworks.online/vwiebe/joplin-plugin-gtd-calendar",
|
||||||
"repository_url": "https://gitea.skeletonworks.online/vwiebe/joplin-plugin-gtd-calendar",
|
"repository_url": "https://gitea.skeletonworks.online/vwiebe/joplin-plugin-gtd-calendar",
|
||||||
"keywords": ["calendar", "gtd", "todo"]
|
"keywords": ["calendar", "gtd", "todo", "agenda", "schedule"],
|
||||||
|
"categories": ["productivity", "viewer"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ function makeEvent(overrides: Partial<GtdEvent>): GtdEvent {
|
|||||||
icon: null,
|
icon: null,
|
||||||
text: null,
|
text: null,
|
||||||
updatedTime: 0,
|
updatedTime: 0,
|
||||||
|
isRecurring: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -131,3 +132,63 @@ describe("groupEvents — unscheduled", () => {
|
|||||||
expect(result.unscheduled.map((e) => e.id)).toEqual(["u"]);
|
expect(result.unscheduled.map((e) => e.id)).toEqual(["u"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("groupEvents — month-grid view", () => {
|
||||||
|
test("covers the whole current month padded to full weeks", () => {
|
||||||
|
const result = groupEvents([], "month-grid", TODAY);
|
||||||
|
const grid = result.grid!;
|
||||||
|
|
||||||
|
expect(grid.cells.length % 7).toBe(0);
|
||||||
|
expect(grid.weekdays).toHaveLength(7);
|
||||||
|
expect(grid.monthLabel).toContain("2026");
|
||||||
|
|
||||||
|
const inMonth = grid.cells.filter((c) => c.inMonth);
|
||||||
|
expect(inMonth).toHaveLength(30); // June 2026
|
||||||
|
expect(inMonth[0].dayNumber).toBe(1);
|
||||||
|
expect(inMonth[29].dayNumber).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("flags today and buckets events into the right cells", () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ id: "a", date: "2026-06-12" }),
|
||||||
|
makeEvent({ id: "b", date: "2026-06-12" }),
|
||||||
|
makeEvent({ id: "c", date: "2026-06-25" }),
|
||||||
|
];
|
||||||
|
const grid = groupEvents(events, "month-grid", TODAY).grid!;
|
||||||
|
|
||||||
|
const todayCell = grid.cells.find((c) => c.isToday)!;
|
||||||
|
expect(todayCell.dateISO).toBe("2026-06-12");
|
||||||
|
expect(todayCell.events.map((e) => e.id)).toEqual(["a", "b"]);
|
||||||
|
|
||||||
|
const cell25 = grid.cells.find((c) => c.dateISO === "2026-06-25")!;
|
||||||
|
expect(cell25.events.map((e) => e.id)).toEqual(["c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("events outside the displayed window are counted, not shown", () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ id: "in", date: "2026-06-05" }),
|
||||||
|
makeEvent({ id: "out", date: "2026-09-01" }),
|
||||||
|
];
|
||||||
|
const grid = groupEvents(events, "month-grid", TODAY).grid!;
|
||||||
|
expect(grid.outsideCount).toBe(1);
|
||||||
|
const allShown = grid.cells.flatMap((c) => c.events.map((e) => e.id));
|
||||||
|
expect(allShown).toEqual(["in"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adjacent-month padding days can still hold events", () => {
|
||||||
|
// June 2026 starts on a Monday; the grid window starts Sunday May 31.
|
||||||
|
const events = [makeEvent({ id: "pad", date: "2026-05-31" })];
|
||||||
|
const grid = groupEvents(events, "month-grid", TODAY).grid!;
|
||||||
|
const padCell = grid.cells.find((c) => c.dateISO === "2026-05-31")!;
|
||||||
|
expect(padCell.inMonth).toBe(false);
|
||||||
|
expect(padCell.events.map((e) => e.id)).toEqual(["pad"]);
|
||||||
|
expect(grid.outsideCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unscheduled events still pass through in month-grid view", () => {
|
||||||
|
const events = [makeEvent({ id: "u" })];
|
||||||
|
const result = groupEvents(events, "month-grid", TODAY);
|
||||||
|
expect(result.unscheduled.map((e) => e.id)).toEqual(["u"]);
|
||||||
|
expect(result.groups).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -222,11 +222,18 @@ describe("date resolution", () => {
|
|||||||
// collectEvents (inclusion matrix + sorting, via a mock adapter)
|
// collectEvents (inclusion matrix + sorting, via a mock adapter)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function makeAdapter(folders: RawFolder[], notes: RawNote[]): DataAdapter {
|
function makeAdapter(
|
||||||
|
folders: RawFolder[],
|
||||||
|
notes: RawNote[],
|
||||||
|
recurringIds: string[] = []
|
||||||
|
): DataAdapter {
|
||||||
|
const recurring = new Set(recurringIds);
|
||||||
return {
|
return {
|
||||||
getFolders: async () => folders,
|
getFolders: async () => folders,
|
||||||
getNotesInFolder: async (folderId: string) =>
|
getNotesInFolder: async (folderId: string) =>
|
||||||
notes.filter((n) => n.parent_id === folderId),
|
notes.filter((n) => n.parent_id === folderId),
|
||||||
|
getNoteTagTitles: async (noteId: string) =>
|
||||||
|
recurring.has(noteId) ? ["recurring"] : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,3 +355,96 @@ describe("collectEvents", () => {
|
|||||||
expect(result.events.map((e) => e.id)).toEqual(["n1", "t1", "n2"]);
|
expect(result.events.map((e) => e.id)).toEqual(["n1", "t1", "n2"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("collectEvents — recurrence detection", () => {
|
||||||
|
const folders: RawFolder[] = [{ id: "blogs", parent_id: "root" }];
|
||||||
|
|
||||||
|
const recurringTodoBody = "```gtd\n```";
|
||||||
|
const notes: RawNote[] = [
|
||||||
|
makeNote({
|
||||||
|
id: "rt",
|
||||||
|
title: "Repeating blog post",
|
||||||
|
parent_id: "blogs",
|
||||||
|
is_todo: 1,
|
||||||
|
todo_due: new Date(2026, 6, 14).getTime(),
|
||||||
|
body: recurringTodoBody,
|
||||||
|
}),
|
||||||
|
makeNote({
|
||||||
|
id: "ot",
|
||||||
|
title: "One-off todo",
|
||||||
|
parent_id: "blogs",
|
||||||
|
is_todo: 1,
|
||||||
|
todo_due: new Date(2026, 6, 15).getTime(),
|
||||||
|
body: "```gtd\n```",
|
||||||
|
}),
|
||||||
|
makeNote({
|
||||||
|
id: "rn",
|
||||||
|
title: "Note that happens to carry the tag",
|
||||||
|
parent_id: "blogs",
|
||||||
|
body: "```gtd\ndate: 2026-07-16\n```",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
test("a to-do with the recurring tag is flagged isRecurring", async () => {
|
||||||
|
const result = await collectEvents(
|
||||||
|
makeAdapter(folders, notes, ["rt"]),
|
||||||
|
"calendar-note",
|
||||||
|
"blogs",
|
||||||
|
parseCalendarConfig({ todos: "all", notes: "all" })
|
||||||
|
);
|
||||||
|
const recurring = result.events.find((e) => e.id === "rt")!;
|
||||||
|
const oneOff = result.events.find((e) => e.id === "ot")!;
|
||||||
|
expect(recurring.isRecurring).toBe(true);
|
||||||
|
expect(oneOff.isRecurring).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plain notes are never flagged recurring even if tagged", async () => {
|
||||||
|
// "rn" is in the recurring set but is a note, not a todo — the tag
|
||||||
|
// lookup is skipped for notes, so it must come back false.
|
||||||
|
const result = await collectEvents(
|
||||||
|
makeAdapter(folders, notes, ["rn"]),
|
||||||
|
"calendar-note",
|
||||||
|
"blogs",
|
||||||
|
parseCalendarConfig({ todos: "all", notes: "all" })
|
||||||
|
);
|
||||||
|
const note = result.events.find((e) => e.id === "rn")!;
|
||||||
|
expect(note.isRecurring).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseCalendarConfig — unscheduled toggles", () => {
|
||||||
|
test("default to shown when absent", () => {
|
||||||
|
const c = parseCalendarConfig({});
|
||||||
|
expect(c.unscheduledTodos).toBe(true);
|
||||||
|
expect(c.unscheduledNotes).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("yes/no are honoured", () => {
|
||||||
|
const c = parseCalendarConfig({
|
||||||
|
"unscheduled-todos": "yes",
|
||||||
|
"unscheduled-notes": "no",
|
||||||
|
});
|
||||||
|
expect(c.unscheduledTodos).toBe(true);
|
||||||
|
expect(c.unscheduledNotes).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("true/false and show/hide are also accepted", () => {
|
||||||
|
expect(
|
||||||
|
parseCalendarConfig({ "unscheduled-todos": "false" }).unscheduledTodos
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseCalendarConfig({ "unscheduled-notes": "hide" }).unscheduledNotes
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseCalendarConfig({ "unscheduled-todos": true }).unscheduledTodos
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid value warns and defaults to shown", () => {
|
||||||
|
const c = parseCalendarConfig({ "unscheduled-todos": "maybe" });
|
||||||
|
expect(c.unscheduledTodos).toBe(true);
|
||||||
|
expect(c.warnings.some((w) => w.includes("unscheduled-todos"))).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
297
src/tests/Gtd/kanban.test.ts
Normal file
297
src/tests/Gtd/kanban.test.ts
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import parseKanbanConfig from "../../Gtd/parseKanbanConfig";
|
||||||
|
import collectKanban from "../../Gtd/collectKanban";
|
||||||
|
import { DataAdapter, RawFolder, RawNote } from "../../Gtd/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseKanbanConfig
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("parseKanbanConfig", () => {
|
||||||
|
test("applies SPEC defaults", () => {
|
||||||
|
const c = parseKanbanConfig({});
|
||||||
|
expect(c).toMatchObject({
|
||||||
|
title: null,
|
||||||
|
scopeDepth: 0,
|
||||||
|
todos: "gtd-only",
|
||||||
|
sortType: "due-date",
|
||||||
|
sort: "asc",
|
||||||
|
inProgressTag: "in-progress",
|
||||||
|
cardDetail: "hover",
|
||||||
|
doneWindow: 7,
|
||||||
|
});
|
||||||
|
expect(c.warnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses a full config", () => {
|
||||||
|
const c = parseKanbanConfig({
|
||||||
|
title: "Board",
|
||||||
|
scope: "children",
|
||||||
|
todos: "all",
|
||||||
|
"sort-type": "title",
|
||||||
|
sort: "desc",
|
||||||
|
"in-progress-tag": "WIP",
|
||||||
|
"card-detail": "always",
|
||||||
|
"done-window": 14,
|
||||||
|
});
|
||||||
|
expect(c.title).toBe("Board");
|
||||||
|
expect(c.scopeDepth).toBe(Infinity);
|
||||||
|
expect(c.todos).toBe("all");
|
||||||
|
expect(c.sortType).toBe("title");
|
||||||
|
expect(c.sort).toBe("desc");
|
||||||
|
expect(c.inProgressTag).toBe("wip"); // lowercased for comparison
|
||||||
|
expect(c.cardDetail).toBe("always");
|
||||||
|
expect(c.doneWindow).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("done-window accepts 'all' as Infinity", () => {
|
||||||
|
expect(parseKanbanConfig({ "done-window": "all" }).doneWindow).toBe(
|
||||||
|
Infinity
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modified_date alias maps to modified-date", () => {
|
||||||
|
expect(parseKanbanConfig({ "sort-type": "modified_date" }).sortType).toBe(
|
||||||
|
"modified-date"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid values warn and fall back", () => {
|
||||||
|
const c = parseKanbanConfig({
|
||||||
|
todos: "maybe",
|
||||||
|
"sort-type": "priority",
|
||||||
|
"card-detail": "popup",
|
||||||
|
"done-window": "soon",
|
||||||
|
nonsense: 1,
|
||||||
|
});
|
||||||
|
expect(c.todos).toBe("gtd-only");
|
||||||
|
expect(c.sortType).toBe("due-date");
|
||||||
|
expect(c.cardDetail).toBe("hover");
|
||||||
|
expect(c.doneWindow).toBe(7);
|
||||||
|
expect(c.warnings.length).toBeGreaterThanOrEqual(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// collectKanban
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeNote(overrides: Partial<RawNote>): RawNote {
|
||||||
|
return {
|
||||||
|
id: "id",
|
||||||
|
title: "Card",
|
||||||
|
parent_id: "board",
|
||||||
|
is_todo: 1,
|
||||||
|
todo_due: 0,
|
||||||
|
todo_completed: 0,
|
||||||
|
updated_time: 0,
|
||||||
|
body: "```gtd\n```",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAdapter(
|
||||||
|
folders: RawFolder[],
|
||||||
|
notes: RawNote[],
|
||||||
|
tagsByNote: Record<string, string[]> = {}
|
||||||
|
): DataAdapter {
|
||||||
|
return {
|
||||||
|
getFolders: async () => folders,
|
||||||
|
getNotesInFolder: async (folderId: string) =>
|
||||||
|
notes.filter((n) => n.parent_id === folderId),
|
||||||
|
getNoteTagTitles: async (noteId: string) => tagsByNote[noteId] || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW = new Date(2026, 5, 15, 12, 0); // Jun 15 2026 noon
|
||||||
|
const folders: RawFolder[] = [{ id: "board", parent_id: "root" }];
|
||||||
|
|
||||||
|
function daysAgo(n: number): number {
|
||||||
|
return NOW.getTime() - n * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("collectKanban — bucketing", () => {
|
||||||
|
test("Done wins over in-progress tag", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "c", title: "Completed but tagged", todo_completed: daysAgo(1) }),
|
||||||
|
];
|
||||||
|
const result = await collectKanban(
|
||||||
|
makeAdapter(folders, notes, { c: ["in-progress"] }),
|
||||||
|
"kanban-note",
|
||||||
|
"board",
|
||||||
|
parseKanbanConfig({ todos: "all" }),
|
||||||
|
NOW
|
||||||
|
);
|
||||||
|
expect(result.board.done.map((c) => c.id)).toEqual(["c"]);
|
||||||
|
expect(result.board.inProgress).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("in-progress tag → In Progress; untagged → Backlog", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "wip", title: "Working" }),
|
||||||
|
makeNote({ id: "todo", title: "Later" }),
|
||||||
|
];
|
||||||
|
const result = await collectKanban(
|
||||||
|
makeAdapter(folders, notes, { wip: ["in-progress"] }),
|
||||||
|
"kanban-note",
|
||||||
|
"board",
|
||||||
|
parseKanbanConfig({ todos: "all" }),
|
||||||
|
NOW
|
||||||
|
);
|
||||||
|
expect(result.board.inProgress.map((c) => c.id)).toEqual(["wip"]);
|
||||||
|
expect(result.board.backlog.map((c) => c.id)).toEqual(["todo"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notes (non-todos) are excluded entirely", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "t", is_todo: 1 }),
|
||||||
|
makeNote({ id: "n", is_todo: 0, title: "Just a note" }),
|
||||||
|
];
|
||||||
|
const result = await collectKanban(
|
||||||
|
makeAdapter(folders, notes),
|
||||||
|
"kanban-note",
|
||||||
|
"board",
|
||||||
|
parseKanbanConfig({ todos: "all", notes: "all" } as any),
|
||||||
|
NOW
|
||||||
|
);
|
||||||
|
const allIds = [
|
||||||
|
...result.board.backlog,
|
||||||
|
...result.board.inProgress,
|
||||||
|
...result.board.done,
|
||||||
|
].map((c) => c.id);
|
||||||
|
expect(allIds).toEqual(["t"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configurable in-progress tag is honoured", async () => {
|
||||||
|
const notes = [makeNote({ id: "x", title: "WIP item" })];
|
||||||
|
const result = await collectKanban(
|
||||||
|
makeAdapter(folders, notes, { x: ["wip"] }),
|
||||||
|
"kanban-note",
|
||||||
|
"board",
|
||||||
|
parseKanbanConfig({ todos: "all", "in-progress-tag": "WIP" }),
|
||||||
|
NOW
|
||||||
|
);
|
||||||
|
expect(result.board.inProgress.map((c) => c.id)).toEqual(["x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the kanban note itself is excluded", async () => {
|
||||||
|
const notes = [makeNote({ id: "kanban-note", title: "The board note" })];
|
||||||
|
const result = await collectKanban(
|
||||||
|
makeAdapter(folders, notes),
|
||||||
|
"kanban-note",
|
||||||
|
"board",
|
||||||
|
parseKanbanConfig({ todos: "all" }),
|
||||||
|
NOW
|
||||||
|
);
|
||||||
|
expect(result.cardCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collectKanban — done window", () => {
|
||||||
|
test("only completions within the window appear in Done", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "recent", todo_completed: daysAgo(3) }),
|
||||||
|
makeNote({ id: "old", todo_completed: daysAgo(30) }),
|
||||||
|
];
|
||||||
|
const result = await collectKanban(
|
||||||
|
makeAdapter(folders, notes),
|
||||||
|
"kanban-note",
|
||||||
|
"board",
|
||||||
|
parseKanbanConfig({ todos: "all", "done-window": 7 }),
|
||||||
|
NOW
|
||||||
|
);
|
||||||
|
expect(result.board.done.map((c) => c.id)).toEqual(["recent"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("done-window: all keeps everything", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "recent", todo_completed: daysAgo(3) }),
|
||||||
|
makeNote({ id: "old", todo_completed: daysAgo(300) }),
|
||||||
|
];
|
||||||
|
const result = await collectKanban(
|
||||||
|
makeAdapter(folders, notes),
|
||||||
|
"kanban-note",
|
||||||
|
"board",
|
||||||
|
parseKanbanConfig({ todos: "all", "done-window": "all" }),
|
||||||
|
NOW
|
||||||
|
);
|
||||||
|
expect(result.board.done.map((c) => c.id).sort()).toEqual([
|
||||||
|
"old",
|
||||||
|
"recent",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collectKanban — sorting", () => {
|
||||||
|
test("due-date asc, dateless cards sort last", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "later", todo_due: new Date(2026, 6, 20).getTime() }),
|
||||||
|
makeNote({ id: "soon", todo_due: new Date(2026, 6, 1).getTime() }),
|
||||||
|
makeNote({ id: "nodate" }), // todo_due 0 → unscheduled
|
||||||
|
];
|
||||||
|
const result = await collectKanban(
|
||||||
|
makeAdapter(folders, notes),
|
||||||
|
"kanban-note",
|
||||||
|
"board",
|
||||||
|
parseKanbanConfig({ todos: "all", "sort-type": "due-date" }),
|
||||||
|
NOW
|
||||||
|
);
|
||||||
|
expect(result.board.backlog.map((c) => c.id)).toEqual([
|
||||||
|
"soon",
|
||||||
|
"later",
|
||||||
|
"nodate",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dateless cards still sort last under desc", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "later", todo_due: new Date(2026, 6, 20).getTime() }),
|
||||||
|
makeNote({ id: "soon", todo_due: new Date(2026, 6, 1).getTime() }),
|
||||||
|
makeNote({ id: "nodate" }),
|
||||||
|
];
|
||||||
|
const result = await collectKanban(
|
||||||
|
makeAdapter(folders, notes),
|
||||||
|
"kanban-note",
|
||||||
|
"board",
|
||||||
|
parseKanbanConfig({
|
||||||
|
todos: "all",
|
||||||
|
"sort-type": "due-date",
|
||||||
|
sort: "desc",
|
||||||
|
}),
|
||||||
|
NOW
|
||||||
|
);
|
||||||
|
expect(result.board.backlog.map((c) => c.id)).toEqual([
|
||||||
|
"later",
|
||||||
|
"soon",
|
||||||
|
"nodate",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("title sort", async () => {
|
||||||
|
const notes = [
|
||||||
|
makeNote({ id: "b", title: "Banana" }),
|
||||||
|
makeNote({ id: "a", title: "Apple" }),
|
||||||
|
];
|
||||||
|
const result = await collectKanban(
|
||||||
|
makeAdapter(folders, notes),
|
||||||
|
"kanban-note",
|
||||||
|
"board",
|
||||||
|
parseKanbanConfig({ todos: "all", "sort-type": "title" }),
|
||||||
|
NOW
|
||||||
|
);
|
||||||
|
expect(result.board.backlog.map((c) => c.id)).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collectKanban — recurrence flag", () => {
|
||||||
|
test("recurring tag flags the card", async () => {
|
||||||
|
const notes = [makeNote({ id: "r", title: "Weekly post" })];
|
||||||
|
const result = await collectKanban(
|
||||||
|
makeAdapter(folders, notes, { r: ["recurring"] }),
|
||||||
|
"kanban-note",
|
||||||
|
"board",
|
||||||
|
parseKanbanConfig({ todos: "all" }),
|
||||||
|
NOW
|
||||||
|
);
|
||||||
|
expect(result.board.backlog[0].isRecurring).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user