Files
meetus/ui/doocus-app/server/doocusApi.ts
Mariano Gabriel bb84558526 scan
2026-07-05 22:59:27 -03:00

485 lines
20 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)
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<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 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<string>()
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 "<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 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<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)
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<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
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 <source> --output <dir>` at repoRoot. */
function runProcessTree(source: string, dir: string): Promise<void> {
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<Node & { collection: string }> = []
const counts: Record<string, number> = { 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<void> {
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<void> {
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<MeetingData | null> {
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<string, number>()
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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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)
})
}