basic search
This commit is contained in:
@@ -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<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 {
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<span class="brand">doocus</span>
|
||||
<span v-if="store.counts.total" class="counts">
|
||||
{{ store.counts.total }} files · {{ store.counts.extracted }} extracted ·
|
||||
{{ store.counts.link }} linked · {{ store.counts.meeting }} meetings
|
||||
</span>
|
||||
<span class="spacer" />
|
||||
|
||||
<div class="search">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<ExportBar />
|
||||
</header>
|
||||
|
||||
<main class="body">
|
||||
<div v-if="store.error" class="err">{{ store.error }}</div>
|
||||
|
||||
<!-- both expanded: resizable split -->
|
||||
<SplitPane v-else-if="!store.treeCollapsed && !store.detailCollapsed"
|
||||
direction="vertical" :initial-size="0.9" :min="0.2" :max="4">
|
||||
<template #first>
|
||||
<Panel title="Tree">
|
||||
<template #actions><button class="chev" title="Collapse tree" @click="store.treeCollapsed = true">▴</button></template>
|
||||
<FileTree :items="store.tree" />
|
||||
<FileTree :items="visibleTree" />
|
||||
</Panel>
|
||||
</template>
|
||||
<template #second>
|
||||
@@ -46,14 +64,13 @@ const barLabel = computed(() => store.detail?.node.name ?? 'viewer')
|
||||
</template>
|
||||
</SplitPane>
|
||||
|
||||
<!-- one or both collapsed: stack of panels + bars -->
|
||||
<div v-else class="stack">
|
||||
<button v-if="store.treeCollapsed" class="bar" title="Expand tree" @click="store.treeCollapsed = false">
|
||||
▾ <span>Tree · {{ store.counts.total }} files</span>
|
||||
</button>
|
||||
<Panel v-else title="Tree" class="grow">
|
||||
<template #actions><button class="chev" title="Collapse tree" @click="store.treeCollapsed = true">▴</button></template>
|
||||
<FileTree :items="store.tree" />
|
||||
<FileTree :items="visibleTree" />
|
||||
</Panel>
|
||||
|
||||
<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);
|
||||
background: var(--surface-1); border-bottom: var(--panel-border); flex-shrink: 0;
|
||||
}
|
||||
.brand { font-weight: 700; letter-spacing: 0.02em; }
|
||||
.counts { color: var(--text-secondary); font-size: var(--font-size-sm); }
|
||||
.spacer { flex: 1; }
|
||||
.brand { font-weight: 700; letter-spacing: 0.02em; flex-shrink: 0; }
|
||||
.search { flex: 1; display: flex; align-items: center; justify-content: center; gap: var(--space-2); }
|
||||
.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); }
|
||||
.err { color: var(--status-error, #e0a030); padding: var(--space-4); }
|
||||
.stack { display: flex; flex-direction: column; height: 100%; gap: var(--space-2); }
|
||||
|
||||
@@ -6,6 +6,11 @@ defineProps<{ items: TreeItem[]; depth?: number }>()
|
||||
const MODE_BADGE: Record<string, string> = {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -14,10 +19,10 @@ const MODE_BADGE: Record<string, string> = {
|
||||
<!-- folder -->
|
||||
<template v-if="it.dir">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- file -->
|
||||
|
||||
@@ -66,6 +66,11 @@ interface State {
|
||||
detailCollapsed: boolean // bottom viewer collapsed to a bar
|
||||
targetKey: 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>({
|
||||
@@ -82,8 +87,50 @@ export const store = reactive<State>({
|
||||
detailCollapsed: false,
|
||||
targetKey: TARGETS[0].key,
|
||||
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 {
|
||||
if (store.collapsed.has(path)) store.collapsed.delete(path)
|
||||
else store.collapsed.add(path)
|
||||
|
||||
Reference in New Issue
Block a user