diff --git a/src/utils/core.ts b/src/utils/core.ts index e399e5d..e2ecf60 100644 --- a/src/utils/core.ts +++ b/src/utils/core.ts @@ -70,6 +70,7 @@ import apiPost from "roamjs-components/util/apiPost"; import deleteBlock from "roamjs-components/writes/deleteBlock"; import { zCommandOutput } from "./zodTypes"; import { z } from "zod"; +import splitSmartBlockArgs from "./splitSmartBlockArgs"; type FormDialogProps = Parameters[0]; const renderFormDialog = createOverlayRender( @@ -128,6 +129,17 @@ const getDateFromBlock = (args: { text: string; title: string }) => { if (fromTitle) return window.roamAlphaAPI.util.pageTitleToDate(fromTitle); return new Date(""); }; + +const parseBlockMentionsDatedArg = (dateArg: string, referenceDate: Date) => { + const normalizedArg = dateArg.trim().replace(/^first of\b/i, "start of"); + const title = + DAILY_REF_REGEX.exec(normalizedArg)?.[1] || + DAILY_NOTE_PAGE_TITLE_REGEX.exec(extractTag(normalizedArg))?.[0]; + return title + ? window.roamAlphaAPI.util.pageTitleToDate(title) || + parseNlpDate(normalizedArg, referenceDate) + : parseNlpDate(normalizedArg, referenceDate); +}; const getPageUidByBlockUid = (blockUid: string): string => ( window.roamAlphaAPI.q( @@ -1218,11 +1230,11 @@ export const COMMANDS: { const undated = startArg === "-1" && endArg === "-1"; const start = !undated && startArg && startArg !== "0" - ? startOfDay(parseNlpDate(startArg, referenceDate)) + ? startOfDay(parseBlockMentionsDatedArg(startArg, referenceDate)) : new Date(0); const end = !undated && endArg && endArg !== "0" - ? endOfDay(parseNlpDate(endArg, referenceDate)) + ? endOfDay(parseBlockMentionsDatedArg(endArg, referenceDate)) : new Date(9999, 11, 31); const limit = Number(limitArg); const title = extractTag(titleArg); @@ -2479,24 +2491,7 @@ const processBlockTextToPromises = (s: string) => { const split = c.value.indexOf(":"); const cmd = split < 0 ? c.value : c.value.substring(0, split); const afterColon = split < 0 ? "" : c.value.substring(split + 1); - let commandStack = 0; - const args = afterColon.split("").reduce((prev, cur, i, arr) => { - if (cur === "," && !commandStack && arr[i - 1] !== "\\") { - return [...prev, ""]; - } else if (cur === "\\" && arr[i + 1] === ",") { - return prev; - } else { - if (cur === "%") { - if (arr[i - 1] === "<") { - commandStack++; - } else if (arr[i + 1] === ">") { - commandStack--; - } - } - const current = prev.slice(-1)[0] || ""; - return [...prev.slice(0, -1), `${current}${cur}`]; - } - }, [] as string[]); + const args = splitSmartBlockArgs(cmd, afterColon); const { handler, delayArgs, illegal } = handlerByCommand[cmd] || {}; if (illegal) smartBlocksContext.illegalCommands.add(cmd); return ( diff --git a/src/utils/splitSmartBlockArgs.ts b/src/utils/splitSmartBlockArgs.ts new file mode 100644 index 0000000..6f7e4c6 --- /dev/null +++ b/src/utils/splitSmartBlockArgs.ts @@ -0,0 +1,72 @@ +const MONTH_DAY_REGEX = + /^(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+\d{1,2}(?:st|nd|rd|th)?$/i; +const YEAR_REGEX = /^\d{4}$/; + +/** + * Re-joins "Month Day, Year" date tokens that were split on commas. + * + * BLOCKMENTIONSDATED signature: (limit, title, startDate, endDate, sort, format, ...search) + * Positions 0-1 (limit, title) are passed through unchanged. + * Starting at position 2, up to 2 date tokens are coalesced (startDate + endDate). + * The `mergedDates < 2` guard stops coalescing after both date slots are filled. + */ +const coalesceBlockMentionsDatedDates = (args: string[]) => { + const merged = args.slice(0, 2); + let i = 2; + let mergedDates = 0; + while (i < args.length) { + const current = args[i] || ""; + const next = args[i + 1]; + if ( + mergedDates < 2 && + typeof next === "string" && + MONTH_DAY_REGEX.test(current.trim()) && + YEAR_REGEX.test(next.trim()) + ) { + merged.push(`${current.trimEnd()}, ${next.trimStart()}`); + i += 2; + mergedDates += 1; + } else { + merged.push(current); + i += 1; + } + } + return merged; +}; + +const splitSmartBlockArgs = (cmd: string, afterColon: string) => { + let commandStack = 0; + let pageRefStack = 0; + const args = [] as string[]; + for (let i = 0; i < afterColon.length; i += 1) { + const cur = afterColon[i]; + const prev = afterColon[i - 1]; + const next = afterColon[i + 1]; + if (cur === "%" && prev === "<") { + commandStack += 1; + } else if (cur === "%" && next === ">" && commandStack) { + commandStack -= 1; + } else if (cur === "[" && next === "[") { + pageRefStack += 1; + } else if (cur === "]" && prev === "]" && pageRefStack) { + pageRefStack -= 1; + } + if (cur === "," && !commandStack && !pageRefStack && prev !== "\\") { + args.push(""); + continue; + } else if (cur === "\\" && next === ",") { + continue; + } + const current = args[args.length - 1] || ""; + if (!args.length) { + args.push(cur); + } else { + args[args.length - 1] = `${current}${cur}`; + } + } + return cmd.toUpperCase() === "BLOCKMENTIONSDATED" + ? coalesceBlockMentionsDatedDates(args) + : args; +}; + +export default splitSmartBlockArgs; diff --git a/tests/splitSmartBlockArgs.test.ts b/tests/splitSmartBlockArgs.test.ts new file mode 100644 index 0000000..62e7cab --- /dev/null +++ b/tests/splitSmartBlockArgs.test.ts @@ -0,0 +1,75 @@ +import { test, expect } from "@playwright/test"; +import splitSmartBlockArgs from "../src/utils/splitSmartBlockArgs"; + +test("splits nested smartblock commands as a single argument", () => { + expect( + splitSmartBlockArgs("ANYCOMMAND", "one,<%RANDOMNUMBER:1,10%>,two") + ).toEqual(["one", "<%RANDOMNUMBER:1,10%>", "two"]); +}); + +test("preserves commas in daily note page references", () => { + expect( + splitSmartBlockArgs( + "BLOCKMENTIONSDATED", + "10,DONE,[[January 24th, 2023]],[[January 1st, 2023]]" + ) + ).toEqual(["10", "DONE", "[[January 24th, 2023]]", "[[January 1st, 2023]]"]); +}); + +test("coalesces month-day-year date tokens for BLOCKMENTIONSDATED", () => { + expect( + splitSmartBlockArgs( + "BLOCKMENTIONSDATED", + "10,DONE,February 1, 2023,February 24, 2023,DESC" + ) + ).toEqual(["10", "DONE", "February 1, 2023", "February 24, 2023", "DESC"]); +}); + +test("handles unclosed [[ gracefully by treating rest as single arg", () => { + expect( + splitSmartBlockArgs("BLOCKMENTIONSDATED", "10,DONE,[[January 24th") + ).toEqual(["10", "DONE", "[[January 24th"]); +}); + +test("preserves commas inside nested page references", () => { + expect( + splitSmartBlockArgs( + "BLOCKMENTIONSDATED", + "10,DONE,[[January 24th, 2023]],today" + ) + ).toEqual(["10", "DONE", "[[January 24th, 2023]]", "today"]); +}); + +test("coalesces at most two date tokens for BLOCKMENTIONSDATED", () => { + expect( + splitSmartBlockArgs( + "BLOCKMENTIONSDATED", + "10,DONE,January 1, 2023,February 1, 2023,March 1, 2023" + ) + ).toEqual([ + "10", + "DONE", + "January 1, 2023", + "February 1, 2023", + "March 1", + " 2023", + ]); +}); + +test("does not coalesce date tokens for non-BLOCKMENTIONSDATED commands", () => { + expect( + splitSmartBlockArgs("ANYCOMMAND", "January 1, 2023") + ).toEqual(["January 1", " 2023"]); +}); + +test("unclosed [[ in non-BLOCKMENTIONSDATED treats remaining as single arg", () => { + expect( + splitSmartBlockArgs("ANYCOMMAND", "one,[[unclosed,two,three") + ).toEqual(["one", "[[unclosed,two,three"]); +}); + +test("balanced [[ ]] followed by normal args splits correctly", () => { + expect( + splitSmartBlockArgs("ANYCOMMAND", "[[page ref]],normal,arg") + ).toEqual(["[[page ref]]", "normal", "arg"]); +});