/** * Vite dev middleware exposing one or more local doocus output trees. * * Each output dir is a "collection" (its own index.json + sidecars + source * root). The client can turn collections on/off; file paths are composite — * "/" — so a single path identifies both the collection * and the file within it. All reads are local; nothing leaves the machine. * * Routes (path params are composite, query is ?path=/?q=): * GET /api/collections available collections * GET /api/tree?collections=a,b merged tree of the active collections * GET /api/detail?path= node + extracted content + meta + text * GET /api/original?path= stream the original (Range-enabled) * GET /api/meeting?path= meetus review data for a meeting * GET /api/meeting/frame?path=&file= a meeting frame * GET /api/runs/:cid (cid=c/rel) meetus-shaped run (for the embed) * PUT /api/runs/:cid/review write the meeting's review.json * GET /api/search?q=&collections=a,b search cached text across active cols */ import type { Plugin } from 'vite' import type { IncomingMessage, ServerResponse } from 'node:http' import { spawn } from 'node:child_process' import fs from 'node:fs' import fsp from 'node:fs/promises' import path from 'node:path' interface Options { outputDirs: string[] // explicit output dirs (env) outputsRoot: string // managed root: UI-scanned sources land here repoRoot: string // where process_tree.py lives (run via uv) } interface Collection { id: string name: string dir: string indexPath: string } interface Node { path: string name: string ext: string family: string mode: 'extracted' | 'meeting' | 'link' bytes: number modified: string url: string | null out?: string title?: string warnings?: string[] } interface Index { root: string files: Node[] counts?: Record } const TEXT_EXTS = new Set(['md', 'markdown', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm']) const MIME: Record = { '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.html': 'text/html', '.htm': 'text/html', '.md': 'text/markdown', '.txt': 'text/plain', '.csv': 'text/csv', '.json': 'application/json', '.yaml': 'text/yaml', '.yml': 'text/yaml', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.mp4': 'video/mp4', } function buildCollections(dirs: string[]): Collection[] { const seen = new Map() return dirs.map((d) => { const dir = path.resolve(d) let id = path.basename(dir) || 'output' const n = seen.get(id) ?? 0 seen.set(id, n + 1) if (n > 0) id = `${id}-${n + 1}` return { id, name: id, dir, indexPath: path.join(dir, 'index.json') } }) } export function doocusApi(opts: Options): Plugin { /** Collections discovered under the managed root (one subdir per source). */ function discoverInRoot(): Collection[] { let subs: fs.Dirent[] = [] try { subs = fs.readdirSync(opts.outputsRoot, { withFileTypes: true }) } catch { return [] } return subs .filter((d) => d.isDirectory() && fs.existsSync(path.join(opts.outputsRoot, d.name, 'index.json'))) .map((d) => ({ id: d.name, name: d.name, dir: path.join(opts.outputsRoot, d.name), indexPath: path.join(opts.outputsRoot, d.name, 'index.json'), })) } /** Current collections = explicit env dirs + managed-root subdirs (deduped, * recomputed each request so UI scans appear without a restart). */ function currentCollections(): Collection[] { const out: Collection[] = [] const seen = new Set() for (const c of [...buildCollections(opts.outputDirs), ...discoverInRoot()]) { if (out.some((o) => o.dir === c.dir)) continue let id = c.id, n = 1 while (seen.has(id)) { n++; id = `${c.id}-${n}` } seen.add(id) out.push({ ...c, id }) } return out } function collectionById(id: string): Collection | undefined { return currentCollections().find((c) => c.id === id) } function readIndexFor(c: Collection): Index | null { try { return JSON.parse(fs.readFileSync(c.indexPath, 'utf-8')) } catch { return null } } /** Split a composite "/" into its collection + rel path. */ function splitPath(p: string): { col: Collection; rel: string } | null { const i = p.indexOf('/') const id = i < 0 ? p : p.slice(0, i) const rel = i < 0 ? '' : p.slice(i + 1) const col = collectionById(id) return col ? { col, rel } : null } /** Resolve a file within its collection's source root, rejecting traversal. */ function originalAbs(index: Index, rel: string): string | null { const root = path.resolve(index.root) const abs = path.resolve(root, rel) if (abs !== root && !abs.startsWith(root + path.sep)) return null return abs } function meetusDir(col: Collection, rel: string): { dir: string; stem: string } { return { dir: path.join(col.dir, rel + '.meetus'), stem: path.parse(rel).name } } // Per-collection search index over cached text — rebuilt on index.json change. const searchCache = new Map }>() 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(c: Collection): Promise { const index = readIndexFor(c) let mtime = 0 try { mtime = fs.statSync(c.indexPath).mtimeMs } catch { /* */ } const entries: Array<{ path: string; hay: string; bytes: number }> = [] for (const f of index?.files ?? []) { let text = `${f.path} ${f.title ?? ''}` if (f.mode === 'extracted' && f.out) { text += ' ' + await readCap(path.join(c.dir, f.out, 'content.md')) } else if (f.mode === 'meeting') { const { dir, stem } = meetusDir(c, f.path) text += ' ' + await readCap(path.join(dir, `${stem}_enhanced.txt`)) } else if (TEXT_EXTS.has(f.ext) && index) { text += ' ' + await readCap(originalAbs(index, f.path)) } entries.push({ path: `${c.id}/${f.path}`, hay: text.toLowerCase(), bytes: f.bytes }) } searchCache.set(c.id, { mtime, entries }) } return { name: 'doocus-api', configureServer(server) { const cols = currentCollections() server.config.logger.info( `[doocus-api] ${cols.length} collection(s); managed root: ${opts.outputsRoot}`) server.middlewares.use('/api', (req, res, next) => { const url = new URL(req.url ?? '/', 'http://localhost') const parts = url.pathname.split('/').filter(Boolean) handle(req, res, parts, url.searchParams).catch((err) => { server.config.logger.error(`[doocus-api] ${String(err)}`) sendJson(res, 500, { error: String(err) }) }).then((handled) => { if (!handled) next() }) }) }, } async function handle(req: IncomingMessage, res: ServerResponse, parts: string[], q: URLSearchParams): Promise { if (parts[0] === 'collections') { listCollections(res); return true } if (parts[0] === 'scan' && req.method === 'POST') { await runScan(req, res, false); return true } if (parts[0] === 'rescan' && req.method === 'POST') { await runScan(req, res, true); return true } if (parts[0] === 'tree') { getTree(res, q.get('collections')); return true } if (parts[0] === 'detail') { await getDetail(res, q.get('path') ?? ''); return true } if (parts[0] === 'original') { await serveOriginal(res, q.get('path') ?? '', req.headers.range); return true } if (parts[0] === 'meeting' && parts[1] === 'frame') { serveMeetingFrame(res, q.get('path') ?? '', q.get('file') ?? ''); return true } if (parts[0] === 'meeting') { await getMeeting(res, q.get('path') ?? ''); return true } if (parts[0] === 'search') { await search(res, q.get('q') ?? '', q.get('collections')); return true } if (parts[0] === 'runs' && parts.length >= 2) { const id = decodeURIComponent(parts[1]) if (parts.length === 2 && req.method === 'GET') { await getRunShaped(res, id); return true } if (parts.length === 3 && parts[2] === 'review' && req.method === 'PUT') { await saveRunReview(req, res, id); return true } } return false } function listCollections(res: ServerResponse): void { const out = currentCollections().map((c) => { const idx = readIndexFor(c) return { id: c.id, name: c.name, available: !!idx, count: idx?.files.length ?? 0 } }) sendJson(res, 200, { collections: out }) } /** Run process_tree.py over a source folder (new scan) or an existing * collection's recorded source (rescan), writing to the managed root. */ async function runScan(req: IncomingMessage, res: ServerResponse, rerun: boolean): Promise { let body: { source?: string; id?: string } try { body = JSON.parse((await readBody(req)) || '{}') } catch { sendJson(res, 400, { error: 'invalid JSON' }); return } let source: string let dir: string if (rerun) { const col = body.id ? collectionById(body.id) : undefined const idx = col ? readIndexFor(col) : null if (!col || !idx?.root) { sendJson(res, 404, { error: 'collection not found' }); return } source = idx.root dir = col.dir } else { source = (body.source ?? '').trim() if (!source || !isDir(source)) { sendJson(res, 400, { error: `not a directory: ${source}` }); return } const slug = path.basename(path.resolve(source)).replace(/[^\w.-]+/g, '_') || 'source' dir = path.join(opts.outputsRoot, slug) fs.mkdirSync(opts.outputsRoot, { recursive: true }) } try { await runProcessTree(source, dir) sendJson(res, 200, { ok: true, id: path.basename(dir) }) } catch (e) { sendJson(res, 500, { error: String(e) }) } } /** Spawn `uv run python process_tree.py --output ` at repoRoot. */ function runProcessTree(source: string, dir: string): Promise { return new Promise((resolve, reject) => { const child = spawn('uv', ['run', 'python', 'process_tree.py', source, '--output', dir], { cwd: opts.repoRoot }) let err = '' child.stderr.on('data', (d) => { err += d }) child.on('error', reject) child.on('close', (code) => code === 0 ? resolve() : reject(new Error(err || `exit ${code}`))) }) } function getTree(res: ServerResponse, activeCsv: string | null): void { const active = activeCsv ? new Set(activeCsv.split(',').filter(Boolean)) : null const cols = currentCollections().filter((c) => !active || active.has(c.id)) const files: Array = [] const counts: Record = { extracted: 0, meeting: 0, link: 0, total: 0 } for (const c of cols) { const idx = readIndexFor(c) if (!idx) continue for (const f of idx.files) { files.push({ ...f, collection: c.id, path: `${c.id}/${f.path}` }) counts[f.mode] = (counts[f.mode] ?? 0) + 1 counts.total += 1 } } sendJson(res, 200, { collections: currentCollections().map((c) => ({ id: c.id, name: c.name })), files, counts, }) } async function getDetail(res: ServerResponse, composite: string): Promise { const sp = splitPath(composite) if (!sp) { sendJson(res, 404, { error: 'collection not found' }); return } const idx = readIndexFor(sp.col) const node = idx?.files.find((f) => f.path === sp.rel) if (!idx || !node) { sendJson(res, 404, { error: 'node not found' }); return } let content: string | null = null let meta: unknown = null let text: string | null = null if (node.mode === 'extracted' && node.out) { try { content = await fsp.readFile(path.join(sp.col.dir, node.out, 'content.md'), 'utf-8') } catch { /* */ } try { meta = JSON.parse(await fsp.readFile(path.join(sp.col.dir, node.out, 'meta.json'), 'utf-8')) } catch { /* */ } } else if (node.mode === 'link' && TEXT_EXTS.has(node.ext)) { const abs = originalAbs(idx, sp.rel) if (abs) { try { text = await fsp.readFile(abs, 'utf-8') } catch { /* */ } } } sendJson(res, 200, { node: { ...node, collection: sp.col.id, path: composite }, content, meta, text, mime: MIME[path.extname(node.name).toLowerCase()] ?? 'application/octet-stream', originalUrl: `/api/original?path=${encodeURIComponent(composite)}`, }) } async function serveOriginal(res: ServerResponse, composite: string, range?: string): Promise { const sp = splitPath(composite) const idx = sp ? readIndexFor(sp.col) : null const abs = sp && idx ? originalAbs(idx, sp.rel) : null if (!abs || !isFile(abs)) { sendJson(res, 404, { error: 'original not reachable' }); return } const mime = MIME[path.extname(abs).toLowerCase()] ?? 'application/octet-stream' const size = fs.statSync(abs).size res.setHeader('Content-Type', mime) res.setHeader('Accept-Ranges', 'bytes') const m = range?.match(/bytes=(\d*)-(\d*)/) if (m) { const start = m[1] ? parseInt(m[1], 10) : 0 const end = m[2] ? parseInt(m[2], 10) : size - 1 if (start >= size || end >= size || start > end) { res.statusCode = 416; res.setHeader('Content-Range', `bytes */${size}`); res.end(); return } res.statusCode = 206 res.setHeader('Content-Range', `bytes ${start}-${end}/${size}`) res.setHeader('Content-Length', String(end - start + 1)) fs.createReadStream(abs, { start, end }).pipe(res) return } res.setHeader('Content-Length', String(size)) fs.createReadStream(abs).pipe(res) } interface MeetingData { segments: Array<{ start: number; end: number; text: string; speaker: string | null }> frames: Array<{ file: string; url: string; time: number | null; size: number }> enhancedAvailable: boolean } async function meetingData(composite: string): Promise { const sp = splitPath(composite) if (!sp) return null const { dir, stem } = meetusDir(sp.col, sp.rel) const segments: MeetingData['segments'] = [] const whisper = path.join(dir, `${stem}.json`) if (fs.existsSync(whisper)) { try { const data = JSON.parse(await fsp.readFile(whisper, 'utf-8')) for (const s of (Array.isArray(data) ? data : data.segments ?? [])) { segments.push({ start: Number(s.start ?? 0), end: Number(s.end ?? s.start ?? 0), text: String(s.text ?? '').trim(), speaker: s.speaker ?? null, }) } } catch { /* none */ } } const enhanced = path.join(dir, `${stem}_enhanced.txt`) const times = new Map() let enhancedAvailable = false if (fs.existsSync(enhanced)) { try { enhancedAvailable = true let last: number | null = null for (const line of (await fsp.readFile(enhanced, 'utf-8')).split('\n')) { const ts = line.match(/^\[(\d+):(\d+)\]/) if (ts) last = Number(ts[1]) * 60 + Number(ts[2]) const fm = line.match(/Frame:\s*\S*?([^/\\\s]+\.jpg)/i) if (fm && last != null) times.set(fm[1], last) } } catch { /* none */ } } const frames: MeetingData['frames'] = [] try { const framesDir = path.join(dir, 'frames') for (const file of (await fsp.readdir(framesDir)).filter((f) => f.toLowerCase().endsWith('.jpg')).sort()) { const fny = file.match(/_(\d+(?:\.\d+)?)s\.jpg$/i) let size = 0 try { size = (await fsp.stat(path.join(framesDir, file))).size } catch { /* */ } frames.push({ file, url: `/api/meeting/frame?path=${encodeURIComponent(composite)}&file=${encodeURIComponent(file)}`, time: fny ? Number(fny[1]) : times.get(file) ?? null, size, }) } } catch { /* no frames */ } return { segments, frames, enhancedAvailable } } async function getMeeting(res: ServerResponse, composite: string): Promise { const sp = splitPath(composite) const data = await meetingData(composite) if (!sp || !data) { sendJson(res, 404, { error: 'meeting not found' }); return } sendJson(res, 200, { path: composite, hasOutput: fs.existsSync(meetusDir(sp.col, sp.rel).dir), ...data, videoUrl: `/api/original?path=${encodeURIComponent(composite)}`, }) } async function getRunShaped(res: ServerResponse, id: string): Promise { const sp = splitPath(id) const data = await meetingData(id) if (!sp || !data) { sendJson(res, 404, { error: 'run not found' }); return } const { dir } = meetusDir(sp.col, sp.rel) let review: unknown = null try { review = JSON.parse(await fsp.readFile(path.join(dir, 'review.json'), 'utf-8')) } catch { /* */ } sendJson(res, 200, { id, manifest: { video: { name: path.basename(sp.rel) } }, segments: data.segments, frames: data.frames, enhancedAvailable: data.enhancedAvailable, hasVideo: true, videoUrl: `/api/original?path=${encodeURIComponent(id)}`, review, }) } async function saveRunReview(req: IncomingMessage, res: ServerResponse, id: string): Promise { const sp = splitPath(id) if (!sp) { sendJson(res, 404, { error: 'run not found' }); return } const { dir } = meetusDir(sp.col, sp.rel) if (!fs.existsSync(dir)) { sendJson(res, 404, { error: 'no meetus output for this meeting' }); return } let parsed: unknown try { parsed = JSON.parse((await readBody(req)) || '{}') } catch { sendJson(res, 400, { error: 'invalid JSON' }); return } const tmp = path.join(dir, `.review.${process.pid}.tmp`) await fsp.writeFile(tmp, JSON.stringify(parsed, null, 2), 'utf-8') await fsp.rename(tmp, path.join(dir, 'review.json')) sendJson(res, 200, { ok: true }) } function serveMeetingFrame(res: ServerResponse, composite: string, file: string): void { const sp = splitPath(composite) if (!sp) { sendJson(res, 404, { error: 'not found' }); return } const { dir } = meetusDir(sp.col, sp.rel) const safe = path.basename(file) const full = path.join(dir, 'frames', safe) if (safe !== file || !fs.existsSync(full)) { sendJson(res, 404, { error: 'frame not found' }); return } res.setHeader('Content-Type', 'image/jpeg') res.setHeader('Cache-Control', 'no-cache') fs.createReadStream(full).pipe(res) } async function search(res: ServerResponse, query: string, activeCsv: string | null): Promise { const qq = query.trim().toLowerCase() const active = activeCsv ? new Set(activeCsv.split(',').filter(Boolean)) : null const cols = currentCollections().filter((c) => !active || active.has(c.id)) let matches: string[] = [] let bytes = 0 if (qq) { for (const c of cols) { let cur = 0 try { cur = fs.statSync(c.indexPath).mtimeMs } catch { /* */ } const cached = searchCache.get(c.id) if (!cached || cached.mtime !== cur) await buildSearchIndex(c) for (const e of searchCache.get(c.id)?.entries ?? []) { if (e.hay.includes(qq)) { matches.push(e.path); bytes += e.bytes } } } } sendJson(res, 200, { query: qq, matches, count: matches.length, bytes }) } } function sendJson(res: ServerResponse, status: number, obj: unknown): void { const body = JSON.stringify(obj) res.statusCode = status res.setHeader('Content-Type', 'application/json') res.setHeader('Content-Length', Buffer.byteLength(body)) res.end(body) } function isFile(p: string): boolean { try { return fs.statSync(p).isFile() } catch { return false } } function isDir(p: string): boolean { try { return fs.statSync(p).isDirectory() } catch { return false } } function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let data = '' req.on('data', (c) => { data += c; if (data.length > 5_000_000) reject(new Error('body too large')) }) req.on('end', () => resolve(data)) req.on('error', reject) }) }