From 3e6d2b4d6a7a4024e5c10555f14b13249ea46fa7 Mon Sep 17 00:00:00 2001 From: mdroidian Date: Mon, 24 Aug 2026 14:36:10 -0600 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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) {