diff --git a/CHANGELOG.md b/CHANGELOG.md index 861d244b..f3849561 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%>` 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 ### Fixed diff --git a/docs/weekly-notes.md b/docs/weekly-notes.md index 25136c49..efeeecd1 100644 --- a/docs/weekly-notes.md +++ b/docs/weekly-notes.md @@ -32,6 +32,12 @@ 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 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. + # 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..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", @@ -23,7 +24,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..1062bc4d 100644 --- a/src/features/weekly-notes.ts +++ b/src/features/weekly-notes.ts @@ -34,32 +34,35 @@ import { UnionField, } from "roamjs-components/components/ConfigPanels/types"; import WeeklyNoteNav from "./WeeklyNoteNav"; +import { + formatWeeklyNotePageTitle, + ProcessSmartBlocksText, + 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 weeklyTemplatePageUids = new Set(); 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 { @@ -215,90 +218,140 @@ 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 = DAYS.indexOf(day) as 0 | 1 | 2 | 3 | 4 | 5 | 6; - 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) { + const tagText = `#[[${pageName}]]`; + tagPromises.push( + Promise.resolve( + getPageUidByPageTitle(title) || createPage({ title }) + ).then((parentUid) => { + const tagExists = getFullTreeByParentUid( + parentUid + ).children.some(({ text }) => text === tagText); + return tagExists + ? undefined + : createBlock({ + node: { text: tagText }, + 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) => { 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) => @@ -362,14 +415,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 +445,73 @@ export const toggleFeature = ( ) ); + const smartBlocksCommand = { + text: "WEEKLYNOTEPAGE", + help: "Returns the WorkBench weekly note page containing a natural-language date, creating and initializing it when empty.", + handler: + ({ + targetUid, + proccessBlockText, + afterWorkflowMethods, + }: { + targetUid: string; + proccessBlockText: ProcessSmartBlocksText; + afterWorkflowMethods?: (() => void | Promise)[]; + }) => + async (expression?: string) => { + const pageName = await resolveWeeklyNotePageTitle({ + expression, + processBlockText: proccessBlockText, + pageTitleToDate: (title) => + window.roamAlphaAPI.util.pageTitleToDate(title) || null, + 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 + | 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..37e30daf --- /dev/null +++ b/src/utils/weeklyNotePage.ts @@ -0,0 +1,82 @@ +import format from "date-fns/format"; +import setDay from "date-fns/setDay"; + +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; +}): 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 } + ) + ); +}; + +export type ProcessSmartBlocksText = ( + text: string +) => Promise<{ text: string }[]>; + +export const resolveWeeklyNotePageTitle = async ({ + expression = "today", + processBlockText, + pageTitleToDate, + weeklyNoteFormat, +}: { + expression?: string; + processBlockText: ProcessSmartBlocksText; + pageTitleToDate: (title: string) => Date | null; + weeklyNoteFormat: string; +}): 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(); +});