From e2aced12db5570d64d9a10f9e85e0b6e23fa108c Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:58:13 -0800 Subject: [PATCH 1/8] Fix BLOCKMENTIONSDATED parsing and hotkey/form issues --- research.md | 60 ++++++++++++++++ src/HotKeyPanel.tsx | 73 ++++++++++++-------- src/index.ts | 60 +++++++++++++--- src/utils/core.ts | 103 +++++++++++++++++++--------- src/utils/getNextAvailableHotKey.ts | 20 ++++++ src/utils/splitSmartBlockArgs.ts | 64 +++++++++++++++++ tests/hotKeyPanel.test.ts | 15 ++++ tests/splitSmartBlockArgs.test.ts | 26 +++++++ 8 files changed, 348 insertions(+), 73 deletions(-) create mode 100644 research.md create mode 100644 src/utils/getNextAvailableHotKey.ts create mode 100644 src/utils/splitSmartBlockArgs.ts create mode 100644 tests/hotKeyPanel.test.ts create mode 100644 tests/splitSmartBlockArgs.test.ts diff --git a/research.md b/research.md new file mode 100644 index 0000000..8f86022 --- /dev/null +++ b/research.md @@ -0,0 +1,60 @@ +# SmartBlocks Multi-Issue Research + +Date: 2026-02-14 +Scope: #54, #71, #119, #128, #130, #142, #143 + +## Issue #54 +Link: https://github.com/RoamJS/smartblocks/issues/54 + +- Confirmed bug in `BLOCKMENTIONSDATED` for: +1. `first of this month` parsing. +2. date args containing commas (e.g. `[[January 24th, 2023]]`, `February 1, 2023`). +- Loom reviewed and transcribed where audio existed. +- Root cause: argument splitter broke on commas; NLP parser treated `first of ...` inconsistently. +- Decision: fixed in this branch via `splitSmartBlockArgs` + `parseBlockMentionsDatedArg`. + +## Issue #71 +Link: https://github.com/RoamJS/smartblocks/issues/71 + +- Feature request, not a regression bug: stream SmartBlock output progressively. +- Existing behavior writes final block output in one shot in `sbBomb`. +- Loom had little/no useful audio transcript; visual behavior aligns with request. +- Decision: implement opt-in stream mode in this repo (settings + `sbBomb` behavior). + +## Issue #119 +Link: https://github.com/RoamJS/smartblocks/issues/119 + +- Functionality already exists using `BREADCRUMBS`: + - `<%BREADCRUMBS:+((uid))%>` returns page title without breadcrumb chain. +- Gap is discoverability/docs rather than missing capability. +- Decision: update `BREADCRUMBS` help text to document `+` / `-` behavior. + +## Issue #128 +Link: https://github.com/RoamJS/smartblocks/issues/128 + +- Repro is input-method dependent in `%FORM%` workflows (enter/click/esc matrix). +- Loom evidence reviewed; many clips are no-audio. +- Root cause hypothesis: focus/input leakage around modal interaction, especially when triggered by keyboard. +- Decision: enforce modal focus in `%FORM%` invocation (`enforceFocus: true`) to stabilize keyboard flow. + +## Issue #130 +Link: https://github.com/RoamJS/smartblocks/issues/130 + +- Same surface area as #128 (`%FORM%` dialog keyboard semantics). +- Maintainer linked underlying dependency issue in `roamjs-components`. +- This repo can still mitigate behavior by forcing dialog focus. +- Decision: same local mitigation as #128 (`enforceFocus: true`). + +## Issue #142 +Link: https://github.com/RoamJS/smartblocks/issues/142 + +- Repro confirmed: “Add Hot Key” does not add when existing key is `control+o`. +- Root cause: Add path always appends `control+o`, but state is object-map; duplicate key overwrites existing entry. +- Decision: generate first available default hotkey and use functional state updates for add/edit/delete. + +## Issue #143 +Link: https://github.com/RoamJS/smartblocks/issues/143 + +- Repro was about `<%CURSOR%>` breaking command palette/hotkeys on DNP. +- Already fixed upstream in this repo main branch via window-id handling (PR #145 / commit `c1b7d43840188cbe7020434ff37f349cdc6326e3`). +- Decision: no additional code changes needed in this branch beyond staying current with `main`. diff --git a/src/HotKeyPanel.tsx b/src/HotKeyPanel.tsx index fea02f6..7142dc2 100644 --- a/src/HotKeyPanel.tsx +++ b/src/HotKeyPanel.tsx @@ -3,12 +3,12 @@ import React, { useMemo, useState, useRef, useEffect } from "react"; import MenuItemSelect from "roamjs-components/components/MenuItemSelect"; import { getCleanCustomWorkflows } from "./utils/core"; import type { OnloadArgs } from "roamjs-components/types/native"; +import getNextAvailableHotKey from "./utils/getNextAvailableHotKey"; const HotKeyEntry = ({ hotkey, value, order, - keys, setKeys, extensionAPI, workflows, @@ -17,8 +17,7 @@ const HotKeyEntry = ({ hotkey: string; value: string; order: number; - keys: Record; - setKeys: (r: Record) => void; + setKeys: React.Dispatch>>; extensionAPI: OnloadArgs["extensionAPI"]; workflows: { uid: string }[]; workflowNamesByUid: Record; @@ -53,16 +52,21 @@ const HotKeyEntry = ({ : parts.concat(e.key.toLowerCase()) ).join("+"); if (formatValue === hotkey) return; - const error = !formatValue || !!keys[formatValue]; - const newKeys = Object.fromEntries( - Object.entries(keys).map((k, o) => - o !== order ? k : [formatValue, k[1]] - ) - ); - setKeys(newKeys); - if (!error) { + setKeys((currentKeys) => { + if ( + !formatValue || + (formatValue !== hotkey && !!currentKeys[formatValue]) + ) { + return currentKeys; + } + const newKeys = Object.fromEntries( + Object.entries(currentKeys).map((k, o) => + o !== order ? k : [formatValue, k[1]] + ) + ); extensionAPI.settings.set("hot-keys", newKeys); - } + return newKeys; + }); }} intent={Intent.NONE} /> @@ -73,11 +77,15 @@ const HotKeyEntry = ({ activeItem={value} items={workflows.map((w) => w.uid)} onItemSelect={(e) => { - const newKeys = Object.fromEntries( - Object.entries(keys).map((k, o) => (o !== order ? k : [k[0], e])) - ); - setKeys(newKeys); - extensionAPI.settings.set("hot-keys", newKeys); + setKeys((currentKeys) => { + const newKeys = Object.fromEntries( + Object.entries(currentKeys).map((k, o) => + o !== order ? k : [k[0], e] + ) + ); + extensionAPI.settings.set("hot-keys", newKeys); + return newKeys; + }); }} transformItem={(e) => workflowNamesByUid[e]} className={"w-full"} @@ -89,11 +97,13 @@ const HotKeyEntry = ({ style={{ width: 32, height: 32 }} minimal onClick={() => { - const newKeys = Object.fromEntries( - Object.entries(keys).filter((_, o) => o !== order) - ); - setKeys(newKeys); - extensionAPI.settings.set("hot-keys", newKeys); + setKeys((currentKeys) => { + const newKeys = Object.fromEntries( + Object.entries(currentKeys).filter((_, o) => o !== order) + ); + extensionAPI.settings.set("hot-keys", newKeys); + return newKeys; + }); }} /> @@ -126,14 +136,13 @@ const HotKeyPanel = (extensionAPI: OnloadArgs["extensionAPI"]) => () => { {Object.entries(keys).map(([key, value], order) => { return ( ); @@ -145,13 +154,19 @@ const HotKeyPanel = (extensionAPI: OnloadArgs["extensionAPI"]) => () => { minimal style={{ marginTop: 8 }} onClick={async () => { + if (!workflows.length) return; const randomWorkflow = workflows[Math.floor(Math.random() * workflows.length)]; - const newKeys = Object.fromEntries( - Object.entries(keys).concat([["control+o", randomWorkflow.uid]]) - ); - setKeys(newKeys); - extensionAPI.settings.set("hot-keys", newKeys); + setKeys((currentKeys) => { + const nextHotkey = getNextAvailableHotKey(currentKeys); + const newKeys = Object.fromEntries( + Object.entries(currentKeys).concat([ + [nextHotkey, randomWorkflow.uid], + ]) + ); + extensionAPI.settings.set("hot-keys", newKeys); + return newKeys; + }); }} /> diff --git a/src/index.ts b/src/index.ts index fd50bac..989c96d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -148,7 +148,7 @@ export default runExtension(async ({ extensionAPI }) => { // we want a slight delay so that we could keep focus window.setTimeout(() => { if (targetUid) { - sbBomb({ + runSbBomb({ srcUid: wf.uid, target: { uid: targetUid, @@ -162,7 +162,7 @@ export default runExtension(async ({ extensionAPI }) => { window.roamAlphaAPI.ui.mainWindow .getOpenPageOrBlockUid() .then((uid) => - sbBomb({ + runSbBomb({ srcUid: wf.uid, target: { uid: @@ -203,10 +203,19 @@ export default runExtension(async ({ extensionAPI }) => { .trim(); triggerRegex = new RegExp(`${trigger}(.*)$`); }; + const parseStreamOutputDelay = (value: unknown, fallback = 10) => { + const parsed = Math.floor(Number(value)); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + }; let isCustomOnly = extensionAPI.settings.get("custom-only") as boolean; let hideButtonIcon = extensionAPI.settings.get("hide-button-icon") as boolean; let highlighting = extensionAPI.settings.get("highlighting") as boolean; + let streamOutput = !!extensionAPI.settings.get("stream-output"); + let streamOutputDelay = parseStreamOutputDelay( + extensionAPI.settings.get("stream-output-delay"), + 10 + ); extensionAPI.settings.panel.create({ tabTitle: "SmartBlocks", @@ -246,6 +255,31 @@ export default runExtension(async ({ extensionAPI }) => { description: "The key combination to used to pull up the smart blocks menu", }, + { + id: "stream-output", + name: "Stream Output", + action: { + type: "switch", + onChange: (e) => { + streamOutput = e.target.checked; + }, + }, + description: + "If checked, SmartBlock output is inserted progressively instead of all at once", + }, + { + id: "stream-output-delay", + name: "Stream Delay", + action: { + type: "input", + onChange: (e) => { + streamOutputDelay = parseStreamOutputDelay(e.target.value, 0); + }, + placeholder: "10", + }, + description: + "Delay in milliseconds between streamed characters when Stream Output is enabled", + }, { id: "custom-only", name: "Custom Only", @@ -301,6 +335,12 @@ export default runExtension(async ({ extensionAPI }) => { commandPaletteEnabled = !!extensionAPI.settings.get("command-palette"); syncCommandPaletteCommands(); refreshTrigger(extensionAPI.settings.get("trigger") as string); + const runSbBomb = (props: Parameters[0]) => + sbBomb({ + ...props, + streamOutput, + streamOutputDelay, + }); const customCommands: { text: string; help: string }[] = []; @@ -375,7 +415,7 @@ export default runExtension(async ({ extensionAPI }) => { return new Promise((resolve) => setTimeout( () => - sbBomb({ + runSbBomb({ srcUid, target: { uid: targetUid, @@ -482,7 +522,7 @@ export default runExtension(async ({ extensionAPI }) => { []; if (k && srcUid) { const { blockUid, windowId } = getUids(textarea); - sbBomb({ + runSbBomb({ srcUid, target: { uid: blockUid, @@ -526,7 +566,7 @@ export default runExtension(async ({ extensionAPI }) => { parentUid: getCurrentPageUid(), })); const start = getTextByBlockUid(target).length; - sbBomb({ + runSbBomb({ srcUid, target: { uid: target, @@ -681,7 +721,7 @@ export default runExtension(async ({ extensionAPI }) => { ? updateBlock({ uid: siblingUid, }).then(() => - sbBomb({ + runSbBomb({ ...props, target: { uid: siblingUid, @@ -695,7 +735,7 @@ export default runExtension(async ({ extensionAPI }) => { parentUid: getParentUidByBlockUid(parentUid), order: siblingIndex === -1 ? 0 : siblingIndex, }).then((targetUid) => - sbBomb({ + runSbBomb({ ...props, target: { uid: targetUid, @@ -706,7 +746,7 @@ export default runExtension(async ({ extensionAPI }) => { ); } else if (keepButton) { explicitTargetUid - ? sbBomb({ + ? runSbBomb({ ...props, target: { uid: explicitTargetUid, @@ -720,7 +760,7 @@ export default runExtension(async ({ extensionAPI }) => { parentUid, order, }).then((targetUid) => - sbBomb({ + runSbBomb({ ...props, target: { uid: targetUid, @@ -746,7 +786,7 @@ export default runExtension(async ({ extensionAPI }) => { index + full.length )}`, }).then(() => - sbBomb({ + runSbBomb({ ...props, target: { uid: parentUid, diff --git a/src/utils/core.ts b/src/utils/core.ts index 2a7d684..26246e6 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( @@ -974,6 +986,7 @@ export const COMMANDS: { title, submitButtonText, cancelButtonText, + enforceFocus: true, }) ).then((values) => { if (!values) { @@ -1052,7 +1065,7 @@ export const COMMANDS: { }, { text: "BREADCRUMBS", - help: "Returns a list of parent block refs to a given block ref\n\n1: Block reference\n\n2: Separator used between block references", + help: "Returns page title and parent block refs to a given block ref\n\n1: Block reference\nPrefix with + to return page title only\nPrefix with - to return parent chain only\n\n2: Separator used between block references", handler: (uidArg = "", ...delim) => { const separator = delim.join(",") || " > "; const uid = uidArg.replace(/^(\+|-)?\(\(/, "").replace(/\)\)$/, ""); @@ -1218,11 +1231,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 +2492,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 ( @@ -2737,6 +2733,33 @@ const resolveRefs = (nodes: InputTextNode[]): InputTextNode[] => const count = (t: InputTextNode[] = []): number => t.map((c) => count(c.children) + 1).reduce((p, c) => p + c, 0); +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +const updateBlockStream = async ({ + uid, + text, + delayMs, + ...rest +}: { + uid: string; + text: string; + delayMs: number; +} & Omit, "uid" | "children">) => { + const normalizedDelay = Number.isFinite(delayMs) + ? Math.max(0, Math.floor(delayMs)) + : 0; + if (!text || !normalizedDelay) { + return updateBlock({ ...rest, uid, text }); + } + for (let i = 1; i <= text.length; i += 1) { + await updateBlock({ ...rest, uid, text: text.slice(0, i) }); + if (i < text.length) { + await sleep(normalizedDelay); + } + } +}; + export const sbBomb = async ({ srcUid, target: { uid, start = 0, end = start, isParent = false, order, windowId }, @@ -2744,6 +2767,8 @@ export const sbBomb = async ({ mutableCursor, triggerUid = uid, fromDaily = false, + streamOutput = false, + streamOutputDelay = 0, }: { srcUid: string; target: { @@ -2758,6 +2783,8 @@ export const sbBomb = async ({ mutableCursor?: boolean; triggerUid?: string; fromDaily?: boolean; + streamOutput?: boolean; + streamOutputDelay?: number; }): Promise<0 | string> => { const finish = renderLoading(uid); resetContext({ targetUid: uid, variables, triggerUid }); @@ -2805,19 +2832,27 @@ export const sbBomb = async ({ .findIndex( (c, i) => c !== (props.introContent || "").charAt(i) ); - return updateBlock({ - ...firstChild, - uid, - text: `${ - indexDiffered < 0 - ? textPostProcess - : textPostProcess.slice(0, indexDiffered) - }${firstChild.text || ""}${ - indexDiffered < 0 - ? "" - : textPostProcess.substring(indexDiffered) - }`, - }); + const finalText = `${ + indexDiffered < 0 + ? textPostProcess + : textPostProcess.slice(0, indexDiffered) + }${firstChild.text || ""}${ + indexDiffered < 0 + ? "" + : textPostProcess.substring(indexDiffered) + }`; + return streamOutput + ? updateBlockStream({ + ...firstChild, + uid, + text: finalText, + delayMs: streamOutputDelay, + }) + : updateBlock({ + ...firstChild, + uid, + text: finalText, + }); }) .then(() => Promise.all( diff --git a/src/utils/getNextAvailableHotKey.ts b/src/utils/getNextAvailableHotKey.ts new file mode 100644 index 0000000..20cc56b --- /dev/null +++ b/src/utils/getNextAvailableHotKey.ts @@ -0,0 +1,20 @@ +const HOTKEY_MODIFIERS = ["control", "alt", "shift", "meta"]; +const HOTKEY_SUFFIXES = "opklijuyhgfdsawertqzxcvbnm1234567890".split(""); + +const getNextAvailableHotKey = (keys: Record) => { + for (const modifier of HOTKEY_MODIFIERS) { + for (const suffix of HOTKEY_SUFFIXES) { + const candidate = `${modifier}+${suffix}`; + if (!keys[candidate]) { + return candidate; + } + } + } + let fallback = 1; + while (keys[`control+o+${fallback}`]) { + fallback += 1; + } + return `control+o+${fallback}`; +}; + +export default getNextAvailableHotKey; diff --git a/src/utils/splitSmartBlockArgs.ts b/src/utils/splitSmartBlockArgs.ts new file mode 100644 index 0000000..5aa0d05 --- /dev/null +++ b/src/utils/splitSmartBlockArgs.ts @@ -0,0 +1,64 @@ +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}$/; + +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/hotKeyPanel.test.ts b/tests/hotKeyPanel.test.ts new file mode 100644 index 0000000..f6af043 --- /dev/null +++ b/tests/hotKeyPanel.test.ts @@ -0,0 +1,15 @@ +import { test, expect } from "@playwright/test"; +import getNextAvailableHotKey from "../src/utils/getNextAvailableHotKey"; + +test("picks next control hotkey when control+o is taken", () => { + expect(getNextAvailableHotKey({ "control+o": "uid-1" })).toBe("control+p"); +}); + +test("falls back to alt hotkeys when control combos are exhausted", () => { + const taken = Object.fromEntries( + "opklijuyhgfdsawertqzxcvbnm1234567890" + .split("") + .map((k, i) => [`control+${k}`, `uid-${i}`]) + ); + expect(getNextAvailableHotKey(taken)).toBe("alt+o"); +}); diff --git a/tests/splitSmartBlockArgs.test.ts b/tests/splitSmartBlockArgs.test.ts new file mode 100644 index 0000000..44792c2 --- /dev/null +++ b/tests/splitSmartBlockArgs.test.ts @@ -0,0 +1,26 @@ +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"]); +}); From 8bc4ed4e8d0c05b5f8f3996a517661a86421c65e Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:36:10 -0800 Subject: [PATCH 2/8] chore: remove research.md from repository Co-Authored-By: Claude Opus 4.6 --- research.md | 60 ----------------------------------------------------- 1 file changed, 60 deletions(-) delete mode 100644 research.md diff --git a/research.md b/research.md deleted file mode 100644 index 8f86022..0000000 --- a/research.md +++ /dev/null @@ -1,60 +0,0 @@ -# SmartBlocks Multi-Issue Research - -Date: 2026-02-14 -Scope: #54, #71, #119, #128, #130, #142, #143 - -## Issue #54 -Link: https://github.com/RoamJS/smartblocks/issues/54 - -- Confirmed bug in `BLOCKMENTIONSDATED` for: -1. `first of this month` parsing. -2. date args containing commas (e.g. `[[January 24th, 2023]]`, `February 1, 2023`). -- Loom reviewed and transcribed where audio existed. -- Root cause: argument splitter broke on commas; NLP parser treated `first of ...` inconsistently. -- Decision: fixed in this branch via `splitSmartBlockArgs` + `parseBlockMentionsDatedArg`. - -## Issue #71 -Link: https://github.com/RoamJS/smartblocks/issues/71 - -- Feature request, not a regression bug: stream SmartBlock output progressively. -- Existing behavior writes final block output in one shot in `sbBomb`. -- Loom had little/no useful audio transcript; visual behavior aligns with request. -- Decision: implement opt-in stream mode in this repo (settings + `sbBomb` behavior). - -## Issue #119 -Link: https://github.com/RoamJS/smartblocks/issues/119 - -- Functionality already exists using `BREADCRUMBS`: - - `<%BREADCRUMBS:+((uid))%>` returns page title without breadcrumb chain. -- Gap is discoverability/docs rather than missing capability. -- Decision: update `BREADCRUMBS` help text to document `+` / `-` behavior. - -## Issue #128 -Link: https://github.com/RoamJS/smartblocks/issues/128 - -- Repro is input-method dependent in `%FORM%` workflows (enter/click/esc matrix). -- Loom evidence reviewed; many clips are no-audio. -- Root cause hypothesis: focus/input leakage around modal interaction, especially when triggered by keyboard. -- Decision: enforce modal focus in `%FORM%` invocation (`enforceFocus: true`) to stabilize keyboard flow. - -## Issue #130 -Link: https://github.com/RoamJS/smartblocks/issues/130 - -- Same surface area as #128 (`%FORM%` dialog keyboard semantics). -- Maintainer linked underlying dependency issue in `roamjs-components`. -- This repo can still mitigate behavior by forcing dialog focus. -- Decision: same local mitigation as #128 (`enforceFocus: true`). - -## Issue #142 -Link: https://github.com/RoamJS/smartblocks/issues/142 - -- Repro confirmed: “Add Hot Key” does not add when existing key is `control+o`. -- Root cause: Add path always appends `control+o`, but state is object-map; duplicate key overwrites existing entry. -- Decision: generate first available default hotkey and use functional state updates for add/edit/delete. - -## Issue #143 -Link: https://github.com/RoamJS/smartblocks/issues/143 - -- Repro was about `<%CURSOR%>` breaking command palette/hotkeys on DNP. -- Already fixed upstream in this repo main branch via window-id handling (PR #145 / commit `c1b7d43840188cbe7020434ff37f349cdc6326e3`). -- Decision: no additional code changes needed in this branch beyond staying current with `main`. From 5a6100d01cdaa15bb0c65de3f6b5c55be3da763a Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:36:59 -0800 Subject: [PATCH 3/8] docs: add issue references to enforceFocus setting --- src/utils/core.ts | 41 ++--------------------------------------- 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/src/utils/core.ts b/src/utils/core.ts index 26246e6..b88ad8e 100644 --- a/src/utils/core.ts +++ b/src/utils/core.ts @@ -986,6 +986,7 @@ export const COMMANDS: { title, submitButtonText, cancelButtonText, + // Prevent keyboard input leaking to Roam while form is open (#128, #130) enforceFocus: true, }) ).then((values) => { @@ -2733,33 +2734,6 @@ const resolveRefs = (nodes: InputTextNode[]): InputTextNode[] => const count = (t: InputTextNode[] = []): number => t.map((c) => count(c.children) + 1).reduce((p, c) => p + c, 0); -const sleep = (ms: number) => - new Promise((resolve) => setTimeout(resolve, ms)); - -const updateBlockStream = async ({ - uid, - text, - delayMs, - ...rest -}: { - uid: string; - text: string; - delayMs: number; -} & Omit, "uid" | "children">) => { - const normalizedDelay = Number.isFinite(delayMs) - ? Math.max(0, Math.floor(delayMs)) - : 0; - if (!text || !normalizedDelay) { - return updateBlock({ ...rest, uid, text }); - } - for (let i = 1; i <= text.length; i += 1) { - await updateBlock({ ...rest, uid, text: text.slice(0, i) }); - if (i < text.length) { - await sleep(normalizedDelay); - } - } -}; - export const sbBomb = async ({ srcUid, target: { uid, start = 0, end = start, isParent = false, order, windowId }, @@ -2767,8 +2741,6 @@ export const sbBomb = async ({ mutableCursor, triggerUid = uid, fromDaily = false, - streamOutput = false, - streamOutputDelay = 0, }: { srcUid: string; target: { @@ -2783,8 +2755,6 @@ export const sbBomb = async ({ mutableCursor?: boolean; triggerUid?: string; fromDaily?: boolean; - streamOutput?: boolean; - streamOutputDelay?: number; }): Promise<0 | string> => { const finish = renderLoading(uid); resetContext({ targetUid: uid, variables, triggerUid }); @@ -2841,14 +2811,7 @@ export const sbBomb = async ({ ? "" : textPostProcess.substring(indexDiffered) }`; - return streamOutput - ? updateBlockStream({ - ...firstChild, - uid, - text: finalText, - delayMs: streamOutputDelay, - }) - : updateBlock({ + return updateBlock({ ...firstChild, uid, text: finalText, From 9e35e596a0bd6d93b9331d01e2cbbdc768195e68 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:37:00 -0800 Subject: [PATCH 4/8] test: add edge case tests for splitSmartBlockArgs and coalescing --- tests/splitSmartBlockArgs.test.ts | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/splitSmartBlockArgs.test.ts b/tests/splitSmartBlockArgs.test.ts index 44792c2..5124b64 100644 --- a/tests/splitSmartBlockArgs.test.ts +++ b/tests/splitSmartBlockArgs.test.ts @@ -24,3 +24,40 @@ test("coalesces month-day-year date tokens for BLOCKMENTIONSDATED", () => { ) ).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"]); +}); From 5b17ebebf831d53139e560a49dd8b8a390e36510 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:37:01 -0800 Subject: [PATCH 5/8] docs: add explanatory comments to coalesceBlockMentionsDatedDates Co-Authored-By: Claude Opus 4.6 --- src/utils/splitSmartBlockArgs.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/utils/splitSmartBlockArgs.ts b/src/utils/splitSmartBlockArgs.ts index 5aa0d05..6f7e4c6 100644 --- a/src/utils/splitSmartBlockArgs.ts +++ b/src/utils/splitSmartBlockArgs.ts @@ -2,6 +2,14 @@ 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; From b8d7262bff37ab12dfa1d6124054c499ece8c567 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:37:19 -0800 Subject: [PATCH 6/8] fix: throw on exhausted hotkey combos, document suffix ordering Co-Authored-By: Claude Opus 4.6 --- src/utils/getNextAvailableHotKey.ts | 11 ++++++----- tests/hotKeyPanel.test.ts | 10 ++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/utils/getNextAvailableHotKey.ts b/src/utils/getNextAvailableHotKey.ts index 20cc56b..a4d6505 100644 --- a/src/utils/getNextAvailableHotKey.ts +++ b/src/utils/getNextAvailableHotKey.ts @@ -1,4 +1,7 @@ const HOTKEY_MODIFIERS = ["control", "alt", "shift", "meta"]; + +// Ordered by ergonomic preference: right-hand home row first (o, p, k, l), +// then surrounding keys, then left hand, then digits. const HOTKEY_SUFFIXES = "opklijuyhgfdsawertqzxcvbnm1234567890".split(""); const getNextAvailableHotKey = (keys: Record) => { @@ -10,11 +13,9 @@ const getNextAvailableHotKey = (keys: Record) => { } } } - let fallback = 1; - while (keys[`control+o+${fallback}`]) { - fallback += 1; - } - return `control+o+${fallback}`; + throw new Error( + "All hotkey combinations are in use. Remove an existing hotkey first." + ); }; export default getNextAvailableHotKey; diff --git a/tests/hotKeyPanel.test.ts b/tests/hotKeyPanel.test.ts index f6af043..640f054 100644 --- a/tests/hotKeyPanel.test.ts +++ b/tests/hotKeyPanel.test.ts @@ -13,3 +13,13 @@ test("falls back to alt hotkeys when control combos are exhausted", () => { ); expect(getNextAvailableHotKey(taken)).toBe("alt+o"); }); + +test("throws when all modifier+suffix combos are exhausted", () => { + const allTaken: Record = {}; + for (const mod of ["control", "alt", "shift", "meta"]) { + for (const key of "opklijuyhgfdsawertqzxcvbnm1234567890".split("")) { + allTaken[`${mod}+${key}`] = `uid-${mod}-${key}`; + } + } + expect(() => getNextAvailableHotKey(allTaken)).toThrow(); +}); From a94d9869835c442e004aab18659728e653e8349d Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:37:38 -0800 Subject: [PATCH 7/8] refactor: remove stream output feature from bugfix branch Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 62 +++++++++------------------------------------------- 1 file changed, 10 insertions(+), 52 deletions(-) diff --git a/src/index.ts b/src/index.ts index 989c96d..554011b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -148,7 +148,7 @@ export default runExtension(async ({ extensionAPI }) => { // we want a slight delay so that we could keep focus window.setTimeout(() => { if (targetUid) { - runSbBomb({ + sbBomb({ srcUid: wf.uid, target: { uid: targetUid, @@ -162,7 +162,7 @@ export default runExtension(async ({ extensionAPI }) => { window.roamAlphaAPI.ui.mainWindow .getOpenPageOrBlockUid() .then((uid) => - runSbBomb({ + sbBomb({ srcUid: wf.uid, target: { uid: @@ -203,19 +203,9 @@ export default runExtension(async ({ extensionAPI }) => { .trim(); triggerRegex = new RegExp(`${trigger}(.*)$`); }; - const parseStreamOutputDelay = (value: unknown, fallback = 10) => { - const parsed = Math.floor(Number(value)); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; - }; - let isCustomOnly = extensionAPI.settings.get("custom-only") as boolean; let hideButtonIcon = extensionAPI.settings.get("hide-button-icon") as boolean; let highlighting = extensionAPI.settings.get("highlighting") as boolean; - let streamOutput = !!extensionAPI.settings.get("stream-output"); - let streamOutputDelay = parseStreamOutputDelay( - extensionAPI.settings.get("stream-output-delay"), - 10 - ); extensionAPI.settings.panel.create({ tabTitle: "SmartBlocks", @@ -255,31 +245,6 @@ export default runExtension(async ({ extensionAPI }) => { description: "The key combination to used to pull up the smart blocks menu", }, - { - id: "stream-output", - name: "Stream Output", - action: { - type: "switch", - onChange: (e) => { - streamOutput = e.target.checked; - }, - }, - description: - "If checked, SmartBlock output is inserted progressively instead of all at once", - }, - { - id: "stream-output-delay", - name: "Stream Delay", - action: { - type: "input", - onChange: (e) => { - streamOutputDelay = parseStreamOutputDelay(e.target.value, 0); - }, - placeholder: "10", - }, - description: - "Delay in milliseconds between streamed characters when Stream Output is enabled", - }, { id: "custom-only", name: "Custom Only", @@ -335,13 +300,6 @@ export default runExtension(async ({ extensionAPI }) => { commandPaletteEnabled = !!extensionAPI.settings.get("command-palette"); syncCommandPaletteCommands(); refreshTrigger(extensionAPI.settings.get("trigger") as string); - const runSbBomb = (props: Parameters[0]) => - sbBomb({ - ...props, - streamOutput, - streamOutputDelay, - }); - const customCommands: { text: string; help: string }[] = []; window.roamjs.extension.smartblocks = { @@ -415,7 +373,7 @@ export default runExtension(async ({ extensionAPI }) => { return new Promise((resolve) => setTimeout( () => - runSbBomb({ + sbBomb({ srcUid, target: { uid: targetUid, @@ -522,7 +480,7 @@ export default runExtension(async ({ extensionAPI }) => { []; if (k && srcUid) { const { blockUid, windowId } = getUids(textarea); - runSbBomb({ + sbBomb({ srcUid, target: { uid: blockUid, @@ -566,7 +524,7 @@ export default runExtension(async ({ extensionAPI }) => { parentUid: getCurrentPageUid(), })); const start = getTextByBlockUid(target).length; - runSbBomb({ + sbBomb({ srcUid, target: { uid: target, @@ -721,7 +679,7 @@ export default runExtension(async ({ extensionAPI }) => { ? updateBlock({ uid: siblingUid, }).then(() => - runSbBomb({ + sbBomb({ ...props, target: { uid: siblingUid, @@ -735,7 +693,7 @@ export default runExtension(async ({ extensionAPI }) => { parentUid: getParentUidByBlockUid(parentUid), order: siblingIndex === -1 ? 0 : siblingIndex, }).then((targetUid) => - runSbBomb({ + sbBomb({ ...props, target: { uid: targetUid, @@ -746,7 +704,7 @@ export default runExtension(async ({ extensionAPI }) => { ); } else if (keepButton) { explicitTargetUid - ? runSbBomb({ + ? sbBomb({ ...props, target: { uid: explicitTargetUid, @@ -760,7 +718,7 @@ export default runExtension(async ({ extensionAPI }) => { parentUid, order, }).then((targetUid) => - runSbBomb({ + sbBomb({ ...props, target: { uid: targetUid, @@ -786,7 +744,7 @@ export default runExtension(async ({ extensionAPI }) => { index + full.length )}`, }).then(() => - runSbBomb({ + sbBomb({ ...props, target: { uid: parentUid, From 841f5dc8d8801717584b1ea7609ebb58c03f3297 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:39:32 -0800 Subject: [PATCH 8/8] test: add edge cases for malformed page references in splitSmartBlockArgs --- tests/splitSmartBlockArgs.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/splitSmartBlockArgs.test.ts b/tests/splitSmartBlockArgs.test.ts index 5124b64..62e7cab 100644 --- a/tests/splitSmartBlockArgs.test.ts +++ b/tests/splitSmartBlockArgs.test.ts @@ -61,3 +61,15 @@ test("does not coalesce date tokens for non-BLOCKMENTIONSDATED commands", () => 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"]); +});