diff --git a/README.md b/README.md index 661b9f9..5720384 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,12 @@ Goes in any note or to-do that should appear on the calendar. **An empty block i 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. +## Repeating to-dos + +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. + +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. + ## Drilldown Everything is clickable: single-event tiles, every row of a multi-event hover card, every chip in the month grid, every Unscheduled chip. Clicking opens the source note. diff --git a/SPEC.md b/SPEC.md index 1be75ee..50aae44 100644 --- a/SPEC.md +++ b/SPEC.md @@ -188,7 +188,7 @@ Recorded direction, not commitments. Each gets a focused design pass before work 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 (chosen for 0.2.0):** detect the recurrence marker in the body and tag those events with a ↻ glyph. No series projection — the to-do shows at its next due date like any dated to-do. Minimal, robust, no hard coupling to another plugin's body format. +- **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). diff --git a/package.json b/package.json index ef2b361..2be0610 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "joplin-plugin-gtd-calendar", - "version": "0.1.0", + "version": "0.2.0", "scripts": { "test": "jest", "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive", diff --git a/src/Gtd/collectEvents.ts b/src/Gtd/collectEvents.ts index 9135732..457d657 100644 --- a/src/Gtd/collectEvents.ts +++ b/src/Gtd/collectEvents.ts @@ -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 extractGtdBlock from "./gtdBlock"; import { resolveEventDate } from "./resolveDate"; @@ -44,7 +50,7 @@ export default async function collectEvents( scannedNotes += 1; if (note.id === calendarNoteId) continue; - const event = noteToEvent(note, config, warnings); + const event = await noteToEvent(adapter, note, config, warnings); if (event) events.push(event); } } @@ -59,11 +65,12 @@ export default async function collectEvents( }; } -function noteToEvent( +async function noteToEvent( + adapter: DataAdapter, note: RawNote, config: CalendarConfig, warnings: string[] -): GtdEvent | null { +): Promise { const mode = note.is_todo ? config.todos : config.notes; if (mode === "none") return null; @@ -81,6 +88,13 @@ function noteToEvent( 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 { id: note.id, title: block && block.title ? block.title : note.title, @@ -92,6 +106,7 @@ function noteToEvent( icon: block ? block.icon : null, text: block ? block.text : null, updatedTime: note.updated_time, + isRecurring, }; } diff --git a/src/Gtd/types.ts b/src/Gtd/types.ts index ce8b1d3..683e1b6 100644 --- a/src/Gtd/types.ts +++ b/src/Gtd/types.ts @@ -55,6 +55,8 @@ export interface GtdEvent { icon: string | null; text: string | null; updatedTime: number; + /** True when the to-do carries the recurrence index tag (see RECURRING_TAG). */ + isRecurring: boolean; } export interface RawFolder { @@ -66,4 +68,14 @@ export interface RawFolder { export interface DataAdapter { getFolders(): Promise; getNotesInFolder(folderId: string): Promise; + /** Lowercased tag titles for a note. Used to detect recurrence. */ + getNoteTagTitles(noteId: string): Promise; } + +/** + * 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"; diff --git a/src/gtd-calendar-webview.js b/src/gtd-calendar-webview.js index df5ec3b..2a4156b 100644 --- a/src/gtd-calendar-webview.js +++ b/src/gtd-calendar-webview.js @@ -86,6 +86,12 @@ 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) { el.innerHTML = ""; @@ -188,11 +194,11 @@ const icon = document.createElement("span"); icon.className = "event"; if (first.fgColour) icon.style.color = first.fgColour; - icon.textContent = first.icon - ? first.icon - : first.title.slice(0, 2); + icon.textContent = + (first.icon ? first.icon : first.title.slice(0, 2)) + + recurSuffix(first); if (group.events.length > 1) { - icon.textContent += "+" + (group.events.length - 1); + icon.textContent += " +" + (group.events.length - 1); } tile.appendChild(icon); @@ -237,7 +243,8 @@ const title = document.createElement("p"); 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"; row.appendChild(title); @@ -319,6 +326,7 @@ chip.className = "gtd-grid-chip gtd-calendar-clickable"; chip.textContent = (event.icon ? event.icon : typeGlyph(event)) + + recurSuffix(event) + " " + event.title; chip.style.backgroundColor = @@ -371,6 +379,7 @@ item.className = "gtd-calendar-clickable"; item.textContent = (event.icon ? event.icon : typeGlyph(event)) + + recurSuffix(event) + " " + event.title; if (event.bgColour) item.style.backgroundColor = event.bgColour; diff --git a/src/index.ts b/src/index.ts index 223926a..b48579f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,14 @@ const joplinAdapter: DataAdapter = { fields: NOTE_FIELDS, }); }, + + async getNoteTagTitles(noteId: string): Promise { + const tags = await fetchAllPages<{ title: string }>( + ["notes", noteId, "tags"], + { fields: ["title"] } + ); + return tags.map((tag) => (tag.title || "").toLowerCase()); + }, }; async function fetchAllPages(path: string[], query: any): Promise { diff --git a/src/manifest.json b/src/manifest.json index 4b6e0a5..cac041b 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "id": "com.victorwiebe.joplin.plugin.gtd-calendar", "app_min_version": "2.7", - "version": "0.1.0", + "version": "0.2.0", "name": "GTD Calendar", "description": "Day, week, and month calendars populated by your notes and to-dos, with click-through to the source note. Configure with simple YAML blocks. A GTD-friendly fork of Event Calendar by Franco Speziali.", "author": "Victor Wiebe", diff --git a/src/tests/Gtd/groupEvents.test.ts b/src/tests/Gtd/groupEvents.test.ts index 74d2ab5..ba910ac 100644 --- a/src/tests/Gtd/groupEvents.test.ts +++ b/src/tests/Gtd/groupEvents.test.ts @@ -13,6 +13,7 @@ function makeEvent(overrides: Partial): GtdEvent { icon: null, text: null, updatedTime: 0, + isRecurring: false, ...overrides, }; } diff --git a/src/tests/Gtd/gtd.test.ts b/src/tests/Gtd/gtd.test.ts index 049da9a..96906e3 100644 --- a/src/tests/Gtd/gtd.test.ts +++ b/src/tests/Gtd/gtd.test.ts @@ -222,11 +222,18 @@ describe("date resolution", () => { // 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 { getFolders: async () => folders, getNotesInFolder: async (folderId: string) => notes.filter((n) => n.parent_id === folderId), + getNoteTagTitles: async (noteId: string) => + recurring.has(noteId) ? ["recurring"] : [], }; } @@ -348,3 +355,59 @@ describe("collectEvents", () => { 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); + }); +});