Files
meetus/ui/doocus-app/server/doocusApi.ts
2026-07-06 01:18:17 -03:00

523 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}${f.path}`
const score = scoreNode(c, f)
const cur = best.get(key)
if (!cur || score > cur.score) {
best.set(key, {
file: { ...f, collection: c.id, root: idx.root, rel: f.path, path: `${c.id}/${f.path}` },
score,
})
}
}
}
const files = [...best.values()].map((v) => v.file)
const counts: Record<string, number> = { extracted: 0, meeting: 0, link: 0, total: files.length }
for (const f of files) counts[f.mode] = (counts[f.mode] ?? 0) + 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)
})
}