diff --git a/CHANGELOG.md b/CHANGELOG.md index 861d244b..eb527eb6 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.8.2] - 2026-08-08 + +### Added + +- `WORKBENCHWEEK` SmartBlocks command - Resolve natural-language dates to links using the configured weekly note title format. + ## [1.8.1] - 2026-06-28 ### Fixed diff --git a/docs/weekly-notes.md b/docs/weekly-notes.md index 25136c49..862fd3af 100644 --- a/docs/weekly-notes.md +++ b/docs/weekly-notes.md @@ -32,6 +32,24 @@ 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. +# SmartBlocks command + +When Weekly Notes and SmartBlocks are enabled, WorkBench registers a `WORKBENCHWEEK` command that returns a link to the configured weekly note containing a resolved date. + +```plain text +<%WORKBENCHWEEK%> +<%WORKBENCHWEEK:This week%> +<%WORKBENCHWEEK:Next week%> +<%WORKBENCHWEEK:In three weeks%> +<%WORKBENCHWEEK:Six weeks ago%> +``` + +The command uses SmartBlocks' natural-language `DATE` resolver and respects the active `DATEBASIS`. For example: + +```plain text +<%DATEBASIS:DNP%><%WORKBENCHWEEK:In one week%> +``` + # 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.json b/package.json index 4038d038..b96c6055 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "scripts": { "prebuild:roam": "npm install", "build:roam": "samepage build --dry", + "test": "samepage test", "start": "samepage dev" }, "dependencies": { diff --git a/src/features/weekly-notes.ts b/src/features/weekly-notes.ts index fe291672..bf545c5c 100644 --- a/src/features/weekly-notes.ts +++ b/src/features/weekly-notes.ts @@ -34,26 +34,26 @@ import { UnionField, } from "roamjs-components/components/ConfigPanels/types"; import WeeklyNoteNav from "./WeeklyNoteNav"; +import { + formatWeeklyNoteTitle, + resolveSmartBlocksDate, + WEEKLY_NOTE_DATE_REGEX, + WEEKLY_NOTE_DAYS, +} from "../utils/weeklyNotes"; const ID = "weekly-notes"; -const DAYS = [ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", -]; -const DATE_REGEX = new RegExp(`{(${DAYS.join("|")}):(.*?)}`, "g"); +const DAYS = WEEKLY_NOTE_DAYS; +const DATE_REGEX = WEEKLY_NOTE_DATE_REGEX; const FORMAT_DEFAULT_VALUE = "{monday:MM/dd yyyy} - {sunday:MM/dd yyyy}"; const CONFIG = `roam/js/${ID}`; const ROAM_TITLE_CONTAINER_CLASS = "rm-title-display-container"; const WEEKLY_NOTE_NAV_ID = "roamjs-weekly-mode-nav"; +const SMARTBLOCK_COMMAND = "WORKBENCHWEEK"; +const SMARTBLOCKS_LOADED_EVENT = "roamjs:smartblocks:loaded"; const formatCache = { current: "" }; -const getFormat = (tree?: TreeNode[]) => - formatCache.current || +const getFormat = (tree?: TreeNode[], refresh = false): string => + (!refresh && formatCache.current) || (formatCache.current = getSettingValueFromTree({ key: "format", defaultValue: FORMAT_DEFAULT_VALUE, @@ -91,6 +91,13 @@ const parse = (...args: Parameters) => { } }; +const getWeeklyPageTitle = (date: Date): string | null => + formatWeeklyNoteTitle({ + date, + format: getFormat(undefined, true), + formatDate: dateFnsFormat, + }); + const hasNodeContent = (node: InputTextNode | RoamBasicNode): boolean => !!node.text.trim() || !!node.children?.some(hasNodeContent); @@ -308,6 +315,42 @@ const navigateToPage = (pageName: string) => { }, timeout); }; +let registeredSmartBlocks: + | typeof window.roamjs.extension.smartblocks + | undefined; + +const unregisterSmartBlocksCommand = (): void => { + registeredSmartBlocks?.unregisterCommand(SMARTBLOCK_COMMAND); + registeredSmartBlocks = undefined; +}; + +const registerSmartBlocksCommand = (): void => { + const smartblocks = window.roamjs?.extension?.smartblocks; + if (!smartblocks || smartblocks === registeredSmartBlocks) return; + + unregisterSmartBlocksCommand(); + smartblocks.registerCommand({ + text: SMARTBLOCK_COMMAND, + help: "Returns a link to the WorkBench weekly note containing a resolved date.\n\n1: Optional natural-language date expression. Defaults to today.", + handler: + ({ proccessBlockText }) => + async (expression = "today") => { + const date = await resolveSmartBlocksDate({ + expression, + processBlockText: proccessBlockText, + pageTitleToDate: (title) => + window.roamAlphaAPI.util.pageTitleToDate(title) || null, + }); + const pageTitle = getWeeklyPageTitle(date); + if (!pageTitle) { + throw new Error("Could not apply the configured weekly note format"); + } + return `[[${pageTitle}]]`; + }, + }); + registeredSmartBlocks = smartblocks; +}; + const unloads = new Set<() => void>(); export const toggleFeature = ( flag: boolean, @@ -360,17 +403,23 @@ export const toggleFeature = ( }, }).then((a) => unloads.add(() => a.observer?.disconnect())); + const handleSmartBlocksLoaded = () => registerSmartBlocksCommand(); + document.body.addEventListener( + SMARTBLOCKS_LOADED_EVENT, + handleSmartBlocksLoaded + ); + unloads.add(() => + document.body.removeEventListener( + SMARTBLOCKS_LOADED_EVENT, + handleSmartBlocksLoaded + ) + ); + unloads.add(unregisterSmartBlocksCommand); + registerSmartBlocksCommand(); + 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) ?? ""; - }); - navigateToPage(pageName); + const pageName = getWeeklyPageTitle(new Date()); + if (pageName) navigateToPage(pageName); }; const defaultHotkey = window.roamAlphaAPI.platform.isPC ? "alt-w" diff --git a/src/utils/weeklyNotes.ts b/src/utils/weeklyNotes.ts new file mode 100644 index 00000000..d6cb2575 --- /dev/null +++ b/src/utils/weeklyNotes.ts @@ -0,0 +1,79 @@ +import setDay from "date-fns/setDay"; + +export const WEEKLY_NOTE_DAYS = [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +] as const; + +const WEEKLY_NOTE_DATE_REGEX_SOURCE = `{(${WEEKLY_NOTE_DAYS.join("|")}):(.*?)}`; + +export const WEEKLY_NOTE_DATE_REGEX = new RegExp( + WEEKLY_NOTE_DATE_REGEX_SOURCE, + "g" +); + +type FormatWeeklyNoteTitleArgs = { + date: Date; + format: string; + formatDate: (date: Date, format: string) => string | null; +}; + +export const formatWeeklyNoteTitle = ({ + date, + format, + formatDate, +}: FormatWeeklyNoteTitleArgs): string | null => { + const firstPlaceholderDay = + new RegExp(WEEKLY_NOTE_DATE_REGEX_SOURCE).exec(format)?.[1] || "sunday"; + const weekStartsOn = WEEKLY_NOTE_DAYS.indexOf( + firstPlaceholderDay as (typeof WEEKLY_NOTE_DAYS)[number] + ) as 0 | 1 | 2 | 3 | 4 | 5 | 6; + let isValid = true; + + const title = format.replace( + WEEKLY_NOTE_DATE_REGEX, + (_, day: string, dateFormat: string) => { + const dayOfWeek = setDay( + date, + WEEKLY_NOTE_DAYS.indexOf(day as (typeof WEEKLY_NOTE_DAYS)[number]), + { weekStartsOn } + ); + const formatted = formatDate(dayOfWeek, dateFormat); + if (formatted === null) { + isValid = false; + return ""; + } + return formatted; + } + ); + + return isValid ? title : null; +}; + +type ResolveSmartBlocksDateArgs = { + expression: string; + processBlockText: (text: string) => Promise<{ text: string }[]>; + pageTitleToDate: (title: string) => Date | null; +}; + +export const resolveSmartBlocksDate = async ({ + expression, + processBlockText, + pageTitleToDate, +}: ResolveSmartBlocksDateArgs): Promise => { + const blocks = await processBlockText(`<%DATE:${expression}%>`); + 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 "${expression}" to a date`); + } + + return date; +}; diff --git a/tests/weeklyNotes.spec.ts b/tests/weeklyNotes.spec.ts new file mode 100644 index 00000000..c0eab600 --- /dev/null +++ b/tests/weeklyNotes.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from "@playwright/test"; +import dateFnsFormat from "date-fns/format"; +import { + formatWeeklyNoteTitle, + resolveSmartBlocksDate, +} from "../src/utils/weeklyNotes"; + +const formatDate = (date: Date, format: string): string => + dateFnsFormat(date, format, { useAdditionalWeekYearTokens: true }); + +test("formats the configured Monday-to-Sunday week containing a date", () => { + expect( + formatWeeklyNoteTitle({ + date: new Date(2026, 6, 30, 12), + format: "{monday:MM/dd yyyy} - {sunday:MM/dd yyyy}", + formatDate, + }) + ).toBe("07/27 2026 - 08/02 2026"); +}); + +test("uses the first placeholder as the configured start of the week", () => { + expect( + formatWeeklyNoteTitle({ + date: new Date(2026, 7, 7, 12), + format: "Week {wednesday:yyyy-MM-dd} to {tuesday:yyyy-MM-dd}", + formatDate, + }) + ).toBe("Week 2026-08-05 to 2026-08-11"); +}); + +test("formats weeks across year boundaries", () => { + expect( + formatWeeklyNoteTitle({ + date: new Date(2026, 11, 31, 12), + format: "{sunday:MM/dd yyyy} - {saturday:MM/dd yyyy}", + formatDate, + }) + ).toBe("12/27 2026 - 01/02 2027"); +}); + +test("returns null when a configured placeholder cannot be formatted", () => { + expect( + formatWeeklyNoteTitle({ + date: new Date(2026, 6, 30, 12), + format: "{monday:invalid}", + formatDate: () => null, + }) + ).toBeNull(); +}); + +test("resolves dates through the SmartBlocks DATE command", async () => { + const expected = new Date(2026, 6, 30, 12); + let processed = ""; + const result = await resolveSmartBlocksDate({ + expression: "In one week", + processBlockText: async (text) => { + processed = text; + return [{ text: "[[July 30th, 2026]]" }]; + }, + pageTitleToDate: (title) => (title === "July 30th, 2026" ? expected : null), + }); + + expect(processed).toBe("<%DATE:In one week%>"); + expect(result).toBe(expected); +}); + +test("rejects output that SmartBlocks did not resolve to a date", async () => { + await expect( + resolveSmartBlocksDate({ + expression: "not a date", + processBlockText: async () => [{ text: "Could not resolve date" }], + pageTitleToDate: () => null, + }) + ).rejects.toThrow('Could not resolve "not a date" to a date'); +});