From 3e6d2b4d6a7a4024e5c10555f14b13249ea46fa7 Mon Sep 17 00:00:00 2001 From: mdroidian Date: Mon, 24 Aug 2026 14:36:10 -0600 Subject: [PATCH 1/5] Add weekly note page SmartBlocks command --- CHANGELOG.md | 6 +++ docs/weekly-notes.md | 2 + package-lock.json | 4 +- package.json | 2 +- src/features/weekly-notes.ts | 99 +++++++++++++++++++++++++++--------- src/utils/weeklyNotePage.ts | 99 ++++++++++++++++++++++++++++++++++++ 6 files changed, 186 insertions(+), 26 deletions(-) create mode 100644 src/utils/weeklyNotePage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 861d244b..f766da62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project does not follow Semantic Versioning, here's what we do instead: - Minor version bumps are released on a regular cadence. - Patch version bumps are for bugfixes and hotfixes. +## [1.9.0] - 2026-08-24 + +### Added + +- Weekly note page SmartBlocks command - `<%WEEKLYNOTEPAGE:In one week%>` resolves SmartBlocks natural-language dates and `DATEBASIS` into the configured WorkBench weekly page title. + ## [1.8.1] - 2026-06-28 ### Fixed diff --git a/docs/weekly-notes.md b/docs/weekly-notes.md index 25136c49..8fd17aed 100644 --- a/docs/weekly-notes.md +++ b/docs/weekly-notes.md @@ -32,6 +32,8 @@ If the template includes SmartBlocks syntax like `<%DATE:In one week%>`, WorkBen If SmartBlocks is not enabled, WorkBench will show a warning and copy the template blocks without processing the SmartBlocks commands. +When SmartBlocks is enabled, `<%WEEKLYNOTEPAGE:In one week%>` returns a reference to the weekly note page containing the resolved date. It respects SmartBlocks `DATEBASIS` and the weekly page format configured above, and can be used in any SmartBlocks workflow while Weekly Notes is enabled. + # Auto Tagging When a new weekly page is created, the weekly page will be tagged in all of the daily pages that are part of the week. The tag will be added as the top block on the page. This could be toggled on and off in the `roam/js/weekly-notes` page. diff --git a/package-lock.json b/package-lock.json index 98e04f2e..f2aece45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "workbench", - "version": "1.8.2", + "version": "1.9.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "workbench", - "version": "1.8.2", + "version": "1.9.0", "dependencies": { "@mozilla/readability": "^0.3.0", "buffer": "^6.0.3", diff --git a/package.json b/package.json index 4038d038..dcf5c1ee 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@types/mozilla-readability": "^0.2.0", "@types/turndown": "^5.0.1" }, - "version": "1.8.2", + "version": "1.9.0", "samepage": { "extends": "node_modules/roamjs-components/package.json" } diff --git a/src/features/weekly-notes.ts b/src/features/weekly-notes.ts index fe291672..6caaf5a0 100644 --- a/src/features/weekly-notes.ts +++ b/src/features/weekly-notes.ts @@ -34,32 +34,33 @@ import { UnionField, } from "roamjs-components/components/ConfigPanels/types"; import WeeklyNoteNav from "./WeeklyNoteNav"; +import { + formatWeeklyNotePageTitle, + getWeeklyNoteDayIndex, + resolveWeeklyNotePageTitle, + WEEKLY_NOTE_DATE_REGEX, + WEEKLY_NOTE_DAYS, + WEEKLY_NOTE_FORMAT_DEFAULT, +} from "../utils/weeklyNotePage"; const ID = "weekly-notes"; -const DAYS = [ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", -]; -const DATE_REGEX = new RegExp(`{(${DAYS.join("|")}):(.*?)}`, "g"); -const FORMAT_DEFAULT_VALUE = "{monday:MM/dd yyyy} - {sunday:MM/dd yyyy}"; +const DAYS = WEEKLY_NOTE_DAYS; +const DATE_REGEX = WEEKLY_NOTE_DATE_REGEX; +const FORMAT_DEFAULT_VALUE = WEEKLY_NOTE_FORMAT_DEFAULT; const CONFIG = `roam/js/${ID}`; const ROAM_TITLE_CONTAINER_CLASS = "rm-title-display-container"; const WEEKLY_NOTE_NAV_ID = "roamjs-weekly-mode-nav"; const formatCache = { current: "" }; -const getFormat = (tree?: TreeNode[]) => - formatCache.current || - (formatCache.current = getSettingValueFromTree({ +const getFormat = (tree?: TreeNode[]) => { + if (!tree && formatCache.current) return formatCache.current; + return (formatCache.current = getSettingValueFromTree({ key: "format", defaultValue: FORMAT_DEFAULT_VALUE, tree: tree || getFullTreeByParentUid(getPageUidByPageTitle(CONFIG)).children, })); +}; const dateFnsFormat = (...args: Parameters) => { try { @@ -246,7 +247,7 @@ const createWeeklyPage = (pageName: string) => { try { if (date) { - const weekStartsOn = DAYS.indexOf(day) as 0 | 1 | 2 | 3 | 4 | 5 | 6; + const weekStartsOn = getWeeklyNoteDayIndex(day); const autoTag = tree.some((t) => toFlexRegex("auto tag").test(t.text)); const autoEmbed = tree.some((t) => toFlexRegex("auto embed").test(t.text) @@ -362,14 +363,20 @@ export const toggleFeature = ( const goToThisWeek = () => { const format = getFormat(); - const today = new Date(); - const weekStartsOn = DAYS.indexOf( - format.match(new RegExp(DATE_REGEX.source))?.[1] || "sunday" - ) as 0 | 1 | 2 | 3 | 4 | 5 | 6; - const pageName = format.replace(DATE_REGEX, (_, day, f) => { - const dayOfWeek = setDay(today, DAYS.indexOf(day), { weekStartsOn }); - return dateFnsFormat(dayOfWeek, f) ?? ""; - }); + let pageName = ""; + try { + pageName = formatWeeklyNotePageTitle({ + date: new Date(), + weeklyNoteFormat: format, + }); + } catch (e) { + renderToast({ + id: "weekly-notes-error", + content: `Invalid date format: ${(e as Error).message}`, + intent: "danger", + }); + return; + } navigateToPage(pageName); }; const defaultHotkey = window.roamAlphaAPI.platform.isPC @@ -386,6 +393,52 @@ export const toggleFeature = ( ) ); + const smartBlocksCommand = { + text: "WEEKLYNOTEPAGE", + help: "Returns the WorkBench weekly note page containing a natural-language date.", + handler: + ({ + targetUid, + variables, + }: { + targetUid: string; + variables: Record; + }) => + (expression?: string) => + `[[${resolveWeeklyNotePageTitle({ + expression, + targetUid, + variables, + weeklyNoteFormat: getFormat( + getFullTreeByParentUid(getPageUidByPageTitle(CONFIG)).children, + ), + })}]]`, + }; + let registeredSmartBlocks: + | typeof window.roamjs.extension.smartblocks + | undefined; + const registerSmartBlocksCommand = () => { + const smartblocks = window.roamjs?.extension?.smartblocks; + if (!smartblocks || registeredSmartBlocks === smartblocks) return; + smartblocks.registerCommand(smartBlocksCommand); + registeredSmartBlocks = smartblocks; + }; + document.body.addEventListener( + "roamjs:smartblocks:loaded", + registerSmartBlocksCommand, + ); + registerSmartBlocksCommand(); + unloads.add(() => { + document.body.removeEventListener( + "roamjs:smartblocks:loaded", + registerSmartBlocksCommand, + ); + if (registeredSmartBlocks) { + registeredSmartBlocks.unregisterCommand(smartBlocksCommand.text); + registeredSmartBlocks = undefined; + } + }); + const getFormatDateData = (title: string) => { const format = getFormat(); const formats: string[] = []; diff --git a/src/utils/weeklyNotePage.ts b/src/utils/weeklyNotePage.ts new file mode 100644 index 00000000..01f4e1c3 --- /dev/null +++ b/src/utils/weeklyNotePage.ts @@ -0,0 +1,99 @@ +import format from "date-fns/format"; +import setDay from "date-fns/setDay"; +import { DAILY_NOTE_PAGE_REGEX } from "roamjs-components/date/constants"; +import parseNlpDate from "roamjs-components/date/parseNlpDate"; +import getPageTitleByBlockUid from "roamjs-components/queries/getPageTitleByBlockUid"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; + +export const WEEKLY_NOTE_DAYS = [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +] as const; + +export const WEEKLY_NOTE_FORMAT_DEFAULT = + "{monday:MM/dd yyyy} - {sunday:MM/dd yyyy}"; + +export const WEEKLY_NOTE_DATE_REGEX = new RegExp( + `{(${WEEKLY_NOTE_DAYS.join("|")}):(.*?)}`, + "g", +); + +type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +export const getWeeklyNoteDayIndex = (day: string): WeekStartsOn => { + const index = WEEKLY_NOTE_DAYS.findIndex((d) => d === day); + return (index < 0 ? 0 : index) as WeekStartsOn; +}; + +const getWeekStartsOn = (weeklyNoteFormat: string): WeekStartsOn => + getWeeklyNoteDayIndex( + weeklyNoteFormat.match(new RegExp(WEEKLY_NOTE_DATE_REGEX.source))?.[1] || + "sunday", + ); + +export const formatWeeklyNotePageTitle = ({ + date, + weeklyNoteFormat, +}: { + date: Date; + weeklyNoteFormat: string; +}) => { + const weekStartsOn = getWeekStartsOn(weeklyNoteFormat); + return weeklyNoteFormat.replace( + WEEKLY_NOTE_DATE_REGEX, + (_, day: string, dayFormat: string) => + format( + setDay(date, getWeeklyNoteDayIndex(day), { weekStartsOn }), + dayFormat, + { useAdditionalWeekYearTokens: true }, + ), + ); +}; + +const getSmartBlocksDateBasis = ({ + targetUid, + variables, +}: { + targetUid: string; + variables: Record; +}) => { + const dateBasisMethod = variables.DATEBASISMETHOD; + if (dateBasisMethod === "DNP") { + const title = + getPageTitleByBlockUid(targetUid) || getPageTitleByPageUid(targetUid); + const date = DAILY_NOTE_PAGE_REGEX.test(title) + ? window.roamAlphaAPI.util.pageTitleToDate(title) || new Date() + : new Date(); + const now = new Date(); + date.setHours(now.getHours()); + date.setMinutes(now.getMinutes()); + return date; + } + + if (dateBasisMethod) return new Date(dateBasisMethod); + return new Date(); +}; + +export const resolveWeeklyNotePageTitle = ({ + expression = "today", + targetUid, + variables, + weeklyNoteFormat, +}: { + expression?: string; + targetUid: string; + variables: Record; + weeklyNoteFormat: string; +}) => { + const dateBasis = getSmartBlocksDateBasis({ targetUid, variables }); + const resolvedDate = parseNlpDate(expression || "today", dateBasis); + return formatWeeklyNotePageTitle({ + date: /^\s*this\s+week\s*$/i.test(expression) ? dateBasis : resolvedDate, + weeklyNoteFormat, + }); +}; From aeb15507363534e2383b775b641d7295d75cef14 Mon Sep 17 00:00:00 2001 From: mdroidian Date: Mon, 24 Aug 2026 18:14:22 -0600 Subject: [PATCH 2/5] Initialize empty weekly pages from SmartBlocks --- CHANGELOG.md | 2 +- docs/weekly-notes.md | 2 + src/features/weekly-notes.ts | 206 +++++++++++++++++++++++------------ 3 files changed, 137 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f766da62..8a31538a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project does not follow Semantic Versioning, here's what we do instead: ### Added -- Weekly note page SmartBlocks command - `<%WEEKLYNOTEPAGE:In one week%>` resolves SmartBlocks natural-language dates and `DATEBASIS` into the configured WorkBench weekly page title. +- Weekly note page SmartBlocks command - `<%WEEKLYNOTEPAGE:In one week%>` resolves SmartBlocks natural-language dates and `DATEBASIS` into the configured WorkBench weekly page title, creating and initializing the page from the weekly template when it is missing or empty. ## [1.8.1] - 2026-06-28 diff --git a/docs/weekly-notes.md b/docs/weekly-notes.md index 8fd17aed..f183872d 100644 --- a/docs/weekly-notes.md +++ b/docs/weekly-notes.md @@ -34,6 +34,8 @@ If SmartBlocks is not enabled, WorkBench will show a warning and copy the templa When SmartBlocks is enabled, `<%WEEKLYNOTEPAGE:In one week%>` returns a reference to the weekly note page containing the resolved date. It respects SmartBlocks `DATEBASIS` and the weekly page format configured above, and can be used in any SmartBlocks workflow while Weekly Notes is enabled. +If the resolved weekly page does not exist or has no block content, WorkBench creates or initializes it with the configured weekly template. Existing page content is never overwritten. `WEEKLYNOTEPAGE` references inside the weekly template remain link-only: Roam may create the referenced empty page entity, but WorkBench does not initialize that linked week or recursively expand an unlimited chain of weekly templates. + # Auto Tagging When a new weekly page is created, the weekly page will be tagged in all of the daily pages that are part of the week. The tag will be added as the top block on the page. This could be toggled on and off in the `roam/js/weekly-notes` page. diff --git a/src/features/weekly-notes.ts b/src/features/weekly-notes.ts index 6caaf5a0..8dac3967 100644 --- a/src/features/weekly-notes.ts +++ b/src/features/weekly-notes.ts @@ -50,6 +50,7 @@ const FORMAT_DEFAULT_VALUE = WEEKLY_NOTE_FORMAT_DEFAULT; const CONFIG = `roam/js/${ID}`; const ROAM_TITLE_CONTAINER_CLASS = "rm-title-display-container"; const WEEKLY_NOTE_NAV_ID = "roamjs-weekly-mode-nav"; +const weeklyTemplatePageUids = new Set(); const formatCache = { current: "" }; const getFormat = (tree?: TreeNode[]) => { @@ -216,83 +217,123 @@ const renderWeeklyTemplate = async ({ }); await createBlocksFromTemplate({ templateNode, pageUid }); } else if (smartblocks) { - await smartblocks.triggerSmartblock({ - srcUid: templateNode.uid, - targetUid: pageUid, - variables: date ? { DATEBASISMETHOD: date.toJSON() } : undefined, - }); + weeklyTemplatePageUids.add(pageUid); + try { + await smartblocks.triggerSmartblock({ + srcUid: templateNode.uid, + targetUid: pageUid, + variables: date ? { DATEBASISMETHOD: date.toJSON() } : undefined, + }); + } finally { + weeklyTemplatePageUids.delete(pageUid); + } } else { await createBlocksFromTemplate({ templateNode, pageUid }); } }; +const weeklyPageInitializations = new Map>(); const createWeeklyPage = (pageName: string) => { - const weekUid = createPage({ title: pageName }); - const tree = getFullTreeByParentUid(getPageUidByPageTitle(CONFIG)).children; - const format = getFormat(tree); - const [, day, dayFormat] = format.match(new RegExp(DATE_REGEX.source)) || []; - const firstDateFormatted = pageName.match( - new RegExp( - `^${format - .replace(/{(.*?)}/g, "(.*?)") - .replace(/\[/g, "\\[") - .replace(/\]/g, "\\]")}$` - ) - )?.[1]; - - weekUid.then(async (pageUid) => { - const date = firstDateFormatted - ? parse(firstDateFormatted, dayFormat, new Date()) - : null; + const activeInitialization = weeklyPageInitializations.get(pageName); + if (activeInitialization) return activeInitialization; - try { - if (date) { - const weekStartsOn = getWeeklyNoteDayIndex(day); - const autoTag = tree.some((t) => toFlexRegex("auto tag").test(t.text)); - const autoEmbed = tree.some((t) => - toFlexRegex("auto embed").test(t.text) - ); - const tagPromises: Promise[] = []; - const embedPromises: Promise[] = []; - DAYS.forEach((_, i) => { - const dayDate = setDay(date, i, { weekStartsOn }); - const title = window.roamAlphaAPI.util.dateToPageTitle(dayDate); - if (autoTag) { - tagPromises.push( - Promise.resolve( - getPageUidByPageTitle(title) || createPage({ title }) - ).then((parentUid) => - createBlock({ node: { text: `#[[${pageName}]]` }, parentUid }) - ) - ); - } - if (autoEmbed) { - embedPromises.push( - createBlock({ - node: { text: `{{[[embed]]:[[${title}]]}}` }, - parentUid: pageUid, - order: (i - weekStartsOn + 7) % 7, - }) - ); - } + const weekUid = Promise.resolve( + getPageUidByPageTitle(pageName) || createPage({ title: pageName }) + ); + const initialization = weekUid + .then(async (pageUid) => { + if (getFullTreeByParentUid(pageUid).children.some(hasNodeContent)) { + return pageUid; + } + + const tree = getFullTreeByParentUid( + getPageUidByPageTitle(CONFIG) + ).children; + const format = getFormat(tree); + const [, day, dayFormat] = + format.match(new RegExp(DATE_REGEX.source)) || []; + const firstDateFormatted = pageName.match( + new RegExp( + `^${format + .replace(/{(.*?)}/g, "(.*?)") + .replace(/\[/g, "\\[") + .replace(/\]/g, "\\]")}$` + ) + )?.[1]; + + const date = firstDateFormatted + ? parse(firstDateFormatted, dayFormat, new Date()) + : null; + + try { + if (date) { + const weekStartsOn = getWeeklyNoteDayIndex(day); + const autoTag = tree.some((t) => + toFlexRegex("auto tag").test(t.text) + ); + const autoEmbed = tree.some((t) => + toFlexRegex("auto embed").test(t.text) + ); + const tagPromises: Promise[] = []; + const embedPromises: Promise[] = []; + DAYS.forEach((_, i) => { + const dayDate = setDay(date, i, { weekStartsOn }); + const title = window.roamAlphaAPI.util.dateToPageTitle(dayDate); + if (autoTag) { + tagPromises.push( + Promise.resolve( + getPageUidByPageTitle(title) || createPage({ title }) + ).then((parentUid) => + createBlock({ + node: { text: `#[[${pageName}]]` }, + parentUid, + }) + ) + ); + } + if (autoEmbed) { + embedPromises.push( + createBlock({ + node: { text: `{{[[embed]]:[[${title}]]}}` }, + parentUid: pageUid, + order: (i - weekStartsOn + 7) % 7, + }) + ); + } + }); + await Promise.all(embedPromises); + await renderWeeklyTemplate({ tree, pageUid, date }); + await Promise.all(tagPromises); + } else { + await renderWeeklyTemplate({ tree, pageUid }); + } + } catch (e) { + console.error(e); + renderToast({ + id: "weekly-notes-template-error", + content: `Weekly note template failed: ${(e as Error).message}`, + intent: "danger", }); - await Promise.all(embedPromises); - await renderWeeklyTemplate({ tree, pageUid, date }); - await Promise.all(tagPromises); - } else { - await renderWeeklyTemplate({ tree, pageUid }); } - } catch (e) { - console.error(e); - renderToast({ - id: "weekly-notes-template-error", - content: `Weekly note template failed: ${(e as Error).message}`, - intent: "danger", - }); - } - return pageUid; - }); - return weekUid; + return pageUid; + }) + .finally(() => { + if (weeklyPageInitializations.get(pageName) === initialization) { + weeklyPageInitializations.delete(pageName); + } + }); + + weeklyPageInitializations.set(pageName, initialization); + return initialization; +}; + +let weeklyPageInitializationQueue = Promise.resolve(undefined); +const queueWeeklyPageInitialization = (pageName: string) => { + const initialization = weeklyPageInitializationQueue.then(() => + createWeeklyPage(pageName) + ); + weeklyPageInitializationQueue = initialization.catch(() => undefined); + return initialization; }; const navigateToPage = (pageName: string) => { @@ -395,24 +436,45 @@ export const toggleFeature = ( const smartBlocksCommand = { text: "WEEKLYNOTEPAGE", - help: "Returns the WorkBench weekly note page containing a natural-language date.", + help: "Returns the WorkBench weekly note page containing a natural-language date, creating and initializing it when empty.", handler: ({ targetUid, variables, + afterWorkflowMethods, }: { targetUid: string; variables: Record; + afterWorkflowMethods?: (() => void | Promise)[]; }) => - (expression?: string) => - `[[${resolveWeeklyNotePageTitle({ + async (expression?: string) => { + const pageName = resolveWeeklyNotePageTitle({ expression, targetUid, variables, weeklyNoteFormat: getFormat( getFullTreeByParentUid(getPageUidByPageTitle(CONFIG)).children, ), - })}]]`, + }); + if (!weeklyTemplatePageUids.has(targetUid)) { + const pageUid = + getPageUidByPageTitle(pageName) || + (await createPage({ title: pageName })); + const isEmpty = !getFullTreeByParentUid(pageUid).children.some( + hasNodeContent + ); + if (isEmpty) { + const initialize = () => + queueWeeklyPageInitialization(pageName).then(() => undefined); + if (afterWorkflowMethods) { + afterWorkflowMethods.push(initialize); + } else { + window.setTimeout(initialize, 0); + } + } + } + return `[[${pageName}]]`; + }, }; let registeredSmartBlocks: | typeof window.roamjs.extension.smartblocks From 19208ba87c3e892f966ae61a40d81c61131d5cfc Mon Sep 17 00:00:00 2001 From: mdroidian Date: Mon, 24 Aug 2026 19:24:00 -0600 Subject: [PATCH 3/5] Open weekly pages before template initialization --- src/features/weekly-notes.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/features/weekly-notes.ts b/src/features/weekly-notes.ts index 8dac3967..659dc5e6 100644 --- a/src/features/weekly-notes.ts +++ b/src/features/weekly-notes.ts @@ -338,9 +338,13 @@ const queueWeeklyPageInitialization = (pageName: string) => { const navigateToPage = (pageName: string) => { const existingPageUid = getPageUidByPageTitle(pageName); - const { pageUid, timeout } = existingPageUid - ? { pageUid: existingPageUid, timeout: 1 } - : { pageUid: createWeeklyPage(pageName), timeout: 500 }; + const pageUid = existingPageUid || createPage({ title: pageName }); + const timeout = existingPageUid ? 1 : 500; + + if (!existingPageUid) { + Promise.resolve(pageUid).then(() => createWeeklyPage(pageName)); + } + setTimeout(() => { if (pageUid) { Promise.resolve(pageUid).then((uid) => From 2f277857969f7bd583829043f7dd661657702d21 Mon Sep 17 00:00:00 2001 From: mdroidian Date: Mon, 24 Aug 2026 19:28:47 -0600 Subject: [PATCH 4/5] Avoid duplicate weekly auto-tags --- src/features/weekly-notes.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/features/weekly-notes.ts b/src/features/weekly-notes.ts index 659dc5e6..0c6cd8ad 100644 --- a/src/features/weekly-notes.ts +++ b/src/features/weekly-notes.ts @@ -280,15 +280,21 @@ const createWeeklyPage = (pageName: string) => { const dayDate = setDay(date, i, { weekStartsOn }); const title = window.roamAlphaAPI.util.dateToPageTitle(dayDate); if (autoTag) { + const tagText = `#[[${pageName}]]`; tagPromises.push( Promise.resolve( getPageUidByPageTitle(title) || createPage({ title }) - ).then((parentUid) => - createBlock({ - node: { text: `#[[${pageName}]]` }, - parentUid, - }) - ) + ).then((parentUid) => { + const tagExists = getFullTreeByParentUid( + parentUid + ).children.some(({ text }) => text === tagText); + return tagExists + ? undefined + : createBlock({ + node: { text: tagText }, + parentUid, + }); + }) ); } if (autoEmbed) { From 22f36f8debc81423dfc60056277fb139096c4f4c Mon Sep 17 00:00:00 2001 From: mdroidian Date: Sun, 11 Oct 2026 11:54:46 -0600 Subject: [PATCH 5/5] Use SmartBlocks DATE for weekly page resolution and add regression tests --- CHANGELOG.md | 2 +- docs/weekly-notes.md | 4 +- package.json | 3 +- src/features/weekly-notes.ts | 23 +++--- src/utils/weeklyNotePage.ts | 71 +++++++--------- tests/weeklyNotePage.spec.ts | 153 +++++++++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 58 deletions(-) create mode 100644 tests/weeklyNotePage.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a31538a..f3849561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project does not follow Semantic Versioning, here's what we do instead: ### Added -- Weekly note page SmartBlocks command - `<%WEEKLYNOTEPAGE:In one week%>` resolves SmartBlocks natural-language dates and `DATEBASIS` into the configured WorkBench weekly page title, creating and initializing the page from the weekly template when it is missing or empty. +- Weekly note page SmartBlocks command - `<%WEEKLYNOTEPAGE:In one week%>` uses SmartBlocks’ `DATE` command to resolve natural-language dates and `DATEBASIS` into the configured WorkBench weekly page title, creating and initializing the page from the weekly template when it is missing or empty. ## [1.8.1] - 2026-06-28 diff --git a/docs/weekly-notes.md b/docs/weekly-notes.md index f183872d..efeeecd1 100644 --- a/docs/weekly-notes.md +++ b/docs/weekly-notes.md @@ -32,7 +32,9 @@ If the template includes SmartBlocks syntax like `<%DATE:In one week%>`, WorkBen If SmartBlocks is not enabled, WorkBench will show a warning and copy the template blocks without processing the SmartBlocks commands. -When SmartBlocks is enabled, `<%WEEKLYNOTEPAGE:In one week%>` returns a reference to the weekly note page containing the resolved date. It respects SmartBlocks `DATEBASIS` and the weekly page format configured above, and can be used in any SmartBlocks workflow while Weekly Notes is enabled. +When SmartBlocks is enabled, `<%WEEKLYNOTEPAGE:In one week%>` returns a reference to the weekly note page containing the resolved date. It delegates date resolution to SmartBlocks’ `DATE` command and respects `DATEBASIS` and the weekly page format configured above, and can be used in any SmartBlocks workflow while Weekly Notes is enabled. + +`This week` selects the configured week containing the active date basis. Other expressions, including `Next week`, `Last week`, `In three weeks`, and `Six weeks ago`, select the week containing the date returned by SmartBlocks. If the resolved weekly page does not exist or has no block content, WorkBench creates or initializes it with the configured weekly template. Existing page content is never overwritten. `WEEKLYNOTEPAGE` references inside the weekly template remain link-only: Roam may create the referenced empty page entity, but WorkBench does not initialize that linked week or recursively expand an unlimited chain of weekly templates. diff --git a/package.json b/package.json index dcf5c1ee..5711ee0e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "scripts": { "prebuild:roam": "npm install", "build:roam": "samepage build --dry", - "start": "samepage dev" + "start": "samepage dev", + "test": "playwright test tests/weeklyNotePage.spec.ts --workers=1 --reporter=list --output=local/test-results" }, "dependencies": { "@mozilla/readability": "^0.3.0", diff --git a/src/features/weekly-notes.ts b/src/features/weekly-notes.ts index 0c6cd8ad..1062bc4d 100644 --- a/src/features/weekly-notes.ts +++ b/src/features/weekly-notes.ts @@ -36,6 +36,7 @@ import { import WeeklyNoteNav from "./WeeklyNoteNav"; import { formatWeeklyNotePageTitle, + ProcessSmartBlocksText, getWeeklyNoteDayIndex, resolveWeeklyNotePageTitle, WEEKLY_NOTE_DATE_REGEX, @@ -450,29 +451,29 @@ export const toggleFeature = ( handler: ({ targetUid, - variables, + proccessBlockText, afterWorkflowMethods, }: { targetUid: string; - variables: Record; + proccessBlockText: ProcessSmartBlocksText; afterWorkflowMethods?: (() => void | Promise)[]; }) => async (expression?: string) => { - const pageName = resolveWeeklyNotePageTitle({ + const pageName = await resolveWeeklyNotePageTitle({ expression, - targetUid, - variables, + processBlockText: proccessBlockText, + pageTitleToDate: (title) => + window.roamAlphaAPI.util.pageTitleToDate(title) || null, weeklyNoteFormat: getFormat( - getFullTreeByParentUid(getPageUidByPageTitle(CONFIG)).children, + getFullTreeByParentUid(getPageUidByPageTitle(CONFIG)).children ), }); if (!weeklyTemplatePageUids.has(targetUid)) { const pageUid = getPageUidByPageTitle(pageName) || (await createPage({ title: pageName })); - const isEmpty = !getFullTreeByParentUid(pageUid).children.some( - hasNodeContent - ); + const isEmpty = + !getFullTreeByParentUid(pageUid).children.some(hasNodeContent); if (isEmpty) { const initialize = () => queueWeeklyPageInitialization(pageName).then(() => undefined); @@ -497,13 +498,13 @@ export const toggleFeature = ( }; document.body.addEventListener( "roamjs:smartblocks:loaded", - registerSmartBlocksCommand, + registerSmartBlocksCommand ); registerSmartBlocksCommand(); unloads.add(() => { document.body.removeEventListener( "roamjs:smartblocks:loaded", - registerSmartBlocksCommand, + registerSmartBlocksCommand ); if (registeredSmartBlocks) { registeredSmartBlocks.unregisterCommand(smartBlocksCommand.text); diff --git a/src/utils/weeklyNotePage.ts b/src/utils/weeklyNotePage.ts index 01f4e1c3..37e30daf 100644 --- a/src/utils/weeklyNotePage.ts +++ b/src/utils/weeklyNotePage.ts @@ -1,9 +1,5 @@ import format from "date-fns/format"; import setDay from "date-fns/setDay"; -import { DAILY_NOTE_PAGE_REGEX } from "roamjs-components/date/constants"; -import parseNlpDate from "roamjs-components/date/parseNlpDate"; -import getPageTitleByBlockUid from "roamjs-components/queries/getPageTitleByBlockUid"; -import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; export const WEEKLY_NOTE_DAYS = [ "sunday", @@ -20,7 +16,7 @@ export const WEEKLY_NOTE_FORMAT_DEFAULT = export const WEEKLY_NOTE_DATE_REGEX = new RegExp( `{(${WEEKLY_NOTE_DAYS.join("|")}):(.*?)}`, - "g", + "g" ); type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6; @@ -33,7 +29,7 @@ export const getWeeklyNoteDayIndex = (day: string): WeekStartsOn => { const getWeekStartsOn = (weeklyNoteFormat: string): WeekStartsOn => getWeeklyNoteDayIndex( weeklyNoteFormat.match(new RegExp(WEEKLY_NOTE_DATE_REGEX.source))?.[1] || - "sunday", + "sunday" ); export const formatWeeklyNotePageTitle = ({ @@ -42,7 +38,7 @@ export const formatWeeklyNotePageTitle = ({ }: { date: Date; weeklyNoteFormat: string; -}) => { +}): string => { const weekStartsOn = getWeekStartsOn(weeklyNoteFormat); return weeklyNoteFormat.replace( WEEKLY_NOTE_DATE_REGEX, @@ -50,50 +46,37 @@ export const formatWeeklyNotePageTitle = ({ format( setDay(date, getWeeklyNoteDayIndex(day), { weekStartsOn }), dayFormat, - { useAdditionalWeekYearTokens: true }, - ), + { useAdditionalWeekYearTokens: true } + ) ); }; -const getSmartBlocksDateBasis = ({ - targetUid, - variables, -}: { - targetUid: string; - variables: Record; -}) => { - const dateBasisMethod = variables.DATEBASISMETHOD; - if (dateBasisMethod === "DNP") { - const title = - getPageTitleByBlockUid(targetUid) || getPageTitleByPageUid(targetUid); - const date = DAILY_NOTE_PAGE_REGEX.test(title) - ? window.roamAlphaAPI.util.pageTitleToDate(title) || new Date() - : new Date(); - const now = new Date(); - date.setHours(now.getHours()); - date.setMinutes(now.getMinutes()); - return date; - } +export type ProcessSmartBlocksText = ( + text: string +) => Promise<{ text: string }[]>; - if (dateBasisMethod) return new Date(dateBasisMethod); - return new Date(); -}; - -export const resolveWeeklyNotePageTitle = ({ +export const resolveWeeklyNotePageTitle = async ({ expression = "today", - targetUid, - variables, + processBlockText, + pageTitleToDate, weeklyNoteFormat, }: { expression?: string; - targetUid: string; - variables: Record; + processBlockText: ProcessSmartBlocksText; + pageTitleToDate: (title: string) => Date | null; weeklyNoteFormat: string; -}) => { - const dateBasis = getSmartBlocksDateBasis({ targetUid, variables }); - const resolvedDate = parseNlpDate(expression || "today", dateBasis); - return formatWeeklyNotePageTitle({ - date: /^\s*this\s+week\s*$/i.test(expression) ? dateBasis : resolvedDate, - weeklyNoteFormat, - }); +}): Promise => { + // SmartBlocks can resolve "this week" to Sunday, outside a Monday-based week. + // "today" preserves its active DATEBASIS while selecting the containing week. + const dateExpression = /^\s*this\s+week\s*$/i.test(expression) + ? "today" + : expression || "today"; + const blocks = await processBlockText(`<%DATE:${dateExpression}%>`); + const output = blocks[0]?.text?.trim() || ""; + const pageTitle = /^\[\[(.*)\]\]$/.exec(output)?.[1]; + const date = pageTitle ? pageTitleToDate(pageTitle) : null; + if (!date || Number.isNaN(date.valueOf())) { + throw new Error(`Could not resolve "${dateExpression}" to a date`); + } + return formatWeeklyNotePageTitle({ date, weeklyNoteFormat }); }; diff --git a/tests/weeklyNotePage.spec.ts b/tests/weeklyNotePage.spec.ts new file mode 100644 index 00000000..d6573f19 --- /dev/null +++ b/tests/weeklyNotePage.spec.ts @@ -0,0 +1,153 @@ +import { expect, test } from "@playwright/test"; +import format from "date-fns/format"; +import parseNlpDate from "roamjs-components/date/parseNlpDate"; +import { + formatWeeklyNotePageTitle, + resolveWeeklyNotePageTitle, + WEEKLY_NOTE_FORMAT_DEFAULT, +} from "../src/utils/weeklyNotePage"; + +const basis = new Date(2026, 6, 23, 12); + +// Exercise the installed NLP parser as well as the weekly formatter. The live +// Roam test separately exercises SmartBlocks' command context and DATEBASIS. +const resolveWithParser = ({ + expression, + dateBasis = basis, + weeklyNoteFormat = WEEKLY_NOTE_FORMAT_DEFAULT, +}: { + expression?: string; + dateBasis?: Date; + weeklyNoteFormat?: string; +}): Promise => + resolveWeeklyNotePageTitle({ + expression, + weeklyNoteFormat, + processBlockText: async (command) => { + const argument = /^<%DATE:(.*)%>$/.exec(command)?.[1]; + expect(argument).toBeDefined(); + return [ + { + text: `[[${format( + parseNlpDate(argument!, dateBasis), + "yyyy-MM-dd" + )}]]`, + }, + ]; + }, + pageTitleToDate: (title) => { + const [year, month, day] = title.split("-").map(Number); + return new Date(year, month - 1, day, 12); + }, + }); + +for (const [expression, expected] of [ + ["This week", "07/20 2026 - 07/26 2026"], + ["Next week", "07/27 2026 - 08/02 2026"], + ["Last week", "07/13 2026 - 07/19 2026"], + ["In one week", "07/27 2026 - 08/02 2026"], + ["In three weeks", "08/10 2026 - 08/16 2026"], + ["Six weeks ago", "06/08 2026 - 06/14 2026"], + ["In one day", "07/20 2026 - 07/26 2026"], + [" THIS week ", "07/20 2026 - 07/26 2026"], + ["", "07/20 2026 - 07/26 2026"], +]) { + test(`resolves ${JSON.stringify( + expression + )} using the real NLP parser`, async () => { + expect(await resolveWithParser({ expression })).toBe(expected); + }); +} + +test("defaults to the active date basis", async () => { + expect(await resolveWithParser({})).toBe("07/20 2026 - 07/26 2026"); +}); + +test("this week follows a different basis instead of the wall clock", async () => { + expect( + await resolveWithParser({ + expression: "This week", + dateBasis: new Date(2030, 0, 1, 12), + }) + ).toBe("12/31 2029 - 01/06 2030"); +}); + +for (const [day, expected] of [ + [19, "07/13 2026 - 07/19 2026"], + [20, "07/20 2026 - 07/26 2026"], + [26, "07/20 2026 - 07/26 2026"], + [27, "07/27 2026 - 08/02 2026"], +] as const) { + test(`keeps July ${day} in its Monday-to-Sunday week`, async () => { + expect( + await resolveWithParser({ + expression: "This week", + dateBasis: new Date(2026, 6, day, 12), + }) + ).toBe(expected); + }); +} + +test("supports a Sunday-to-Saturday week across the year boundary", async () => { + expect( + await resolveWithParser({ + expression: "today", + dateBasis: new Date(2026, 11, 31, 12), + weeklyNoteFormat: "{sunday:MM/dd yyyy} - {saturday:MM/dd yyyy}", + }) + ).toBe("12/27 2026 - 01/02 2027"); +}); + +test("uses the first configured placeholder for custom weekly boundaries", async () => { + expect( + await resolveWithParser({ + expression: "This week", + weeklyNoteFormat: "Week {wednesday:yyyy-MM-dd} to {tuesday:yyyy-MM-dd}", + }) + ).toBe("Week 2026-07-22 to 2026-07-28"); +}); + +test("delegates general expressions unchanged to the active SmartBlocks processor", async () => { + let command = ""; + expect( + await resolveWeeklyNotePageTitle({ + expression: "Six weeks ago", + weeklyNoteFormat: WEEKLY_NOTE_FORMAT_DEFAULT, + processBlockText: async (text) => { + command = text; + return [{ text: "[[June 11th, 2026]]" }]; + }, + pageTitleToDate: (title) => + title === "June 11th, 2026" ? new Date(2026, 5, 11, 12) : null, + }) + ).toBe("06/08 2026 - 06/14 2026"); + expect(command).toBe("<%DATE:Six weeks ago%>"); +}); + +for (const output of [ + [], + [{ text: "DATE failed" }], + [{ text: "[[invalid]]" }], +]) { + test(`rejects unresolved SmartBlocks output ${JSON.stringify( + output + )}`, async () => { + await expect( + resolveWeeklyNotePageTitle({ + expression: "bad input", + weeklyNoteFormat: WEEKLY_NOTE_FORMAT_DEFAULT, + processBlockText: async () => output, + pageTitleToDate: () => new Date(NaN), + }) + ).rejects.toThrow('Could not resolve "bad input" to a date'); + }); +} + +test("reports invalid weekly format tokens", () => { + expect(() => + formatWeeklyNotePageTitle({ + date: basis, + weeklyNoteFormat: "{monday:invalid}", + }) + ).toThrow(); +});