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..554011b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -203,7 +203,6 @@ export default runExtension(async ({ extensionAPI }) => { .trim(); triggerRegex = new RegExp(`${trigger}(.*)$`); }; - 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; @@ -301,7 +300,6 @@ export default runExtension(async ({ extensionAPI }) => { commandPaletteEnabled = !!extensionAPI.settings.get("command-palette"); syncCommandPaletteCommands(); refreshTrigger(extensionAPI.settings.get("trigger") as string); - const customCommands: { text: string; help: string }[] = []; window.roamjs.extension.smartblocks = { diff --git a/src/utils/core.ts b/src/utils/core.ts index 2a7d684..b88ad8e 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,8 @@ export const COMMANDS: { title, submitButtonText, cancelButtonText, + // Prevent keyboard input leaking to Roam while form is open (#128, #130) + enforceFocus: true, }) ).then((values) => { if (!values) { @@ -1052,7 +1066,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 +1232,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 +2493,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 ( @@ -2805,19 +2802,20 @@ export const sbBomb = async ({ .findIndex( (c, i) => c !== (props.introContent || "").charAt(i) ); + const finalText = `${ + indexDiffered < 0 + ? textPostProcess + : textPostProcess.slice(0, indexDiffered) + }${firstChild.text || ""}${ + indexDiffered < 0 + ? "" + : textPostProcess.substring(indexDiffered) + }`; return updateBlock({ - ...firstChild, - uid, - text: `${ - indexDiffered < 0 - ? textPostProcess - : textPostProcess.slice(0, indexDiffered) - }${firstChild.text || ""}${ - indexDiffered < 0 - ? "" - : textPostProcess.substring(indexDiffered) - }`, - }); + ...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..a4d6505 --- /dev/null +++ b/src/utils/getNextAvailableHotKey.ts @@ -0,0 +1,21 @@ +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) => { + for (const modifier of HOTKEY_MODIFIERS) { + for (const suffix of HOTKEY_SUFFIXES) { + const candidate = `${modifier}+${suffix}`; + if (!keys[candidate]) { + return candidate; + } + } + } + throw new Error( + "All hotkey combinations are in use. Remove an existing hotkey first." + ); +}; + +export default getNextAvailableHotKey; 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/hotKeyPanel.test.ts b/tests/hotKeyPanel.test.ts new file mode 100644 index 0000000..640f054 --- /dev/null +++ b/tests/hotKeyPanel.test.ts @@ -0,0 +1,25 @@ +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"); +}); + +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(); +}); 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"]); +});