Phase 1: gtd-calendar fence, webview round trip, drilldown proof

- New markdown-it content script intercepts ```gtd-calendar fences and
  emits a placeholder div (config URI-encoded in a data attribute)
- New webview asset (gtd-calendar-webview.js) finds placeholders, requests
  data via webviewApi.postMessage, renders a debug view, and wires
  click-to-open drilldown (openNote command)
- index.ts registers the content script and handles getEvents/openNote;
  getEvents parses the YAML config for real but returns hardcoded events,
  including one pointing at the current note to prove drilldown
- Old event-calendar content script removed; Calendar/ classes retained
  for reuse in Phase 3
- Fork CSS section appended (placeholder, error, debug styles)
This commit is contained in:
Victor Wiebe 2026-06-12 21:52:41 +00:00 committed by vwiebe
parent 5791ee5ec2
commit 16242a2f44
7 changed files with 338 additions and 53 deletions

8
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "joplin-plugin-event-calendar",
"version": "0.3.0",
"name": "joplin-plugin-gtd-calendar",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "joplin-plugin-event-calendar",
"version": "0.3.0",
"name": "joplin-plugin-gtd-calendar",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"date-fns": "^2.29.3",

View File

@ -1,3 +1,3 @@
{
"extraScripts": ["joplin-plugin-event-calendar.ts"]
"extraScripts": ["gtd-calendar-renderer.ts"]
}

View File

@ -127,3 +127,37 @@
top: 5vh;
right: 5vw;
}
/* --- GTD Calendar (fork) --- */
.gtd-calendar-placeholder .gtd-calendar-loading {
font-style: italic;
opacity: 0.7;
}
.gtd-calendar-error {
border: 1px solid #c0392b;
border-radius: 4px;
padding: 0.5em 0.75em;
color: #c0392b;
}
.gtd-calendar-debug {
border: 1px dashed currentColor;
border-radius: 6px;
padding: 0.75em 1em;
}
.gtd-calendar-debug-meta {
font-size: 0.85em;
opacity: 0.75;
}
.gtd-calendar-debug-events li {
margin: 0.25em 0;
}
.gtd-calendar-clickable {
cursor: pointer;
text-decoration: underline;
}

View File

@ -0,0 +1,59 @@
const FENCE_INFO = "gtd-calendar";
/**
* Markdown-it content script.
*
* Markdown-it renders synchronously and has no access to the Joplin data
* API, so this script does the minimum possible: it intercepts
* ```gtd-calendar fences and emits a placeholder <div> carrying the raw
* (URI-encoded) fence content and our content script id.
*
* The companion webview asset (gtd-calendar-webview.js) finds these
* placeholders in the rendered note, requests event data from the main
* plugin process via webviewApi.postMessage, and builds the calendar DOM.
*/
export default function (context: { contentScriptId: string }) {
return {
plugin: function (markdownIt: any) {
const defaultRender: Function =
markdownIt.renderer.rules.fence ||
function (
tokens: any[],
idx: number,
options: any,
env: any,
self: any
) {
return self.renderToken(tokens, idx, options, env, self);
};
markdownIt.renderer.rules.fence = function (
tokens: any[],
idx: number,
options: {},
env: any,
self: any
) {
const token = tokens[idx];
if (token.info !== FENCE_INFO)
return defaultRender(tokens, idx, options, env, self);
const encodedConfig = encodeURIComponent(token.content);
return (
`<div class="gtd-calendar-placeholder"` +
` data-content-script-id="${context.contentScriptId}"` +
` data-config="${encodedConfig}">` +
`<p class="gtd-calendar-loading">Loading GTD Calendar…</p>` +
`</div>`
);
};
},
assets: function () {
return [
{ name: "event-calendar.css" },
{ name: "gtd-calendar-webview.js" },
];
},
};
}

130
src/gtd-calendar-webview.js Normal file
View File

