diff --git a/ui/doocus-app/server/doocusApi.ts b/ui/doocus-app/server/doocusApi.ts index 15ebc35..0b6f892 100644 --- a/ui/doocus-app/server/doocusApi.ts +++ b/ui/doocus-app/server/doocusApi.ts @@ -73,6 +73,53 @@ export function doocusApi(opts: Options): Plugin { 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 { + if (!p) return '' + try { return (await fsp.readFile(p, 'utf-8')).slice(0, cap) } catch { return '' } + } + + async function buildSearchIndex(): Promise { + 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 { + 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 { name: 'doocus-api', configureServer(server) { @@ -118,6 +165,11 @@ export function doocusApi(opts: Options): Plugin { 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 // review store works unchanged when embedded in doocus. if (parts[0] === 'runs' && parts.length >= 2) { diff --git a/ui/doocus-app/src/App.vue b/ui/doocus-app/src/App.vue index 939fcb2..53cbda2 100644 --- a/ui/doocus-app/src/App.vue +++ b/ui/doocus-app/src/App.vue @@ -5,37 +5,55 @@ import SplitPane from '@framework/components/SplitPane.vue' import FileTree from '@/components/FileTree.vue' import DocDetail from '@/components/DocDetail.vue' import ExportBar from '@/components/ExportBar.vue' -import { store, loadTree } from '@/store' +import { store, loadTree, runSearch, visibleTree, selectMatches, humanBytes } from '@/store' onMounted(loadTree) const detailTitle = computed(() => store.detail?.node.mode === 'meeting' ? 'Meeting' : 'File') const barLabel = computed(() => store.detail?.node.name ?? 'viewer') + +function onSearch(e: Event) { + runSearch((e.target as HTMLInputElement).value) +}