From 4c7c36f7263b9b9bd2bd219fa08d7bee339324f3 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Tue, 30 Jun 2026 17:14:47 -0600 Subject: [PATCH] Add Query Builder page source --- src/components/QuickSwitcherSettings.tsx | 86 +++++++- src/index.ts | 4 +- src/quickSwitcher.tsx | 262 ++++++++++++++++++++++- src/types/quickSwitcher.ts | 6 + src/utils/quickSwitcher.ts | 53 +++++ tests/quickSwitcher.test.ts | 48 +++++ 6 files changed, 455 insertions(+), 4 deletions(-) diff --git a/src/components/QuickSwitcherSettings.tsx b/src/components/QuickSwitcherSettings.tsx index 142d700..7e68211 100644 --- a/src/components/QuickSwitcherSettings.tsx +++ b/src/components/QuickSwitcherSettings.tsx @@ -4,6 +4,7 @@ import { Dialog, FormGroup, InputGroup, + Switch, Tag, TextArea, } from "@blueprintjs/core"; @@ -12,7 +13,10 @@ import PageInput from "roamjs-components/components/PageInput"; import { render as renderToast } from "roamjs-components/components/Toast"; import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; -import type { QuickSwitcherBookmark } from "~/types/quickSwitcher"; +import type { + QuickSwitcherBookmark, + QuickSwitcherQuerySource, +} from "~/types/quickSwitcher"; import { buildRoamPageUrl, createBookmarkId, @@ -26,8 +30,10 @@ import { type QuickSwitcherSettingsDependencies = { initialBookmarks: QuickSwitcherBookmark[]; + initialQuerySource: QuickSwitcherQuerySource; isMac: boolean; onBookmarksChange: (bookmarks: QuickSwitcherBookmark[]) => void; + onQuerySourceChange: (querySource: QuickSwitcherQuerySource) => void; }; type ToastIntent = "none" | "primary" | "success" | "warning" | "danger"; @@ -54,12 +60,22 @@ const isPageUrlInput = ({ entry }: { entry: string }): boolean => export const createQuickSwitcherSettingsComponent = ({ initialBookmarks, + initialQuerySource, isMac, onBookmarksChange, + onQuerySourceChange, }: QuickSwitcherSettingsDependencies): React.FC => { const QuickSwitcherSettings = (): React.ReactElement => { const [bookmarks, setBookmarks] = useState(initialBookmarks); + const [savedQuerySource, setSavedQuerySource] = + useState(initialQuerySource); + const [querySourceEnabled, setQuerySourceEnabled] = useState( + initialQuerySource.enabled, + ); + const [querySourceRef, setQuerySourceRef] = useState( + initialQuerySource.queryRef, + ); const [isManageDialogOpen, setIsManageDialogOpen] = useState(false); const [pageTitle, setPageTitle] = useState(""); const [shortcut, setShortcut] = useState(""); @@ -75,6 +91,9 @@ export const createQuickSwitcherSettingsComponent = ({ : "", [isMac, shortcut], ); + const isQuerySourceDirty = + querySourceEnabled !== savedQuerySource.enabled || + querySourceRef.trim() !== savedQuerySource.queryRef; const setAndPersistBookmarks = ({ nextBookmarks, @@ -94,10 +113,16 @@ export const createQuickSwitcherSettingsComponent = ({ setBulkPages(""); }; + const resetQuerySource = (): void => { + setQuerySourceEnabled(savedQuerySource.enabled); + setQuerySourceRef(savedQuerySource.queryRef); + }; + const closeManageDialog = (): void => { setIsManageDialogOpen(false); clearForm(); clearBulkPages(); + resetQuerySource(); }; const onShortcutKeyDown = ( @@ -280,6 +305,28 @@ export const createQuickSwitcherSettingsComponent = ({ }); }; + const saveQuerySource = (): void => { + const normalizedQueryRef = querySourceRef.trim(); + if (querySourceEnabled && !normalizedQueryRef) { + showToast({ + content: "Add a Query Builder query reference first", + intent: "warning", + }); + return; + } + + const nextQuerySource = { + enabled: querySourceEnabled, + queryRef: normalizedQueryRef, + }; + setSavedQuerySource(nextQuerySource); + onQuerySourceChange(nextQuerySource); + showToast({ + content: "Query Builder source saved", + intent: "success", + }); + }; + return (
@@ -387,6 +434,43 @@ export const createQuickSwitcherSettingsComponent = ({
+
+ ): void => + setQuerySourceEnabled(event.target.checked) + } + /> + + , + ): void => setQuerySourceRef(event.target.value)} + placeholder="Active Projects, queries/Active Projects, or ((abc123def))" + value={querySourceRef} + /> + +
+
+
+
{bookmarks.length ? ( bookmarks.map((bookmark, index) => ( diff --git a/src/index.ts b/src/index.ts index 63ed3a6..8334c94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,12 +7,14 @@ export default runExtension(async ({ extensionAPI }) => { const quickSwitcher = initializeQuickSwitcher({ extensionAPI }); const settingsComponent = createQuickSwitcherSettingsComponent({ initialBookmarks: quickSwitcher.getBookmarks(), + initialQuerySource: quickSwitcher.getQuerySource(), isMac: /mac|iphone|ipad|ipod/i.test( typeof navigator === "undefined" ? "" : `${navigator.platform} ${navigator.userAgent}`, ), onBookmarksChange: quickSwitcher.setBookmarks, + onQuerySourceChange: quickSwitcher.setQuerySource, }); extensionAPI.settings.panel.create({ @@ -32,7 +34,7 @@ export default runExtension(async ({ extensionAPI }) => { id: "bookmarked-pages", name: "Bookmarked Pages", description: - "Add Roam pages with optional shortcuts. These bookmarks are the only pages searchable in the dialog.", + "Add Roam pages with optional shortcuts and optional Query Builder pages.", action: { type: "reactComponent", component: settingsComponent, diff --git a/src/quickSwitcher.tsx b/src/quickSwitcher.tsx index 5dd09f5..b4b9492 100644 --- a/src/quickSwitcher.tsx +++ b/src/quickSwitcher.tsx @@ -1,18 +1,30 @@ import React from "react"; import ReactDOM from "react-dom"; import { render as renderToast } from "roamjs-components/components/Toast"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import type { OnloadArgs } from "roamjs-components/types/native"; +import type { Result as QueryBuilderResult } from "roamjs-components/types/query-builder"; import QuickSwitcherDialog from "~/components/QuickSwitcherDialog"; -import type { QuickSwitcherBookmark } from "~/types/quickSwitcher"; +import type { + QuickSwitcherBookmark, + QuickSwitcherQuerySource, +} from "~/types/quickSwitcher"; import { + buildRoamPageUrl, + extractBlockRefUid, + extractQueryBlockLabel, keyboardEventToShortcut, + normalizeQuerySource, normalizeShortcut, parsePageUidFromUrl, + parseStoredQuerySource, parseStoredBookmarks, toAbsoluteUrl, } from "~/utils/quickSwitcher"; const BOOKMARKS_SETTING_KEY = "quickSwitcherBookmarks"; +const QUERY_SOURCE_SETTING_KEY = "quickSwitcherQuerySource"; const OPEN_QUICK_SWITCHER_COMMAND = "Quick Switcher: Open"; type ExtensionApi = OnloadArgs["extensionAPI"]; @@ -21,8 +33,10 @@ type ToastIntent = "none" | "primary" | "success" | "warning" | "danger"; export type QuickSwitcherController = { getBookmarks: () => QuickSwitcherBookmark[]; + getQuerySource: () => QuickSwitcherQuerySource; open: () => void; setBookmarks: (bookmarks: QuickSwitcherBookmark[]) => void; + setQuerySource: (querySource: QuickSwitcherQuerySource) => void; unload: () => void; }; @@ -135,6 +149,186 @@ const openBookmark = async ({ } }; +const getBookmarkKey = ({ bookmark }: { bookmark: QuickSwitcherBookmark }) => + bookmark.pageUid ? `page:${bookmark.pageUid}` : `url:${bookmark.url}`; + +const mergeBookmarks = ({ + savedBookmarks, + dynamicBookmarks, +}: { + savedBookmarks: QuickSwitcherBookmark[]; + dynamicBookmarks: QuickSwitcherBookmark[]; +}): QuickSwitcherBookmark[] => { + const seen = new Set(); + return [...savedBookmarks, ...dynamicBookmarks].filter((bookmark) => { + const key = getBookmarkKey({ bookmark }); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +}; + +const getQueryBuilderApi = (): { + runQuery: (parentUid: string) => Promise; +} | null => { + const queryBuilder = ( + window as Window & { + roamjs?: { + extension?: { + queryBuilder?: { + runQuery?: (parentUid: string) => Promise; + }; + }; + }; + } + ).roamjs?.extension?.queryBuilder; + return queryBuilder?.runQuery ? { runQuery: queryBuilder.runQuery } : null; +}; + +const getUidIfExists = ({ uid }: { uid: string }): string => { + if (!uid) { + return ""; + } + const pulled = window.roamAlphaAPI.pull("[:block/uid]", [ + ":block/uid", + uid, + ]) as { ":block/uid"?: string } | null; + return pulled?.[":block/uid"] || ""; +}; + +const toDatalogString = ({ value }: { value: string }): string => + JSON.stringify(value); + +const findQueryBlockUidByLabel = ({ label }: { label: string }): string => { + const normalizedLabel = label.trim(); + if (!normalizedLabel) { + return ""; + } + + const queryBlockReference = `{{query block:${normalizedLabel}}}`; + return ( + ( + window.roamAlphaAPI.data.fast.q( + `[:find ?uid :where + [?b :block/uid ?uid] + [?b :block/string ?s] + [(clojure.string/includes? ?s ${toDatalogString({ + value: queryBlockReference, + })})]]`, + ) as string[][] + )[0]?.[0] || "" + ); +}; + +const resolveQueryBuilderUid = ({ queryRef }: { queryRef: string }): string => { + const normalizedQueryRef = queryRef.trim(); + if (!normalizedQueryRef) { + return ""; + } + + const blockRefUid = extractBlockRefUid({ value: normalizedQueryRef }); + if (blockRefUid) { + return getUidIfExists({ uid: blockRefUid }); + } + + const existingUid = getUidIfExists({ uid: normalizedQueryRef }); + if (existingUid) { + return existingUid; + } + + const queryBlockLabel = extractQueryBlockLabel({ + value: normalizedQueryRef, + }); + if (queryBlockLabel) { + return findQueryBlockUidByLabel({ label: queryBlockLabel }); + } + + const pageUid = getPageUidByPageTitle(normalizedQueryRef); + if (pageUid) { + return pageUid; + } + + const defaultQueryPageUid = getPageUidByPageTitle( + `queries/${normalizedQueryRef}`, + ); + if (defaultQueryPageUid) { + return defaultQueryPageUid; + } + + return findQueryBlockUidByLabel({ label: normalizedQueryRef }); +}; + +const getResultUidCandidates = ({ + result, +}: { + result: QueryBuilderResult; +}): string[] => { + const candidates = new Set(); + Object.entries(result).forEach(([key, value]) => { + if (typeof value !== "string") { + return; + } + const normalizedKey = key.toLowerCase(); + if (normalizedKey === "uid" || normalizedKey.endsWith("-uid")) { + candidates.add(value); + } + }); + return [...candidates]; +}; + +const resolveQueryBuilderPageBookmarks = async ({ + querySource, +}: { + querySource: QuickSwitcherQuerySource; +}): Promise => { + if (!querySource.enabled || !querySource.queryRef) { + return []; + } + + const queryBuilder = getQueryBuilderApi(); + if (!queryBuilder) { + return []; + } + + const queryUid = resolveQueryBuilderUid({ queryRef: querySource.queryRef }); + if (!queryUid) { + return []; + } + + const results = await queryBuilder.runQuery(queryUid); + const seenPageUids = new Set(); + return results.reduce((bookmarks, result) => { + const pageUid = getResultUidCandidates({ result }).find((uid) => { + if (seenPageUids.has(uid)) { + return false; + } + return Boolean(getPageTitleByPageUid(uid)); + }); + if (!pageUid) { + return bookmarks; + } + + const title = getPageTitleByPageUid(pageUid); + const url = buildRoamPageUrl({ pageUid }); + if (!title || !url) { + return bookmarks; + } + + seenPageUids.add(pageUid); + bookmarks.push({ + id: `query-builder-${pageUid}`, + title, + pageUid, + url, + shortcut: null, + source: "query-builder", + }); + return bookmarks; + }, []); +}; + const initializeQuickSwitcher = ({ extensionAPI, }: { @@ -149,13 +343,29 @@ const initializeQuickSwitcher = ({ value: extensionAPI.settings.get(BOOKMARKS_SETTING_KEY), }), }); + let querySource = parseStoredQuerySource({ + value: extensionAPI.settings.get(QUERY_SOURCE_SETTING_KEY), + }); + let dynamicBookmarks: QuickSwitcherBookmark[] = []; let isDialogOpen = false; let hasRenderedDialog = false; + let isUnloaded = false; + let refreshQuerySourceId = 0; const persistBookmarks = (): void => { void extensionAPI.settings.set(BOOKMARKS_SETTING_KEY, bookmarks); }; + const persistQuerySource = (): void => { + void extensionAPI.settings.set(QUERY_SOURCE_SETTING_KEY, querySource); + }; + + const getMergedBookmarks = (): QuickSwitcherBookmark[] => + mergeBookmarks({ + savedBookmarks: bookmarks, + dynamicBookmarks, + }); + const closeDialog = (): void => { isDialogOpen = false; if (hasRenderedDialog) { @@ -164,10 +374,13 @@ const initializeQuickSwitcher = ({ }; const render = (): void => { + if (isUnloaded) { + return; + } hasRenderedDialog = true; ReactDOM.render( => { + const refreshId = refreshQuerySourceId + 1; + refreshQuerySourceId = refreshId; + if (dynamicBookmarks.length) { + dynamicBookmarks = []; + if (hasRenderedDialog) { + render(); + } + } + try { + const nextDynamicBookmarks = await resolveQueryBuilderPageBookmarks({ + querySource, + }); + if (refreshId !== refreshQuerySourceId || isUnloaded) { + return; + } + dynamicBookmarks = nextDynamicBookmarks; + if (hasRenderedDialog) { + render(); + } + } catch (error) { + if (refreshId !== refreshQuerySourceId || isUnloaded) { + return; + } + dynamicBookmarks = []; + if (hasRenderedDialog) { + render(); + } + showToast({ + content: "Unable to load Query Builder pages", + intent: "warning", + }); + } + }; + const openDialog = (): void => { isDialogOpen = true; + dynamicBookmarks = []; render(); + void refreshQuerySourceBookmarks(); }; const onDocumentKeyDown = (event: KeyboardEvent): void => { @@ -238,6 +488,7 @@ const initializeQuickSwitcher = ({ return { getBookmarks: (): QuickSwitcherBookmark[] => bookmarks, + getQuerySource: (): QuickSwitcherQuerySource => querySource, open: openDialog, setBookmarks: (nextBookmarks: QuickSwitcherBookmark[]): void => { bookmarks = sanitizeBookmarks({ bookmarks: nextBookmarks }); @@ -246,7 +497,14 @@ const initializeQuickSwitcher = ({ render(); } }, + setQuerySource: (nextQuerySource: QuickSwitcherQuerySource): void => { + querySource = normalizeQuerySource({ querySource: nextQuerySource }); + persistQuerySource(); + void refreshQuerySourceBookmarks(); + }, unload: (): void => { + isUnloaded = true; + refreshQuerySourceId += 1; closeDialog(); document.removeEventListener("keydown", onDocumentKeyDown, true); unregisterCommand(); diff --git a/src/types/quickSwitcher.ts b/src/types/quickSwitcher.ts index 226ebf3..c8a4d0c 100644 --- a/src/types/quickSwitcher.ts +++ b/src/types/quickSwitcher.ts @@ -4,6 +4,12 @@ export type QuickSwitcherBookmark = { url: string; pageUid: string | null; shortcut: string | null; + source?: "saved" | "query-builder"; +}; + +export type QuickSwitcherQuerySource = { + enabled: boolean; + queryRef: string; }; export type ShortcutKeyboardEvent = { diff --git a/src/utils/quickSwitcher.ts b/src/utils/quickSwitcher.ts index 0db8fe8..db5e390 100644 --- a/src/utils/quickSwitcher.ts +++ b/src/utils/quickSwitcher.ts @@ -1,5 +1,6 @@ import type { QuickSwitcherBookmark, + QuickSwitcherQuerySource, ShortcutKeyboardEvent, } from "~/types/quickSwitcher"; @@ -10,6 +11,14 @@ const MODIFIER_EVENT_KEYS = new Set(["control", "meta", "alt", "shift"]); const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; +const BLOCK_REF_REGEX = /\(\(([A-Za-z0-9_-]+)\)\)/; +const QUERY_BLOCK_REGEX = /^\{\{query block(?::(.+?))?\}\}$/i; + +const DEFAULT_QUERY_SOURCE: QuickSwitcherQuerySource = { + enabled: false, + queryRef: "", +}; + const normalizeModifierToken = ({ token }: { token: string }): string => { const normalizedToken = token.toLowerCase().trim(); if (normalizedToken === "cmd" || normalizedToken === "command") { @@ -432,5 +441,49 @@ export const parseStoredBookmarks = ({ .filter((bookmark): bookmark is QuickSwitcherBookmark => Boolean(bookmark)); }; +export const parseStoredQuerySource = ({ + value, +}: { + value: unknown; +}): QuickSwitcherQuerySource => { + if (!isRecord(value)) { + return DEFAULT_QUERY_SOURCE; + } + + const queryRef = typeof value.queryRef === "string" ? value.queryRef : ""; + return { + enabled: Boolean(value.enabled), + queryRef: queryRef.trim(), + }; +}; + +export const normalizeQuerySource = ({ + querySource, +}: { + querySource: QuickSwitcherQuerySource; +}): QuickSwitcherQuerySource => ({ + enabled: Boolean(querySource.enabled), + queryRef: querySource.queryRef.trim(), +}); + +export const extractBlockRefUid = ({ + value, +}: { + value: string; +}): string | null => { + const match = value.match(BLOCK_REF_REGEX); + return match?.[1] || null; +}; + +export const extractQueryBlockLabel = ({ + value, +}: { + value: string; +}): string | null => { + const match = value.trim().match(QUERY_BLOCK_REGEX); + const label = match?.[1]?.trim(); + return label || null; +}; + export const createBookmarkId = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; diff --git a/tests/quickSwitcher.test.ts b/tests/quickSwitcher.test.ts index a0907c0..729834f 100644 --- a/tests/quickSwitcher.test.ts +++ b/tests/quickSwitcher.test.ts @@ -2,13 +2,17 @@ import { expect, test } from "@playwright/test"; import type { QuickSwitcherBookmark } from "../src/types/quickSwitcher"; import { buildRoamPageUrl, + extractBlockRefUid, + extractQueryBlockLabel, filterBookmarks, formatShortcutForDisplay, keyboardEventToShortcut, moveBookmarkByOffset, + normalizeQuerySource, normalizeShortcut, parsePageUidFromUrl, parseStoredBookmarks, + parseStoredQuerySource, shortcutHasModifier, toAbsoluteUrl, } from "../src/utils/quickSwitcher"; @@ -165,6 +169,50 @@ test("parses and sanitizes stored bookmarks", () => { expect(parsed[1].shortcut).toBeNull(); }); +test("parses and normalizes query builder source settings", () => { + expect( + parseStoredQuerySource({ + value: { + enabled: true, + queryRef: " queries/Active Projects ", + }, + }), + ).toEqual({ + enabled: true, + queryRef: "queries/Active Projects", + }); + expect(parseStoredQuerySource({ value: "invalid" })).toEqual({ + enabled: false, + queryRef: "", + }); + expect( + normalizeQuerySource({ + querySource: { + enabled: true, + queryRef: " ((abc123def)) ", + }, + }), + ).toEqual({ + enabled: true, + queryRef: "((abc123def))", + }); +}); + +test("extracts block uids from roam block refs", () => { + expect(extractBlockRefUid({ value: "Run ((abc123_DEF))" })).toBe( + "abc123_DEF", + ); + expect(extractBlockRefUid({ value: "not a block ref" })).toBeNull(); +}); + +test("extracts query builder labels from query block refs", () => { + expect( + extractQueryBlockLabel({ value: "{{query block:Active Projects}}" }), + ).toBe("Active Projects"); + expect(extractQueryBlockLabel({ value: "{{query block}}" })).toBeNull(); + expect(extractQueryBlockLabel({ value: "Active Projects" })).toBeNull(); +}); + test("resolves relative urls to absolute urls", () => { expect(toAbsoluteUrl({ url: "/#/app/graph/page/abc" })).toContain( "/#/app/graph/page/abc",