@ -0,0 +1,130 @@
/*
* GTD Calendar webview script.
*
* Runs inside Joplin's rendered-note webview. Finds gtd-calendar
* placeholders emitted by the markdown-it content script, asks the main
* plugin process for event data, and renders the result.
*
* Phase 1: the plugin returns a hardcoded payload; this script renders a
* simple debug view proving the full round trip, including click-to-open
* drilldown.
*/
(function () {
"use strict";
function processPlaceholders() {
const placeholders = document.querySelectorAll(
".gtd-calendar-placeholder:not([data-gtd-processed])"
);
placeholders.forEach(function (el) {
el.setAttribute("data-gtd-processed", "true");
const contentScriptId = el.getAttribute("data-content-script-id");
const rawConfig = decodeURIComponent(
el.getAttribute("data-config") || ""
);
if (
typeof webviewApi === "undefined" ||
typeof webviewApi.postMessage !== "function"
) {
renderError(
el,
"GTD Calendar: webview messaging is unavailable. " +
"This plugin requires Joplin 2.7 or newer (desktop)."
);
return;
}
webviewApi
.postMessage(contentScriptId, {
type: "getEvents",
rawConfig: rawConfig,
})
.then(function (payload) {
if (!payload) {
renderError(el, "GTD Calendar: empty response from plugin.");
return;
}
renderPayload(el, payload, contentScriptId);
})
.catch(function (error) {
renderError(el, "GTD Calendar: " + String(error));
});
});
}
function renderError(el, message) {
el.innerHTML = "";
const box = document.createElement("div");
box.className = "gtd-calendar-error";
box.textContent = message;
el.appendChild(box);
}
function renderPayload(el, payload, contentScriptId) {
el.innerHTML = "";
const box = document.createElement("div");
box.className = "gtd-calendar-debug";
const heading = document.createElement("h3");
heading.textContent =
(payload.title || "GTD Calendar") + " — Phase 1 plumbing test";
box.appendChild(heading);
const meta = document.createElement("p");
meta.className = "gtd-calendar-debug-meta";
const noteInfo = payload.sourceNote
? '"' + payload.sourceNote.title + '" (' + payload.sourceNote.id + ")"
: "unknown";
meta.textContent =
"view: " +
(payload.view || "?") +
" · rendered in note: " +
noteInfo;
box.appendChild(meta);
if (payload.configError) {
const warn = document.createElement("p");
warn.className = "gtd-calendar-error";
warn.textContent = "Config problem: " + payload.configError;
box.appendChild(warn);
}
const list = document.createElement("ul");
list.className = "gtd-calendar-debug-events";
(payload.events || []).forEach(function (event) {
const item = document.createElement("li");
const glyph = event.isTodo ? (event.completed ? "☑ " : "☐ ") : "📄 ";
const dateLabel = event.date ? event.date : "unscheduled";
item.textContent = glyph + event.title + " — " + dateLabel;
if (event.id) {
item.classList.add("gtd-calendar-clickable");
item.title = "Click to open this note";
item.addEventListener("click", function () {
webviewApi.postMessage(contentScriptId, {
type: "openNote",
noteId: event.id,
});
});
}
list.appendChild(item);
});
box.appendChild(list);
el.appendChild(box);
}
processPlaceholders();
// Joplin may replace rendered content without re-running asset scripts;
// watch for new placeholders arriving in the DOM.
const observer = new MutationObserver(processPlaceholders);
observer.observe(document.body, { childList: true, subtree: true });
})();

View File

@ -1,12 +1,120 @@
import joplin from "api";
import { ContentScriptType } from "api/types";
const YAML = require("yaml");
const CONTENT_SCRIPT_ID = "gtd-calendar-renderer";
function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}
joplin.plugins.register({
onStart: async function () {
await joplin.contentScripts.register(
ContentScriptType.MarkdownItPlugin,
"joplin-plugin-event-calendar",
"./joplin-plugin-event-calendar.js"
CONTENT_SCRIPT_ID,
"./gtd-calendar-renderer.js"
);
await joplin.contentScripts.onMessage(
CONTENT_SCRIPT_ID,
async (message: any) => {
if (!message || !message.type) return null;
switch (message.type) {
case "getEvents":
return handleGetEvents(message);
case "openNote":
return handleOpenNote(message);
default:
return null;
}
}
);
},
});
/**
* Phase 1: parse the gtd-calendar config for real, but return hardcoded
* events. Proves: config transit, YAML parsing in the main process, data
* API access (selectedNote), and the response path back to the webview.
*
* Phase 2 replaces the hardcoded events with folder-scoped note/todo
* collection per SPEC.md.
*/
async function handleGetEvents(message: { rawConfig?: string }) {
let config: any = {};
let configError: string | null = null;
try {
config = YAML.parse(message.rawConfig || "") || {};
} catch (error) {
configError = String(error);
}
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) {
// Non-fatal in Phase 1; the debug view reports "unknown".
console.warn("gtd-calendar: could not resolve selected note", error);
}
return {
phase: 1,
title: typeof config.title === "string" ? config.title : "GTD Calendar",
view: typeof config.view === "string" ? config.view : "day",
config,
configError,
sourceNote,
events: [
{
id: sourceNote ? sourceNote.id : null,
title: "Drilldown test — opens this very note",
date: todayISO(),
isTodo: false,
completed: false,
},
{
id: null,
title: "Hardcoded todo",
date: todayISO(),
isTodo: true,
completed: false,
},
{
id: null,
title: "Hardcoded completed todo",
date: todayISO(),
isTodo: true,
completed: true,
},
{
id: null,
title: "Hardcoded unscheduled note",
date: null,
isTodo: false,
completed: false,
},
],
};
}
async function handleOpenNote(message: { noteId?: string }) {
if (!message.noteId) return { ok: false, error: "No noteId provided" };
try {
await joplin.commands.execute("openNote", message.noteId);
return { ok: true };
} catch (error) {
return { ok: false, error: String(error) };
}
}

View File

@ -1,46 +0,0 @@
const YAML = require("yaml");
import Calendar from "./Calendar/";
export default function () {
return {
plugin: function (markdownIt: any) {
const defaultRender: Function =
markdownIt.renderer.rules.fence ||
function (
tokens: any[],
idx: number,
options: any,
env: any,
self: any
) {
return self.renderToken(tokens, idx, options, env, self);
};
markdownIt.renderer.rules.fence = function (
tokens: any[],
idx: number,
options: {},
env: any,
self: any
) {
const token = tokens[idx];
if (token.info !== "joplin-plugin-event-calendar")
return defaultRender(tokens, idx, options, env, self);
try {
const jsonContent = YAML.parse(
markdownIt.utils.escapeHtml(token.content)
);
const calendar = new Calendar(jsonContent);
const contentHtml = calendar.render();
return `<div class="joplin-editable">${contentHtml.outerHTML}</div>`;
} catch (error) {
console.log(error);
}
};
},
assets: function () {
return [{ name: "event-calendar.css" }];
},
};
}