diff --git a/CHANGELOG.md b/CHANGELOG.md index 825c32f..0864cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,5 @@ # Changelog + +## Unreleased + +- Show unsaved pages and blocks resolved from a Roam block reference in Manage search results. diff --git a/src/utils/quickSwitcher.ts b/src/utils/quickSwitcher.ts index 13a20e6..203f2ec 100644 --- a/src/utils/quickSwitcher.ts +++ b/src/utils/quickSwitcher.ts @@ -3,11 +3,11 @@ import type { QuickSwitcherCommandPaletteSettings, QuickSwitcherTargetType, } from "~/types/quickSwitcher"; +import { BLOCK_REF_REGEX } from "roamjs-components/dom/constants"; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; -const BLOCK_REF_REGEX = /\(\(([A-Za-z0-9_-]+)\)\)/; export const DEFAULT_COMMAND_PALETTE_PREFIX = "QS: "; const LEGACY_COMMAND_PALETTE_PREFIX = "Q S - "; diff --git a/src/utils/quickSwitcherEntries.ts b/src/utils/quickSwitcherEntries.ts index 9281d8d..694b68b 100644 --- a/src/utils/quickSwitcherEntries.ts +++ b/src/utils/quickSwitcherEntries.ts @@ -90,11 +90,17 @@ const getUidFromEntryInput = ({ value }: { value: string }): string => { extractBlockRefUid({ value: normalizedValue }) || (isPageUrlInput({ entry: normalizedValue }) ? parsePageUidFromUrl({ url: normalizedValue }) - : normalizedValue) || + : "") || "" ); }; +const getUidFromExactBlockRef = ({ value }: { value: string }): string => { + const normalizedValue = value.trim(); + const uid = extractBlockRefUid({ value: normalizedValue }); + return uid && normalizedValue === `((${uid}))` ? uid : ""; +}; + export const getSuggestionTargetKey = ({ suggestion, }: { @@ -245,6 +251,25 @@ export const searchEntries = async ({ savedTargetKeys: Set; searchApi?: RoamSearchApi; }): Promise => { + const referencedUid = getUidFromExactBlockRef({ value: query }); + if (referencedUid) { + try { + const referencedSuggestion = await resolveUidToSuggestion({ + uid: referencedUid, + }); + if ( + referencedSuggestion && + !savedTargetKeys.has( + getSuggestionTargetKey({ suggestion: referencedSuggestion }), + ) + ) { + return [referencedSuggestion]; + } + } catch { + // Fall through to the existing text search when direct resolution fails. + } + } + const options: RoamSearchOptions = { "hide-code-blocks": false, limit: MAX_SEARCH_RESULTS, diff --git a/tests/quickSwitcher.test.ts b/tests/quickSwitcher.test.ts index 0dfbe98..899c676 100644 --- a/tests/quickSwitcher.test.ts +++ b/tests/quickSwitcher.test.ts @@ -17,6 +17,7 @@ import { createBookmarkFromSuggestion, getBlockBreadcrumbsFromPull, getSavedTargetKeys, + resolveEntryInput, searchEntries, } from "../src/utils/quickSwitcherEntries"; @@ -250,6 +251,222 @@ test("searches a bounded frontend result set and filters saved targets", async ( } }); +test("resolves an exact block reference to an unsaved Manage search result", async () => { + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { + href: "https://roamresearch.com/#/app/test-graph/daily-notes", + origin: "https://roamresearch.com", + }, + roamAlphaAPI: { + data: { + backend: { + q: async (query: string): Promise<[string][]> => + query.includes("?page :node/title ?title") + ? [["Resolved project page"]] + : [], + }, + pull: (): null => null, + }, + }, + }, + writable: true, + }); + + try { + let textSearchCalled = false; + const suggestions = await searchEntries({ + query: " ((acW-i9uMD)) ", + savedTargetKeys: new Set(), + searchApi: async () => { + textSearchCalled = true; + return []; + }, + }); + + expect(textSearchCalled).toBe(false); + expect(suggestions).toEqual([ + { + uid: "acW-i9uMD", + title: "Resolved project page", + targetType: "page", + url: "https://roamresearch.com/#/app/test-graph/page/acW-i9uMD", + }, + ]); + } finally { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + writable: true, + }); + } +}); + +test("resolves an exact block reference to an unsaved block result", async () => { + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { + href: "https://roamresearch.com/#/app/test-graph/daily-notes", + origin: "https://roamresearch.com", + }, + roamAlphaAPI: { + data: { + backend: { + q: async (query: string): Promise<[string][]> => + query.includes("?block :block/string ?text") + ? [["Follow up with the project team tomorrow"]] + : [], + }, + pull: (): Record => ({ + ":block/page": { ":node/title": "Projects" }, + }), + }, + }, + }, + writable: true, + }); + + try { + const suggestions = await searchEntries({ + query: "((acW-i9uMD))", + savedTargetKeys: new Set(), + searchApi: async () => { + throw new Error("Text search should not run for a resolved block ref"); + }, + }); + + expect(suggestions).toEqual([ + { + breadcrumbs: ["Projects"], + uid: "acW-i9uMD", + title: "Follow up with the project team tomorrow", + targetType: "block", + url: "https://roamresearch.com/#/app/test-graph/page/acW-i9uMD", + }, + ]); + } finally { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + writable: true, + }); + } +}); + +test("preserves Manage text search for saved, unresolved, invalid, and bare UID inputs", async () => { + const originalWindow = globalThis.window; + let uidResolutionQueries = 0; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { + href: "https://roamresearch.com/#/app/test-graph/daily-notes", + origin: "https://roamresearch.com", + }, + roamAlphaAPI: { + data: { + backend: { + q: async (query: string): Promise<[string][]> => { + uidResolutionQueries += 1; + if ( + query.includes('"savedUid1"') && + query.includes("?page :node/title ?title") + ) { + return [["Saved page"]]; + } + return []; + }, + }, + pull: (): null => null, + }, + }, + }, + writable: true, + }); + + try { + let textSearches = 0; + const searchApi = async (): Promise[]> => { + textSearches += 1; + return []; + }; + const savedSuggestions = await searchEntries({ + query: "((savedUid1))", + savedTargetKeys: new Set(["page:savedUid1"]), + searchApi, + }); + expect(savedSuggestions).toEqual([]); + + await searchEntries({ + query: "((missing01))", + savedTargetKeys: new Set(), + searchApi, + }); + const queriesAfterUnresolvedRef = uidResolutionQueries; + await searchEntries({ + query: "((short))", + savedTargetKeys: new Set(), + searchApi, + }); + await searchEntries({ + query: "acW-i9uMD", + savedTargetKeys: new Set(), + searchApi, + }); + + expect(textSearches).toBe(4); + expect(uidResolutionQueries).toBe(queriesAfterUnresolvedRef); + } finally { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + writable: true, + }); + } +}); + +test("does not resolve a bare UID through the entry input UID path", async () => { + const originalWindow = globalThis.window; + const queries: string[] = []; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { + href: "https://roamresearch.com/#/app/test-graph/daily-notes", + origin: "https://roamresearch.com", + }, + roamAlphaAPI: { + data: { + backend: { + q: async (query: string): Promise<[string][]> => { + queries.push(query); + return []; + }, + }, + pull: (): null => null, + }, + }, + }, + writable: true, + }); + + try { + expect(await resolveEntryInput({ value: "acW-i9uMD" })).toBeNull(); + expect(queries).toHaveLength(1); + expect(queries[0]).toContain('?page :node/title "acW-i9uMD"'); + } finally { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + writable: true, + }); + } +}); + test("extracts block breadcrumbs from pulled block parents", () => { expect( getBlockBreadcrumbsFromPull({