Files
meetus/ui/doocus-app/server/doocusApi.ts
Mariano Gabriel 8606520ef2 doocus improvemnts
2026-07-05 20:35:44 -03:00

183 lines
6.5 KiB
TypeScript

/**
* Vite dev middleware exposing the local doocus tree index to the app.
*
* Reads `docs-output/index.json` (produced by process_tree.py) — a replica of
* the whole local drive tree — plus the extracted `<file>.doocus/content.md`
* sidecars, and streams the ORIGINAL files by relative path (resolved against
* the index's recorded root). All reads are local; nothing leaves the machine.
*
* Routes:
* GET /api/tree the index.json tree
* GET /api/detail?path=<rel> node + extracted content + meta + inline text
* GET /api/original?path=<rel> stream the original file (native viewer / packaging)
*/
import type { Plugin } from 'vite'
import type { ServerResponse } from 'node:http'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
interface Options {
outputDir: string
}
interface Node {
path: string
name: string
ext: string
family: string
mode: 'extracted' | 'meeting' | 'link'
bytes: number
modified: string
url: string | null
out?: string
}
interface Index {
root: string
generatedAt: string
counts: Record<string, number>
files: Node[]
}
// Text-native files: readable as-is, so the "original" doubles as its own text.
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',
}
export function doocusApi(opts: Options): Plugin {
const outputDir = path.resolve(opts.outputDir)
const indexPath = path.join(outputDir, 'index.json')
function readIndex(): Index | null {
try {
return JSON.parse(fs.readFileSync(indexPath, 'utf-8'))
} catch {
return null
}
}
/** Resolve a node's original file, rejecting traversal outside the index root. */
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
}
return {
name: 'doocus-api',
configureServer(server) {
server.config.logger.info(`[doocus-api] serving tree from: ${indexPath}`)
server.middlewares.use('/api', (req, res, next) => {
const url = new URL(req.url ?? '/', 'http://localhost')
const parts = url.pathname.split('/').filter(Boolean)
handle(res, parts, url.searchParams, req.headers.range).catch((err) => {
server.config.logger.error(`[doocus-api] ${String(err)}`)
sendJson(res, 500, { error: String(err) })
}).then((handled) => { if (!handled) next() })
})
},
}
async function handle(res: ServerResponse, parts: string[], q: URLSearchParams, range?: string): Promise<boolean> {
if (parts[0] === 'tree') {
const index = readIndex()
if (!index) { sendJson(res, 404, { error: `index.json not found in ${outputDir}` }); return true }
sendJson(res, 200, index)
return true
}
if (parts[0] === 'detail') {
await getDetail(res, q.get('path') ?? '')
return true
}
if (parts[0] === 'original') {
await serveOriginal(res, q.get('path') ?? '', range)
return true
}
return false
}
async function getDetail(res: ServerResponse, rel: string): Promise<void> {
const index = readIndex()
if (!index) { sendJson(res, 404, { error: 'no index' }); return }
const node = index.files.find((f) => f.path === rel)
if (!node) { sendJson(res, 404, { error: 'node not found' }); return }
let content: string | null = null // extracted search text (heavy formats)
let meta: unknown = null
let text: string | null = null // inline text for text-native originals
if (node.mode === 'extracted' && node.out) {
try { content = await fsp.readFile(path.join(outputDir, node.out, 'content.md'), 'utf-8') } catch { /* */ }
try { meta = JSON.parse(await fsp.readFile(path.join(outputDir, node.out, 'meta.json'), 'utf-8')) } catch { /* */ }
} else if (node.mode === 'link' && TEXT_EXTS.has(node.ext)) {
const abs = originalAbs(index, rel)
if (abs) { try { text = await fsp.readFile(abs, 'utf-8') } catch { /* */ } }
}
sendJson(res, 200, {
node,
content,
meta,
text,
mime: MIME[path.extname(node.name).toLowerCase()] ?? 'application/octet-stream',
originalUrl: `/api/original?path=${encodeURIComponent(rel)}`,
})
}
async function serveOriginal(res: ServerResponse, rel: string, range?: string): Promise<void> {
const index = readIndex()
if (!index) { sendJson(res, 404, { error: 'no index' }); return }
const abs = originalAbs(index, rel)
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')
// Range support — needed so large meeting videos can seek/stream.
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)
}
}
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 }
}