basic search
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user