basic search

This commit is contained in:
Mariano Gabriel
2026-07-05 22:00:52 -03:00
parent 8ff29fc776
commit b65d6075be
4 changed files with 143 additions and 15 deletions

View File

@@ -73,6 +73,53 @@ export function doocusApi(opts: Options): Plugin {
return abs return abs
} }
// Lazy search index over the cached small text — rebuilt when index.json changes.
let searchCache: { mtime: number; entries: Array<{ path: string; hay: string; bytes: number }> } | null = null
// Read full text for reliable search (local files; speed is secondary). The
// generous cap only guards against a pathologically huge file.
async function readCap(p: string | null, cap = 20_000_000): Promise<string> {
if (!p) return ''
try { return (await fsp.readFile(p, 'utf-8')).slice(0, cap) } catch { return '' }
}
async function buildSearchIndex(): Promise<void> {
const index = readIndex()
if (!index) { searchCache = { mtime: 0, entries: [] }; return }
let mtime = 0
try { mtime = fs.statSync(indexPath).mtimeMs } catch { /* */ }
const entries: Array<{ path: string; hay: string; bytes: number }> = []
for (const f of index.files) {
let text = `${f.path} ${(f as any).title ?? ''}`
if (f.mode === 'extracted' && f.out) {
text += ' ' + await readCap(path.join(outputDir, f.out, 'content.md'))
} else if (f.mode === 'meeting') {
const { dir, stem } = meetusDirFor(f.path)
text += ' ' + await readCap(path.join(dir, `${stem}_enhanced.txt`))
} else if (TEXT_EXTS.has(f.ext)) {
text += ' ' + await readCap(originalAbs(index, f.path))
}
entries.push({ path: f.path, hay: text.toLowerCase(), bytes: f.bytes })
}
searchCache = { mtime, entries }
}
async function search(res: ServerResponse, query: string): Promise<void> {
const q = query.trim().toLowerCase()
let curMtime = 0
try { curMtime = fs.statSync(indexPath).mtimeMs } catch { /* */ }
if (!searchCache || searchCache.mtime !== curMtime) await buildSearchIndex()
const entries = searchCache?.entries ?? []
// Empty query matches nothing (the UI shows the full tree when not searching).
const hits = q ? entries.filter((e) => e.hay.includes(q)) : []
sendJson(res, 200, {
query: q,
matches: hits.map((e) => e.path),
count: hits.length,
bytes: hits.reduce((s, e) => s + e.bytes, 0),
})
}
return { return {
name: 'doocus-api', name: 'doocus-api',
configureServer(server) { configureServer(server) {
@@ -118,6 +165,11 @@ export function doocusApi(opts: Options): Plugin {
return true return true
} }
if (parts[0] === 'search') {
await search(res, q.get('q') ?? '')
return true
}
// meetus-shaped run API (id = meeting rel path), so the composed meetus // meetus-shaped run API (id = meeting rel path), so the composed meetus
// review store works unchanged when embedded in doocus. // review store works unchanged when embedded in doocus.
if (parts[0] === 'runs' && parts.length >= 2) { if (parts[0] === 'runs' && parts.length >= 2) {

View File

@@ -5,37 +5,55 @@ import SplitPane from '@framework/components/SplitPane.vue'
import FileTree from '@/components/FileTree.vue' import FileTree from '@/components/FileTree.vue'
import DocDetail from '@/components/DocDetail.vue' import DocDetail from '@/components/DocDetail.vue'
import ExportBar from '@/components/ExportBar.vue' import ExportBar from '@/components/ExportBar.vue'
import { store, loadTree } from '@/store' import { store, loadTree, runSearch, visibleTree, selectMatches, humanBytes } from '@/store'
onMounted(loadTree) onMounted(loadTree)
const detailTitle = computed(() => const detailTitle = computed(() =>
store.detail?.node.mode === 'meeting' ? 'Meeting' : 'File') store.detail?.node.mode === 'meeting' ? 'Meeting' : 'File')
const barLabel = computed(() => store.detail?.node.name ?? 'viewer') const barLabel = computed(() => store.detail?.node.name ?? 'viewer')
function onSearch(e: Event) {
runSearch((e.target as HTMLInputElement).value)
}
</script> </script>
<template> <template>
<div class="app"> <div class="app">
<header class="topbar"> <header class="topbar">
<span class="brand">doocus</span> <span class="brand">doocus</span>
<span v-if="store.counts.total" class="counts">
{{ store.counts.total }} files · {{ store.counts.extracted }} extracted · <div class="search">
{{ store.counts.link }} linked · {{ store.counts.meeting }} meetings <input
class="search-input"
type="search"
:value="store.query"
placeholder="Search transcripts, documents, files…"
@input="onSearch"
/>
<span v-if="store.query" class="search-stats">
<template v-if="store.searching">searching</template>
<template v-else>
{{ store.searchStats.count }} files · {{ humanBytes(store.searchStats.bytes) }}
<button class="pkg" :disabled="!store.searchStats.count" title="Select matches for a package" @click="selectMatches">
package
</button>
</template>
</span> </span>
<span class="spacer" /> </div>
<ExportBar /> <ExportBar />
</header> </header>
<main class="body"> <main class="body">
<div v-if="store.error" class="err">{{ store.error }}</div> <div v-if="store.error" class="err">{{ store.error }}</div>
<!-- both expanded: resizable split -->
<SplitPane v-else-if="!store.treeCollapsed && !store.detailCollapsed" <SplitPane v-else-if="!store.treeCollapsed && !store.detailCollapsed"
direction="vertical" :initial-size="0.9" :min="0.2" :max="4"> direction="vertical" :initial-size="0.9" :min="0.2" :max="4">
<template #first> <template #first>
<Panel title="Tree"> <Panel title="Tree">
<template #actions><button class="chev" title="Collapse tree" @click="store.treeCollapsed = true"></button></template> <template #actions><button class="chev" title="Collapse tree" @click="store.treeCollapsed = true"></button></template>
<FileTree :items="store.tree" /> <FileTree :items="visibleTree" />
</Panel> </Panel>
</template> </template>
<template #second> <template #second>
@@ -46,14 +64,13 @@ const barLabel = computed(() => store.detail?.node.name ?? 'viewer')
</template> </template>
</SplitPane> </SplitPane>
<!-- one or both collapsed: stack of panels + bars -->
<div v-else class="stack"> <div v-else class="stack">
<button v-if="store.treeCollapsed" class="bar" title="Expand tree" @click="store.treeCollapsed = false"> <button v-if="store.treeCollapsed" class="bar" title="Expand tree" @click="store.treeCollapsed = false">
<span>Tree · {{ store.counts.total }} files</span> <span>Tree · {{ store.counts.total }} files</span>
</button> </button>
<Panel v-else title="Tree" class="grow"> <Panel v-else title="Tree" class="grow">
<template #actions><button class="chev" title="Collapse tree" @click="store.treeCollapsed = true"></button></template> <template #actions><button class="chev" title="Collapse tree" @click="store.treeCollapsed = true"></button></template>
<FileTree :items="store.tree" /> <FileTree :items="visibleTree" />
</Panel> </Panel>
<Panel v-if="!store.detailCollapsed" :title="detailTitle" class="grow"> <Panel v-if="!store.detailCollapsed" :title="detailTitle" class="grow">
@@ -75,9 +92,16 @@ const barLabel = computed(() => store.detail?.node.name ?? 'viewer')
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
background: var(--surface-1); border-bottom: var(--panel-border); flex-shrink: 0; background: var(--surface-1); border-bottom: var(--panel-border); flex-shrink: 0;
} }
.brand { font-weight: 700; letter-spacing: 0.02em; } .brand { font-weight: 700; letter-spacing: 0.02em; flex-shrink: 0; }
.counts { color: var(--text-secondary); font-size: var(--font-size-sm); } .search { flex: 1; display: flex; align-items: center; justify-content: center; gap: var(--space-2); }
.spacer { flex: 1; } .search-input {
width: 100%; max-width: 520px;
padding: var(--space-1) var(--space-3);
background: var(--surface-2); border: var(--panel-border); border-radius: var(--panel-radius);
color: var(--text-primary);
}
.search-stats { color: var(--text-secondary); font-size: var(--font-size-sm); white-space: nowrap; }
.pkg { padding: 1px var(--space-2); }
.body { flex: 1; min-height: 0; padding: var(--space-2); } .body { flex: 1; min-height: 0; padding: var(--space-2); }
.err { color: var(--status-error, #e0a030); padding: var(--space-4); } .err { color: var(--status-error, #e0a030); padding: var(--space-4); }
.stack { display: flex; flex-direction: column; height: 100%; gap: var(--space-2); } .stack { display: flex; flex-direction: column; height: 100%; gap: var(--space-2); }

View File

@@ -6,6 +6,11 @@ defineProps<{ items: TreeItem[]; depth?: number }>()
const MODE_BADGE: Record<string, string> = { const MODE_BADGE: Record<string, string> = {
extracted: 'ext', meeting: 'mtg', link: 'link', extracted: 'ext', meeting: 'mtg', link: 'link',
} }
// While searching, the tree is pruned to matches — keep every folder open.
function folderCollapsed(path: string): boolean {
return !store.query && store.collapsed.has(path)
}
</script> </script>
<template> <template>
@@ -14,10 +19,10 @@ const MODE_BADGE: Record<string, string> = {
<!-- folder --> <!-- folder -->
<template v-if="it.dir"> <template v-if="it.dir">
<div class="row folder" :style="{ paddingLeft: (depth ?? 0) * 14 + 8 + 'px' }" @click="toggleFolder(it.path)"> <div class="row folder" :style="{ paddingLeft: (depth ?? 0) * 14 + 8 + 'px' }" @click="toggleFolder(it.path)">
<span class="twisty">{{ store.collapsed.has(it.path) ? '▸' : '▾' }}</span> <span class="twisty">{{ folderCollapsed(it.path) ? '▸' : '▾' }}</span>
<span class="fname">{{ it.name }}</span> <span class="fname">{{ it.name }}</span>
</div> </div>
<FileTree v-show="!store.collapsed.has(it.path)" :items="it.children!" :depth="(depth ?? 0) + 1" /> <FileTree v-show="!folderCollapsed(it.path)" :items="it.children!" :depth="(depth ?? 0) + 1" />
</template> </template>
<!-- file --> <!-- file -->

View File

@@ -66,6 +66,11 @@ interface State {
detailCollapsed: boolean // bottom viewer collapsed to a bar detailCollapsed: boolean // bottom viewer collapsed to a bar
targetKey: string targetKey: string
error: string error: string
query: string
matches: Set<string> | null // null = not searching; else the matched paths
searching: boolean
searchStats: { count: number; bytes: number }
} }
export const store = reactive<State>({ export const store = reactive<State>({
@@ -82,8 +87,50 @@ export const store = reactive<State>({
detailCollapsed: false, detailCollapsed: false,
targetKey: TARGETS[0].key, targetKey: TARGETS[0].key,
error: '', error: '',
query: '',
matches: null,
searching: false,
searchStats: { count: 0, bytes: 0 },
}) })
let searchTimer: ReturnType<typeof setTimeout> | null = null
/** Search the cached text (transcripts, extracted docs, raw files) and prune the
* tree to matches. Debounced; reliability over speed (server reads full text). */
export function runSearch(q: string): void {
store.query = q
if (searchTimer) clearTimeout(searchTimer)
if (!q.trim()) {
store.matches = null
store.searching = false
store.searchStats = { count: 0, bytes: 0 }
return
}
store.searching = true
searchTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
if (!res.ok) return
const d = await res.json()
if (store.query !== q) return // a newer query superseded this one
store.matches = new Set<string>(d.matches ?? [])
store.searchStats = { count: d.count ?? 0, bytes: d.bytes ?? 0 }
} finally {
if (store.query === q) store.searching = false
}
}, 250)
}
/** Tree pruned to search matches (folders auto-expand while searching). */
export const visibleTree = computed(() =>
store.matches ? buildTree(store.files.filter((f) => store.matches!.has(f.path))) : store.tree)
/** Put the current match set into the package selection (one package for now). */
export function selectMatches(): void {
if (store.matches) store.selected = new Set(store.matches)
}
export function toggleFolder(path: string): void { export function toggleFolder(path: string): void {
if (store.collapsed.has(path)) store.collapsed.delete(path) if (store.collapsed.has(path)) store.collapsed.delete(path)
else store.collapsed.add(path) else store.collapsed.add(path)