diff --git a/README.md b/README.md index 3543253..d527839 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,11 @@ Open the search modal with either the **Alt+K** keyboard shortcut or the toolbar Type a query and results appear ranked by similarity. Click a result to navigate to that block. **Shift+click** (or **Shift+Enter**) opens the block in the right sidebar instead. -Hover over a result to reveal the **copy** button, or press **Ctrl+C** (**Cmd+C** on Mac) with a result selected, to copy a `((block reference))` to the clipboard. +When a query matches several blocks on the same page, they are collapsed into a **page group** showing the best match score and the number of matching blocks. Click the group header to expand or collapse its blocks. Short queries group more aggressively; longer, more specific queries tend to show individual blocks. Journal pages are never grouped. -The **Include journal** checkbox in the footer controls whether results from journal pages are shown. +Hover over a result to reveal the **copy** button, or press **Ctrl+C** (**Cmd+C** on Mac) with a result selected, to copy a `((block reference))` to the clipboard. On a page group, this copies a `[[page reference]]` instead. + +Results from journal pages are gently down-ranked based on age so that recent entries surface above older ones. The **Include journal** checkbox in the footer controls whether journal results are shown at all. Blocks are automatically indexed when the graph loads, and only changed blocks are re-embedded on subsequent runs. To rebuild the index from scratch, use the **Semantic Search: Rebuild index** command from the command palette (Ctrl/Cmd+Shift+P). diff --git a/docs/demo.gif b/docs/demo.gif index 94d4ea7..46d00b4 100644 Binary files a/docs/demo.gif and b/docs/demo.gif differ diff --git a/src/__tests__/ranking.test.ts b/src/__tests__/ranking.test.ts new file mode 100644 index 0000000..5390f11 --- /dev/null +++ b/src/__tests__/ranking.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from "vitest"; +import { + computeDecayMultiplier, + applyTimeDecay, + computeGroupingParams, + groupAndRank, + getOverfetchCount, + DECAY_FLOOR, + T_MIN, + T_MAX, + W_MAX, + W_MIN, + type ScoredResult, +} from "../ranking"; +import type { SearchResult } from "../search"; + +describe("getOverfetchCount", () => { + it("returns 2x topK", () => { + expect(getOverfetchCount(10)).toBe(20); + expect(getOverfetchCount(1)).toBe(2); + }); +}); + +describe("computeDecayMultiplier", () => { + const now = new Date(2025, 5, 15); // June 15, 2025 + + it("returns 1.0 for null journalDay", () => { + expect(computeDecayMultiplier(null, now)).toBe(1.0); + }); + + it("returns ~1.0 for today", () => { + expect(computeDecayMultiplier(20250615, now)).toBeCloseTo(1.0, 2); + }); + + it("returns ~0.906 for 180 days ago", () => { + // 180 days before June 15 = ~Dec 17, 2024 + const journalDay = 20241217; + const result = computeDecayMultiplier(journalDay, now); + // F + (1-F)*e^(-1) = 0.85 + 0.15*0.368 = 0.905 + expect(result).toBeCloseTo(0.905, 2); + }); + + it("converges to DECAY_FLOOR for very old entries", () => { + const result = computeDecayMultiplier(20100101, now); + expect(result).toBeCloseTo(DECAY_FLOOR, 2); + }); + + it("clamps future dates to 1.0", () => { + const result = computeDecayMultiplier(20260101, now); + expect(result).toBeCloseTo(1.0, 2); + }); +}); + +describe("computeGroupingParams", () => { + it("returns min threshold and max density for short queries (<=3 words)", () => { + for (const wc of [1, 2, 3]) { + const { threshold, densityWeight } = computeGroupingParams(wc); + expect(threshold).toBe(T_MIN); + expect(densityWeight).toBeCloseTo(W_MAX); + } + }); + + it("returns max threshold and min density for long queries (>=8 words)", () => { + for (const wc of [8, 12]) { + const { threshold, densityWeight } = computeGroupingParams(wc); + expect(threshold).toBe(T_MAX); + expect(densityWeight).toBeCloseTo(W_MIN); + } + }); + + it("interpolates for mid-length queries", () => { + const { threshold, densityWeight } = computeGroupingParams(5); + // ratio = (5-3)/(8-3) = 0.4 + expect(threshold).toBe(Math.round(T_MIN + (T_MAX - T_MIN) * 0.4)); + expect(densityWeight).toBeCloseTo(W_MAX + (W_MIN - W_MAX) * 0.4); + }); +}); + +describe("applyTimeDecay", () => { + it("applies decay multipliers from journal day map", () => { + const now = new Date(2025, 5, 15); + const results: SearchResult[] = [ + { blockId: "a", pageId: 1, similarity: 0.9 }, + { blockId: "b", pageId: 2, similarity: 0.8 }, + ]; + const journalDays = new Map([ + [1, null], // not a journal + [2, 20250615], // today's journal + ]); + const scored = applyTimeDecay(results, journalDays, now); + expect(scored[0].decayMultiplier).toBe(1.0); + expect(scored[0].adjustedScore).toBe(0.9); + expect(scored[1].decayMultiplier).toBeCloseTo(1.0, 2); + expect(scored[1].adjustedScore).toBeCloseTo(0.8, 2); + }); + + it("treats missing pageId as non-journal", () => { + const results: SearchResult[] = [ + { blockId: "a", pageId: 99, similarity: 0.9 }, + ]; + const scored = applyTimeDecay(results, new Map()); + expect(scored[0].decayMultiplier).toBe(1.0); + }); +}); + +describe("groupAndRank", () => { + function makeScoredResult( + blockId: string, + pageId: number, + adjustedScore: number, + ): ScoredResult { + return { + blockId, + pageId, + similarity: adjustedScore, + decayMultiplier: 1.0, + adjustedScore, + }; + } + + it("groups pages meeting threshold into PageGroup", () => { + const scored = [ + makeScoredResult("a1", 1, 0.9), + makeScoredResult("a2", 1, 0.8), + makeScoredResult("a3", 1, 0.7), + makeScoredResult("a4", 1, 0.6), + makeScoredResult("b1", 2, 0.85), + ]; + // queryWordCount=1 → threshold=T_MIN=4, densityWeight=W_MAX=0.15 + const items = groupAndRank(scored, 1, 10, new Set()); + expect(items[0].kind).toBe("page-group"); + if (items[0].kind === "page-group") { + expect(items[0].pageId).toBe(1); + // pageScore = 0.9 + 0.8*0.15/1 + 0.7*0.15/2 + 0.6*0.15/3 (harmonic decay) + expect(items[0].pageScore).toBeCloseTo(0.9 + 0.8 * 0.15 + 0.7 * 0.15 / 2 + 0.6 * 0.15 / 3); + expect(items[0].blocks).toHaveLength(4); + } + expect(items[1].kind).toBe("single-block"); + }); + + it("does not group pages below threshold", () => { + const scored = [ + makeScoredResult("a1", 1, 0.9), + makeScoredResult("b1", 2, 0.85), + ]; + // threshold=4, page 1 has only 1 block + const items = groupAndRank(scored, 1, 10, new Set()); + expect(items.every((i) => i.kind === "single-block")).toBe(true); + }); + + it("never groups journal pages", () => { + const scored = [ + makeScoredResult("a1", 1, 0.9), + makeScoredResult("a2", 1, 0.8), + makeScoredResult("a3", 1, 0.7), + makeScoredResult("a4", 1, 0.6), + ]; + const journalPageIds = new Set([1]); + const items = groupAndRank(scored, 1, 10, journalPageIds); + expect(items.every((i) => i.kind === "single-block")).toBe(true); + }); + + it("respects topK limit", () => { + const scored = [ + makeScoredResult("a1", 1, 0.9), + makeScoredResult("b1", 2, 0.85), + makeScoredResult("c1", 3, 0.80), + makeScoredResult("d1", 4, 0.75), + ]; + const items = groupAndRank(scored, 1, 2, new Set()); + expect(items).toHaveLength(2); + }); + + + it("sorts by score descending", () => { + const scored = [ + makeScoredResult("a1", 1, 0.5), + makeScoredResult("b1", 2, 0.9), + ]; + const items = groupAndRank(scored, 1, 10, new Set()); + expect(items[0].kind === "single-block" && items[0].result.blockId === "b1").toBe(true); + }); + + it("handles empty input", () => { + const items = groupAndRank([], 1, 10, new Set()); + expect(items).toHaveLength(0); + }); + + it("handles all results from one page", () => { + const scored = [ + makeScoredResult("a1", 1, 0.9), + makeScoredResult("a2", 1, 0.8), + makeScoredResult("a3", 1, 0.7), + makeScoredResult("a4", 1, 0.6), + ]; + const items = groupAndRank(scored, 1, 10, new Set()); + expect(items).toHaveLength(1); + expect(items[0].kind).toBe("page-group"); + }); +}); diff --git a/src/ranking.ts b/src/ranking.ts new file mode 100644 index 0000000..3e61147 --- /dev/null +++ b/src/ranking.ts @@ -0,0 +1,134 @@ +import type { SearchResult } from "./search"; + +export interface ScoredResult { + blockId: string; + pageId: number; + similarity: number; + decayMultiplier: number; + adjustedScore: number; +} + +export interface PageGroup { + kind: "page-group"; + pageId: number; + pageScore: number; + blocks: ScoredResult[]; +} + +export interface SingleBlock { + kind: "single-block"; + result: ScoredResult; +} + +export type RankedItem = PageGroup | SingleBlock; + +export const DECAY_FLOOR = 0.85; +export const DECAY_SCALE_DAYS = 180; +export const T_MIN = 4; +export const T_MAX = 6; +export const W_MAX = 0.15; +export const W_MIN = 0.03; +export const QUERY_SHORT = 3; +export const QUERY_LONG = 8; +export const OVERFETCH_MULTIPLIER = 2; + +export function getOverfetchCount(topK: number): number { + return topK * OVERFETCH_MULTIPLIER; +} + +export function computeDecayMultiplier( + journalDay: number | null, + now: Date = new Date(), +): number { + if (journalDay === null) return 1.0; + const str = String(journalDay); + const year = parseInt(str.slice(0, 4), 10); + const month = parseInt(str.slice(4, 6), 10) - 1; + const day = parseInt(str.slice(6, 8), 10); + const journalDate = new Date(year, month, day); + const deltaMs = now.getTime() - journalDate.getTime(); + const deltaDays = Math.max(0, deltaMs / (1000 * 60 * 60 * 24)); + return DECAY_FLOOR + (1 - DECAY_FLOOR) * Math.exp(-deltaDays / DECAY_SCALE_DAYS); +} + +export function applyTimeDecay( + results: SearchResult[], + journalDays: Map, + now: Date = new Date(), +): ScoredResult[] { + return results.map((r) => { + const journalDay = journalDays.get(r.pageId) ?? null; + const decayMultiplier = computeDecayMultiplier(journalDay, now); + return { + blockId: r.blockId, + pageId: r.pageId, + similarity: r.similarity, + decayMultiplier, + adjustedScore: r.similarity * decayMultiplier, + }; + }); +} + +export function computeGroupingParams(queryWordCount: number): { + threshold: number; + densityWeight: number; +} { + const ratio = Math.max(0, Math.min(1, (queryWordCount - QUERY_SHORT) / (QUERY_LONG - QUERY_SHORT))); + const threshold = Math.round(T_MIN + (T_MAX - T_MIN) * ratio); + const densityWeight = W_MAX + (W_MIN - W_MAX) * ratio; + return { threshold, densityWeight }; +} + +export function groupAndRank( + scored: ScoredResult[], + queryWordCount: number, + topK: number, + journalPageIds: Set, +): RankedItem[] { + const { threshold, densityWeight } = computeGroupingParams(queryWordCount); + + // Group by pageId + const byPage = new Map(); + for (const s of scored) { + let arr = byPage.get(s.pageId); + if (!arr) { + arr = []; + byPage.set(s.pageId, arr); + } + arr.push(s); + } + + // Sort each page's blocks descending + for (const arr of byPage.values()) { + arr.sort((a, b) => b.adjustedScore - a.adjustedScore); + } + + const items: RankedItem[] = []; + for (const [pageId, blocks] of byPage) { + if (!journalPageIds.has(pageId) && blocks.length >= threshold) { + const maxScore = blocks[0].adjustedScore; + let bonus = 0; + for (let i = 1; i < blocks.length; i++) { + bonus += blocks[i].adjustedScore * densityWeight / i; + } + items.push({ + kind: "page-group", + pageId, + pageScore: maxScore + bonus, + blocks, + }); + } else { + for (const b of blocks) { + items.push({ kind: "single-block", result: b }); + } + } + } + + items.sort((a, b) => { + const scoreA = a.kind === "page-group" ? a.pageScore : a.result.adjustedScore; + const scoreB = b.kind === "page-group" ? b.pageScore : b.result.adjustedScore; + return scoreB - scoreA; + }); + + return items.slice(0, topK); +} diff --git a/src/ui.ts b/src/ui.ts index c94b717..f1822e0 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,21 +1,53 @@ import { debounce } from "./utils"; import { embedTexts } from "./embeddings"; import { getCachedEmbeddings, getEmbeddingCount, invalidateEmbeddingCache } from "./storage"; -import { searchEmbeddings, type SearchResult } from "./search"; +import { searchEmbeddings } from "./search"; import { indexBlocks, indexingState, acquireSearchPriority, releaseSearchPriority } from "./indexer"; import { getSettings } from "./settings"; - -interface DisplayResult extends SearchResult { +import { + getOverfetchCount, + applyTimeDecay, + groupAndRank, + type ScoredResult, +} from "./ranking"; + +interface DisplayBlock { + blockId: string; + pageId: number; + similarity: number; + adjustedScore: number; pageName: string; content: string; isJournal: boolean; breadcrumbs: string[]; } +interface DisplayPageGroup { + kind: "page-group"; + pageId: number; + pageName: string; + pageScore: number; + isJournal: boolean; + blocks: DisplayBlock[]; +} + +interface DisplaySingleBlock { + kind: "single-block"; + block: DisplayBlock; +} + +type DisplayItem = DisplayPageGroup | DisplaySingleBlock; + let progressInterval: ReturnType | undefined; let evictTimer: ReturnType | undefined; const CACHE_EVICT_MS = 60_000; -let lastDisplayResults: DisplayResult[] = []; +let lastDisplayItems: DisplayItem[] = []; +let expandedPageIds = new Set(); +let lastScoredResults: ScoredResult[] = []; +let lastDisplayBlockCache = new Map(); +let lastJournalPageIds = new Set(); +let lastQueryWordCount = 0; +let lastTopK = 20; let lastQuery = ""; const queryHistory: string[] = []; const MAX_HISTORY = 20; @@ -144,7 +176,7 @@ function handleKeydown(e: KeyboardEvent): void { if (!results) return; - const items = results.querySelectorAll(".ss-result-item"); + const items = results.querySelectorAll(".ss-result-item:not(.ss-hidden)"); if (items.length === 0) return; const active = results.querySelector(".ss-result-item.active"); @@ -164,17 +196,46 @@ function handleKeydown(e: KeyboardEvent): void { items[index].scrollIntoView({ block: "nearest" }); } else if (e.key === "Enter" && active) { e.preventDefault(); - (active as HTMLElement).dispatchEvent( - new MouseEvent("click", { shiftKey: e.shiftKey, bubbles: true }), - ); + if (active.classList.contains("ss-page-group")) { + const pageId = Number(active.getAttribute("data-page-id")); + if (e.shiftKey) { + const displayItem = lastDisplayItems.find( + (di) => di.kind === "page-group" && di.pageId === pageId, + ) as DisplayPageGroup | undefined; + if (displayItem) { + const firstBlockId = displayItem.blocks[0]?.blockId; + if (firstBlockId) { + logseq.Editor.getBlock(firstBlockId).then((block) => { + if (block?.page?.id) { + logseq.Editor.openInRightSidebar(block.page.id); + } + }); + } + } + } else { + togglePageGroup(pageId, results); + } + } else { + (active as HTMLElement).dispatchEvent( + new MouseEvent("click", { shiftKey: e.shiftKey, bubbles: true }), + ); + } } else if (e.key === "Enter" && !active && input && document.activeElement === input) { e.preventDefault(); const query = input.value.trim(); if (query) performSearch(query); } else if (e.key === "c" && (e.ctrlKey || e.metaKey) && active) { e.preventDefault(); - const blockId = active.getAttribute("data-block-id"); - if (blockId) copyBlockReference(blockId); + if (active.classList.contains("ss-page-group")) { + const pageId = Number(active.getAttribute("data-page-id")); + const displayItem = lastDisplayItems.find( + (item) => item.kind === "page-group" && item.pageId === pageId, + ) as DisplayPageGroup | undefined; + if (displayItem) copyPageReference(displayItem.pageName); + } else { + const blockId = active.getAttribute("data-block-id"); + if (blockId) copyBlockReference(blockId); + } } } @@ -236,6 +297,86 @@ function startStatusPolling(): void { progressInterval = setInterval(() => updateStatus(), 500); } +interface PageInfo { + pageName: string; + isJournal: boolean; + journalDay: number | null; +} + +async function fetchPageInfo( + pageEntityId: number, + cache: Map, +): Promise { + const cached = cache.get(pageEntityId); + if (cached) return cached; + const page = await logseq.Editor.getPage(pageEntityId); + const info: PageInfo = { + pageName: page?.originalName ?? page?.name ?? "Unknown", + isJournal: page?.["journal?"] ?? false, + journalDay: page?.journalDay ?? null, + }; + cache.set(pageEntityId, info); + return info; +} + +async function fetchBreadcrumbs(blockId: string, pageName: string): Promise { + const breadcrumbs: string[] = [pageName]; + const ancestors: string[] = []; + let childUuid = blockId; + while (childUuid) { + try { + const rows: [string, string][] = await logseq.DB.datascriptQuery( + `[:find ?content ?uuid + :where [?child :block/uuid #uuid "${childUuid}"] + [?child :block/parent ?parent] + [?parent :block/content ?content] + [?parent :block/uuid ?uuid] + (not [?parent :block/name _])]`, + ); + if (!rows?.[0]) break; + const [content, parentUuid] = rows[0]; + const firstLine = content.split("\n")[0]; + const truncated = firstLine.length > 50; + ancestors.unshift(truncated ? firstLine.slice(0, 50) + "..." : firstLine); + childUuid = parentUuid; + } catch { + break; + } + } + breadcrumbs.push(...ancestors); + return breadcrumbs; +} + +async function scoredToDisplayBlock( + scored: ScoredResult, + pageCache: Map, +): Promise { + try { + const block = await logseq.Editor.getBlock(scored.blockId); + if (!block) return null; + + let pageInfo: PageInfo = { pageName: "Unknown", isJournal: false, journalDay: null }; + if (block.page?.id) { + pageInfo = await fetchPageInfo(block.page.id, pageCache); + } + + const breadcrumbs = await fetchBreadcrumbs(scored.blockId, pageInfo.pageName); + + return { + blockId: scored.blockId, + pageId: scored.pageId, + similarity: scored.similarity, + adjustedScore: scored.adjustedScore, + pageName: pageInfo.pageName, + content: block.content ?? "", + isJournal: pageInfo.isJournal, + breadcrumbs, + }; + } catch { + return null; + } +} + async function performSearch(query: string): Promise { const resultsEl = document.getElementById("ss-results"); if (!resultsEl) return; @@ -261,152 +402,260 @@ async function performSearch(query: string): Promise { } const allEmbeddings = await getCachedEmbeddings(); - const results = searchEmbeddings( + const candidates = searchEmbeddings( queryEmbedding, allEmbeddings, - settings.topK, + getOverfetchCount(settings.topK), ); - // Fetch block details - const displayResults: DisplayResult[] = []; - for (const result of results) { - try { - const block = await logseq.Editor.getBlock(result.blockId); - if (!block) continue; - - let pageName = "Unknown"; - let isJournal = false; - if (block.page?.id) { - const page = await logseq.Editor.getPage(block.page.id); - pageName = page?.originalName ?? page?.name ?? "Unknown"; - isJournal = page?.["journal?"] ?? false; - } - - // Build breadcrumbs by walking up parent chain via datascript - const breadcrumbs: string[] = [pageName]; - const ancestors: string[] = []; - let childUuid = result.blockId; - while (childUuid) { - try { - const rows: [string, string][] = await logseq.DB.datascriptQuery( - `[:find ?content ?uuid - :where [?child :block/uuid #uuid "${childUuid}"] - [?child :block/parent ?parent] - [?parent :block/content ?content] - [?parent :block/uuid ?uuid] - (not [?parent :block/name _])]`, - ); - if (!rows?.[0]) break; - const [content, parentUuid] = rows[0]; - const firstLine = content.split("\n")[0]; - const truncated = firstLine.length > 50; - ancestors.unshift(truncated ? firstLine.slice(0, 50) + "..." : firstLine); - childUuid = parentUuid; - } catch { - break; + // Fetch page info for all candidates, build journalDays map + const pageCache = new Map(); + const journalDays = new Map(); + const journalPageIds = new Set(); + for (const c of candidates) { + if (!journalDays.has(c.pageId)) { + try { + const block = await logseq.Editor.getBlock(c.blockId); + if (block?.page?.id) { + const info = await fetchPageInfo(block.page.id, pageCache); + journalDays.set(c.pageId, info.journalDay); + if (info.isJournal) journalPageIds.add(c.pageId); + } else { + journalDays.set(c.pageId, null); } + } catch { + journalDays.set(c.pageId, null); } - breadcrumbs.push(...ancestors); - - displayResults.push({ - ...result, - pageName, - content: block.content ?? "", - isJournal, - breadcrumbs, - }); - } catch { - // Skip blocks we can't fetch } } - lastDisplayResults = displayResults; + // Apply time decay + const scored = applyTimeDecay(candidates, journalDays); + + // Fetch display blocks for all scored candidates + const displayBlockCache = new Map(); + for (const s of scored) { + if (!displayBlockCache.has(s.blockId)) { + const db = await scoredToDisplayBlock(s, pageCache); + if (db) displayBlockCache.set(s.blockId, db); + } + } + + // Store state for re-grouping when journal filter changes + lastScoredResults = scored; + lastDisplayBlockCache = displayBlockCache; + lastJournalPageIds = journalPageIds; + lastQueryWordCount = query.split(/\s+/).filter(Boolean).length; + lastTopK = settings.topK; + renderFilteredResults(); } catch (err) { resultsEl.innerHTML = `
${(err as Error).message}
`; } } -function renderResults(results: DisplayResult[]): void { +function renderBlockElement(block: DisplayBlock, extraClass = ""): HTMLDivElement { + const item = document.createElement("div"); + item.className = `ss-result-item${extraClass ? " " + extraClass : ""}`; + item.setAttribute("data-block-id", block.blockId); + + const score = Math.round(block.adjustedScore * 100); + const preview = + block.content.length > 150 + ? block.content.slice(0, 150) + "..." + : block.content; + + const breadcrumbHtml = block.breadcrumbs + .map((b) => `${escapeHtml(b)}`) + .join(''); + + item.innerHTML = ` +
+ ${score}% + ${breadcrumbHtml} +
+
${escapeHtml(preview)}
+ + `; + + const refBtn = item.querySelector(".ss-ref-btn")!; + refBtn.addEventListener("click", (e) => { + e.stopPropagation(); + copyBlockReference(block.blockId); + }); + + item.addEventListener("click", async (e) => { + if (e.shiftKey) { + try { + logseq.Editor.openInRightSidebar(block.blockId); + } catch { + // ignore sidebar errors + } + return; + } + try { + const b = await logseq.Editor.getBlock(block.blockId); + if (b?.page?.id) { + const page = await logseq.Editor.getPage(b.page.id); + if (page?.name) { + logseq.Editor.scrollToBlockInPage(page.name, block.blockId); + } + } + } catch { + // ignore navigation errors + } + hideModal(); + }); + + return item; +} + +function togglePageGroup(pageId: number, container: HTMLElement): void { + const expanded = expandedPageIds.has(pageId); + if (expanded) { + expandedPageIds.delete(pageId); + } else { + expandedPageIds.add(pageId); + } + const header = container.querySelector(`.ss-page-group[data-page-id="${pageId}"]`); + const children = container.querySelector(`.ss-group-children[data-page-id="${pageId}"]`); + if (header && children) { + const toggle = header.querySelector(".ss-group-toggle"); + if (expanded) { + (children as HTMLElement).style.display = "none"; + children.querySelectorAll(".ss-result-item").forEach((el) => el.classList.add("ss-hidden")); + if (toggle) toggle.textContent = "\u25B6"; + } else { + (children as HTMLElement).style.display = ""; + children.querySelectorAll(".ss-result-item").forEach((el) => el.classList.remove("ss-hidden")); + if (toggle) toggle.textContent = "\u25BC"; + } + } +} + +function renderResults(items: DisplayItem[]): void { const resultsEl = document.getElementById("ss-results"); if (!resultsEl) return; - if (results.length === 0) { + if (items.length === 0) { resultsEl.innerHTML = '
No results found
'; return; } resultsEl.innerHTML = ""; - for (const result of results) { - const item = document.createElement("div"); - item.className = "ss-result-item"; - item.setAttribute("data-block-id", result.blockId); - - const similarity = Math.round(result.similarity * 100); - const preview = - result.content.length > 150 - ? result.content.slice(0, 150) + "..." - : result.content; - - const breadcrumbHtml = result.breadcrumbs - .map((b) => `${escapeHtml(b)}`) - .join(''); - - item.innerHTML = ` -
- ${similarity}% - ${breadcrumbHtml} -
-
${escapeHtml(preview)}
- - `; - - const refBtn = item.querySelector(".ss-ref-btn")!; - refBtn.addEventListener("click", (e) => { - e.stopPropagation(); - copyBlockReference(result.blockId); - }); - - item.addEventListener("click", async (e) => { - if (e.shiftKey) { - try { - logseq.Editor.openInRightSidebar(result.blockId); - } catch { - // ignore sidebar errors - } - return; - } - try { - const block = await logseq.Editor.getBlock(result.blockId); - if (block?.page?.id) { - const page = await logseq.Editor.getPage(block.page.id); - if (page?.name) { - logseq.Editor.scrollToBlockInPage(page.name, result.blockId); + for (const item of items) { + if (item.kind === "single-block") { + resultsEl.appendChild(renderBlockElement(item.block)); + } else { + const expanded = expandedPageIds.has(item.pageId); + const score = Math.round(item.blocks[0].adjustedScore * 100); + const blockCount = item.blocks.length; + + const header = document.createElement("div"); + header.className = "ss-result-item ss-page-group"; + header.setAttribute("data-page-id", String(item.pageId)); + header.innerHTML = ` +
+ ${score}% + ${expanded ? "\u25BC" : "\u25B6"} + ${escapeHtml(item.pageName)} + ${blockCount} block${blockCount !== 1 ? "s" : ""} +
+ + `; + + const refBtn = header.querySelector(".ss-ref-btn")!; + refBtn.addEventListener("click", (e) => { + e.stopPropagation(); + copyPageReference(item.pageName); + }); + + header.addEventListener("click", (e) => { + if (e.shiftKey) { + // Open page in sidebar — use first block's ID to find the page + try { + const firstBlockId = item.blocks[0]?.blockId; + if (firstBlockId) { + logseq.Editor.getBlock(firstBlockId).then((block) => { + if (block?.page?.id) { + logseq.Editor.openInRightSidebar(block.page.id); + } + }); + } + } catch { + // ignore } + return; } - } catch { - // ignore navigation errors + togglePageGroup(item.pageId, resultsEl); + }); + + resultsEl.appendChild(header); + + const childrenContainer = document.createElement("div"); + childrenContainer.className = "ss-group-children"; + childrenContainer.setAttribute("data-page-id", String(item.pageId)); + childrenContainer.style.display = expanded ? "" : "none"; + + for (const block of item.blocks) { + const el = renderBlockElement(block, `ss-grouped-block${expanded ? "" : " ss-hidden"}`); + childrenContainer.appendChild(el); } - hideModal(); - }); - resultsEl.appendChild(item); + resultsEl.appendChild(childrenContainer); + } } } +function buildDisplayItems(scored: ScoredResult[], journalPageIds: Set): DisplayItem[] { + const rankedItems = groupAndRank(scored, lastQueryWordCount, lastTopK, journalPageIds); + const displayItems: DisplayItem[] = []; + for (const item of rankedItems) { + if (item.kind === "page-group") { + const blocks: DisplayBlock[] = []; + for (const s of item.blocks) { + const db = lastDisplayBlockCache.get(s.blockId); + if (db) blocks.push(db); + } + if (blocks.length === 0) continue; + displayItems.push({ + kind: "page-group", + pageId: item.pageId, + pageName: blocks[0].pageName, + pageScore: item.pageScore, + isJournal: blocks[0].isJournal, + blocks, + }); + } else { + const db = lastDisplayBlockCache.get(item.result.blockId); + if (db) { + displayItems.push({ kind: "single-block", block: db }); + } + } + } + return displayItems; +} + function renderFilteredResults(): void { const checkbox = document.getElementById("ss-include-journal") as HTMLInputElement | null; const includeJournal = checkbox?.checked ?? true; - const filtered = includeJournal - ? lastDisplayResults - : lastDisplayResults.filter((r) => !r.isJournal); - renderResults(filtered); + + const scored = includeJournal + ? lastScoredResults + : lastScoredResults.filter((s) => !lastJournalPageIds.has(s.pageId)); + + lastDisplayItems = buildDisplayItems(scored, lastJournalPageIds); + renderResults(lastDisplayItems); } function clearResults(): void { const resultsEl = document.getElementById("ss-results"); if (resultsEl) resultsEl.innerHTML = ""; - lastDisplayResults = []; + lastDisplayItems = []; + lastScoredResults = []; + lastDisplayBlockCache.clear(); + lastJournalPageIds.clear(); + expandedPageIds.clear(); } function copyBlockReference(blockId: string): void { @@ -415,6 +664,12 @@ function copyBlockReference(blockId: string): void { }); } +function copyPageReference(pageName: string): void { + navigator.clipboard.writeText(`[[${pageName}]]`).then(() => { + logseq.UI.showMsg("Page reference copied to clipboard"); + }); +} + function escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; diff --git a/styles/search-modal.css b/styles/search-modal.css index 6355dfb..8b4c1e4 100644 --- a/styles/search-modal.css +++ b/styles/search-modal.css @@ -181,6 +181,37 @@ } +.ss-page-group .ss-breadcrumbs { + color: var(--ls-primary-text-color, #333); + font-size: 13px; +} + +.ss-group-toggle { + font-size: 10px; + user-select: none; + min-width: 12px; + text-align: center; + color: var(--ls-secondary-text-color, #999); +} + +.ss-block-count { + font-size: 12px; + color: var(--ls-secondary-text-color, #999); + white-space: nowrap; +} + +.ss-group-children { + padding-left: 0; +} + +.ss-grouped-block { + margin-left: 20px; +} + +.ss-hidden { + display: none; +} + .ss-loading, .ss-error, .ss-no-results {