523 lines
22 KiB
TypeScript
523 lines
22 KiB
TypeScript
/**
|
||
* 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 —
|
||
* "<collectionId>/<relpath>" — 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=<c/rel> node + extracted content + meta + text
|
||
* GET /api/original?path=<c/rel> stream the original (Range-enabled)
|
||
* GET /api/meeting?path=<c/rel> 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)
|
||
outputsRoots: string[] // managed roots (doocus-data, meetus-data): <root>/<source>/
|
||
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<string, number>
|
||
}
|
||
|
||
const TEXT_EXTS = new Set(['md', 'markdown', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm'])
|
||
|
||
const MIME: Record<string, string> = {
|
||
'.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<string, number>()
|
||
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 across the managed roots (each is <root>/<source>/). */
|
||
function discoverInRoots(): Collection[] {
|
||
const found: Collection[] = []
|
||
for (const root of opts.outputsRoots) {
|
||
let subs: fs.Dirent[] = []
|
||
try { subs = fs.readdirSync(root, { withFileTypes: true }) } catch { continue }
|
||
for (const d of subs) {
|
||
if (d.isDirectory() && fs.existsSync(path.join(root, d.name, 'index.json'))) {
|
||
found.push({
|
||
id: d.name, name: d.name,
|
||
dir: path.join(root, d.name),
|
||
indexPath: path.join(root, d.name, 'index.json'),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
return found
|
||
}
|
||
|
||
/** 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<string>()
|
||
for (const c of [...buildCollections(opts.outputDirs), ...discoverInRoots()]) {
|
||
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 "<collectionId>/<rel>" 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<string, { mtime: number; entries: Array<{ path: string; hay: string; bytes: number }> }>()
|
||
|
||
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(c: Collection): Promise<void> {
|
||
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 roots: ${opts.outputsRoots.join(', ')}`)
|
||
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<boolean> {
|
||
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)
|
||
const meetings = idx?.files.filter((f) => f.mode === 'meeting').length ?? 0
|
||
return {
|
||
id: c.id, name: c.name, available: !!idx,
|
||
count: idx?.files.length ?? 0,
|
||
root: idx?.root ?? null, // shared root → same source
|
||
only: (idx as any)?.only ?? 'all',
|
||
meetings,
|
||
}
|
||
})
|
||
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<void> {
|
||
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
|
||
let only = 'docs' // UI scans create document collections (meetings come from the batch)
|
||
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
|
||
only = (idx as any).only ?? 'all' // preserve the collection's kind on rescan
|
||
} 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.outputsRoots[0], slug) // docs land in the first root (doocus-data)
|
||
fs.mkdirSync(opts.outputsRoots[0], { recursive: true })
|
||
}
|
||
|
||
try {
|
||
await runProcessTree(source, dir, only)
|
||
sendJson(res, 200, { ok: true, id: path.basename(dir) })
|
||
} catch (e) {
|
||
sendJson(res, 500, { error: String(e) })
|
||
}
|
||
}
|
||
|
||
/** Spawn `uv run python process_tree.py <source> --output <dir> --only <only>`. */
|
||
function runProcessTree(source: string, dir: string, only: string): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
const child = spawn('uv',
|
||
['run', 'python', 'process_tree.py', source, '--output', dir, '--only', only],
|
||
{ 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}`)))
|
||
})
|
||
}
|
||
|
||
/** Higher = "richer" version of a file — the one whose sidecar resolves wins,
|
||
* so a meetus-data meeting (with .meetus) overrides a doocus copy without it. */
|
||
function scoreNode(c: Collection, f: Node): number {
|
||
if (f.mode === 'meeting') return fs.existsSync(meetusDir(c, f.path).dir) ? 4 : 2
|
||
if (f.mode === 'extracted') {
|
||
return f.out && fs.existsSync(path.join(c.dir, f.out, 'content.md')) ? 3 : 1.5
|
||
}
|
||
return 1
|
||
}
|
||
|
||
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))
|
||
|
||
// Dedup across collections by shared root + rel; the resolving copy wins.
|
||
const best = new Map<string, { file: Node & { collection: string; root: string; rel: string }; score: number }>()
|
||
for (const c of cols) {
|
||
const idx = readIndexFor(c)
|
||
if (!idx) continue
|
||
for (const f of idx.files) {
|
||
const key = `${idx.root} |