merging
This commit is contained in:
@@ -1,15 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Vite dev middleware exposing the local doocus tree index to the app.
|
* Vite dev middleware exposing one or more local doocus output trees.
|
||||||
*
|
*
|
||||||
* Reads `docs-output/index.json` (produced by process_tree.py) — a replica of
|
* Each output dir is a "collection" (its own index.json + sidecars + source
|
||||||
* the whole local drive tree — plus the extracted `<file>.doocus/content.md`
|
* root). The client can turn collections on/off; file paths are composite —
|
||||||
* sidecars, and streams the ORIGINAL files by relative path (resolved against
|
* "<collectionId>/<relpath>" — so a single path identifies both the collection
|
||||||
* the index's recorded root). All reads are local; nothing leaves the machine.
|
* and the file within it. All reads are local; nothing leaves the machine.
|
||||||
*
|
*
|
||||||
* Routes:
|
* Routes (path params are composite, query is ?path=/?q=):
|
||||||
* GET /api/tree the index.json tree
|
* GET /api/collections available collections
|
||||||
* GET /api/detail?path=<rel> node + extracted content + meta + inline text
|
* GET /api/tree?collections=a,b merged tree of the active collections
|
||||||
* GET /api/original?path=<rel> stream the original file (native viewer / packaging)
|
* 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 { Plugin } from 'vite'
|
||||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||||
@@ -18,7 +24,14 @@ import fsp from 'node:fs/promises'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
outputDir: string
|
outputDirs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Collection {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
dir: string
|
||||||
|
indexPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
@@ -31,16 +44,16 @@ interface Node {
|
|||||||
modified: string
|
modified: string
|
||||||
url: string | null
|
url: string | null
|
||||||
out?: string
|
out?: string
|
||||||
|
title?: string
|
||||||
|
warnings?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Index {
|
interface Index {
|
||||||
root: string
|
root: string
|
||||||
generatedAt: string
|
|
||||||
counts: Record<string, number>
|
|
||||||
files: Node[]
|
files: Node[]
|
||||||
|
counts?: Record<string, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 TEXT_EXTS = new Set(['md', 'markdown', 'txt', 'json', 'yaml', 'yml', 'csv', 'html', 'htm'])
|
||||||
|
|
||||||
const MIME: Record<string, string> = {
|
const MIME: Record<string, string> = {
|
||||||
@@ -53,19 +66,36 @@ const MIME: Record<string, string> = {
|
|||||||
'.mp4': 'video/mp4',
|
'.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 {
|
export function doocusApi(opts: Options): Plugin {
|
||||||
const outputDir = path.resolve(opts.outputDir)
|
const collections = buildCollections(opts.outputDirs)
|
||||||
const indexPath = path.join(outputDir, 'index.json')
|
const byId = new Map(collections.map((c) => [c.id, c]))
|
||||||
|
|
||||||
function readIndex(): Index | null {
|
function readIndexFor(c: Collection): Index | null {
|
||||||
try {
|
try { return JSON.parse(fs.readFileSync(c.indexPath, 'utf-8')) } catch { return null }
|
||||||
return JSON.parse(fs.readFileSync(indexPath, 'utf-8'))
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a node's original file, rejecting traversal outside the index root. */
|
/** 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 = byId.get(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 {
|
function originalAbs(index: Index, rel: string): string | null {
|
||||||
const root = path.resolve(index.root)
|
const root = path.resolve(index.root)
|
||||||
const abs = path.resolve(root, rel)
|
const abs = path.resolve(root, rel)
|
||||||
@@ -73,57 +103,43 @@ export function doocusApi(opts: Options): Plugin {
|
|||||||
return abs
|
return abs
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy search index over the cached small text — rebuilt when index.json changes.
|
function meetusDir(col: Collection, rel: string): { dir: string; stem: string } {
|
||||||
let searchCache: { mtime: number; entries: Array<{ path: string; hay: string; bytes: number }> } | null = null
|
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 }> }>()
|
||||||
|
|
||||||
// Read full text for reliable search (local files; speed is secondary). The
|
|
||||||
// generous cap only guards against a pathologically huge file.
|
|
||||||
async function readCap(p: string | null, cap = 20_000_000): Promise<string> {
|
async function readCap(p: string | null, cap = 20_000_000): Promise<string> {
|
||||||
if (!p) return ''
|
if (!p) return ''
|
||||||
try { return (await fsp.readFile(p, 'utf-8')).slice(0, cap) } catch { return '' }
|
try { return (await fsp.readFile(p, 'utf-8')).slice(0, cap) } catch { return '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildSearchIndex(): Promise<void> {
|
async function buildSearchIndex(c: Collection): Promise<void> {
|
||||||
const index = readIndex()
|
const index = readIndexFor(c)
|
||||||
if (!index) { searchCache = { mtime: 0, entries: [] }; return }
|
|
||||||
let mtime = 0
|
let mtime = 0
|
||||||
try { mtime = fs.statSync(indexPath).mtimeMs } catch { /* */ }
|
try { mtime = fs.statSync(c.indexPath).mtimeMs } catch { /* */ }
|
||||||
const entries: Array<{ path: string; hay: string; bytes: number }> = []
|
const entries: Array<{ path: string; hay: string; bytes: number }> = []
|
||||||
for (const f of index.files) {
|
for (const f of index?.files ?? []) {
|
||||||
let text = `${f.path} ${(f as any).title ?? ''}`
|
let text = `${f.path} ${f.title ?? ''}`
|
||||||
if (f.mode === 'extracted' && f.out) {
|
if (f.mode === 'extracted' && f.out) {
|
||||||
text += ' ' + await readCap(path.join(outputDir, f.out, 'content.md'))
|
text += ' ' + await readCap(path.join(c.dir, f.out, 'content.md'))
|
||||||
} else if (f.mode === 'meeting') {
|
} else if (f.mode === 'meeting') {
|
||||||
const { dir, stem } = meetusDirFor(f.path)
|
const { dir, stem } = meetusDir(c, f.path)
|
||||||
text += ' ' + await readCap(path.join(dir, `${stem}_enhanced.txt`))
|
text += ' ' + await readCap(path.join(dir, `${stem}_enhanced.txt`))
|
||||||
} else if (TEXT_EXTS.has(f.ext)) {
|
} else if (TEXT_EXTS.has(f.ext) && index) {
|
||||||
text += ' ' + await readCap(originalAbs(index, f.path))
|
text += ' ' + await readCap(originalAbs(index, f.path))
|
||||||
}
|
}
|
||||||
entries.push({ path: f.path, hay: text.toLowerCase(), bytes: f.bytes })
|
entries.push({ path: `${c.id}/${f.path}`, hay: text.toLowerCase(), bytes: f.bytes })
|
||||||
}
|
}
|
||||||
searchCache = { mtime, entries }
|
searchCache.set(c.id, { mtime, entries })
|
||||||
}
|
|
||||||
|
|
||||||
async function search(res: ServerResponse, query: string): Promise<void> {
|
|
||||||
const q = query.trim().toLowerCase()
|
|
||||||
let curMtime = 0
|
|
||||||
try { curMtime = fs.statSync(indexPath).mtimeMs } catch { /* */ }
|
|
||||||
if (!searchCache || searchCache.mtime !== curMtime) await buildSearchIndex()
|
|
||||||
const entries = searchCache?.entries ?? []
|
|
||||||
// Empty query matches nothing (the UI shows the full tree when not searching).
|
|
||||||
const hits = q ? entries.filter((e) => e.hay.includes(q)) : []
|
|
||||||
sendJson(res, 200, {
|
|
||||||
query: q,
|
|
||||||
matches: hits.map((e) => e.path),
|
|
||||||
count: hits.length,
|
|
||||||
bytes: hits.reduce((s, e) => s + e.bytes, 0),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'doocus-api',
|
name: 'doocus-api',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.config.logger.info(`[doocus-api] serving tree from: ${indexPath}`)
|
server.config.logger.info(
|
||||||
|
`[doocus-api] ${collections.length} collection(s): ${collections.map((c) => `${c.id} → ${c.dir}`).join(', ')}`)
|
||||||
server.middlewares.use('/api', (req, res, next) => {
|
server.middlewares.use('/api', (req, res, next) => {
|
||||||
const url = new URL(req.url ?? '/', 'http://localhost')
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
||||||
const parts = url.pathname.split('/').filter(Boolean)
|
const parts = url.pathname.split('/').filter(Boolean)
|
||||||
@@ -136,54 +152,100 @@ export function doocusApi(opts: Options): Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handle(req: IncomingMessage, res: ServerResponse, parts: string[], q: URLSearchParams): Promise<boolean> {
|
async function handle(req: IncomingMessage, res: ServerResponse, parts: string[], q: URLSearchParams): Promise<boolean> {
|
||||||
const range = req.headers.range
|
if (parts[0] === 'collections') { listCollections(res); return true }
|
||||||
if (parts[0] === 'tree') {
|
if (parts[0] === 'tree') { getTree(res, q.get('collections')); return true }
|
||||||
const index = readIndex()
|
if (parts[0] === 'detail') { await getDetail(res, q.get('path') ?? ''); return true }
|
||||||
if (!index) { sendJson(res, 404, { error: `index.json not found in ${outputDir}` }); return true }
|
if (parts[0] === 'original') { await serveOriginal(res, q.get('path') ?? '', req.headers.range); return true }
|
||||||
sendJson(res, 200, index)
|
if (parts[0] === 'meeting' && parts[1] === 'frame') { serveMeetingFrame(res, q.get('path') ?? '', q.get('file') ?? ''); return true }
|
||||||
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] === 'detail') {
|
|
||||||
await getDetail(res, q.get('path') ?? '')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts[0] === 'original') {
|
|
||||||
await serveOriginal(res, q.get('path') ?? '', range)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/meeting?path=<rel> meetus review data for a meeting
|
|
||||||
// GET /api/meeting/frame?path=<rel>&file=<f> stream a frame from its .meetus/
|
|
||||||
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') ?? '')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// meetus-shaped run API (id = meeting rel path), so the composed meetus
|
|
||||||
// review store works unchanged when embedded in doocus.
|
|
||||||
if (parts[0] === 'runs' && parts.length >= 2) {
|
if (parts[0] === 'runs' && parts.length >= 2) {
|
||||||
const id = decodeURIComponent(parts[1])
|
const id = decodeURIComponent(parts[1])
|
||||||
if (parts.length === 2 && req.method === 'GET') { await getRunShaped(res, id); return true }
|
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 }
|
if (parts.length === 3 && parts[2] === 'review' && req.method === 'PUT') { await saveRunReview(req, res, id); return true }
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The .meetus sidecar dir + the video stem for a meeting node's rel path. */
|
function listCollections(res: ServerResponse): void {
|
||||||
function meetusDirFor(rel: string): { dir: string; stem: string } {
|
const out = collections.map((c) => {
|
||||||
return { dir: path.join(outputDir, rel + '.meetus'), stem: path.parse(rel).name }
|
const idx = readIndexFor(c)
|
||||||
|
return { id: c.id, name: c.name, available: !!idx, count: idx?.files.length ?? 0 }
|
||||||
|
})
|
||||||
|
sendJson(res, 200, { collections: out })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTree(res: ServerResponse, activeCsv: string | null): void {
|
||||||
|
const active = activeCsv ? new Set(activeCsv.split(',').filter(Boolean)) : null
|
||||||
|
const cols = collections.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: collections.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 {
|
interface MeetingData {
|
||||||
@@ -192,9 +254,10 @@ export function doocusApi(opts: Options): Plugin {
|
|||||||
enhancedAvailable: boolean
|
enhancedAvailable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read a meeting's .meetus output into the shared review shape. */
|
async function meetingData(composite: string): Promise<MeetingData | null> {
|
||||||
async function meetingData(rel: string): Promise<MeetingData> {
|
const sp = splitPath(composite)
|
||||||
const { dir, stem } = meetusDirFor(rel)
|
if (!sp) return null
|
||||||
|
const { dir, stem } = meetusDir(sp.col, sp.rel)
|
||||||
const segments: MeetingData['segments'] = []
|
const segments: MeetingData['segments'] = []
|
||||||
const whisper = path.join(dir, `${stem}.json`)
|
const whisper = path.join(dir, `${stem}.json`)
|
||||||
if (fs.existsSync(whisper)) {
|
if (fs.existsSync(whisper)) {
|
||||||
@@ -208,8 +271,6 @@ export function doocusApi(opts: Options): Plugin {
|
|||||||
}
|
}
|
||||||
} catch { /* none */ }
|
} catch { /* none */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frame → time from enhanced.txt ([MM:SS] before each `Frame: frames/…`).
|
|
||||||
const enhanced = path.join(dir, `${stem}_enhanced.txt`)
|
const enhanced = path.join(dir, `${stem}_enhanced.txt`)
|
||||||
const times = new Map<string, number>()
|
const times = new Map<string, number>()
|
||||||
let enhancedAvailable = false
|
let enhancedAvailable = false
|
||||||
@@ -225,7 +286,6 @@ export function doocusApi(opts: Options): Plugin {
|
|||||||
}
|
}
|
||||||
} catch { /* none */ }
|
} catch { /* none */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const frames: MeetingData['frames'] = []
|
const frames: MeetingData['frames'] = []
|
||||||
try {
|
try {
|
||||||
const framesDir = path.join(dir, 'frames')
|
const framesDir = path.join(dir, 'frames')
|
||||||
@@ -235,36 +295,37 @@ export function doocusApi(opts: Options): Plugin {
|
|||||||
try { size = (await fsp.stat(path.join(framesDir, file))).size } catch { /* */ }
|
try { size = (await fsp.stat(path.join(framesDir, file))).size } catch { /* */ }
|
||||||
frames.push({
|
frames.push({
|
||||||
file,
|
file,
|
||||||
url: `/api/meeting/frame?path=${encodeURIComponent(rel)}&file=${encodeURIComponent(file)}`,
|
url: `/api/meeting/frame?path=${encodeURIComponent(composite)}&file=${encodeURIComponent(file)}`,
|
||||||
time: fny ? Number(fny[1]) : times.get(file) ?? null,
|
time: fny ? Number(fny[1]) : times.get(file) ?? null,
|
||||||
size,
|
size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch { /* no frames */ }
|
} catch { /* no frames */ }
|
||||||
|
|
||||||
return { segments, frames, enhancedAvailable }
|
return { segments, frames, enhancedAvailable }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMeeting(res: ServerResponse, rel: string): Promise<void> {
|
async function getMeeting(res: ServerResponse, composite: string): Promise<void> {
|
||||||
const { dir } = meetusDirFor(rel)
|
const sp = splitPath(composite)
|
||||||
const data = await meetingData(rel)
|
const data = await meetingData(composite)
|
||||||
|
if (!sp || !data) { sendJson(res, 404, { error: 'meeting not found' }); return }
|
||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
path: rel,
|
path: composite,
|
||||||
hasOutput: fs.existsSync(dir),
|
hasOutput: fs.existsSync(meetusDir(sp.col, sp.rel).dir),
|
||||||
...data,
|
...data,
|
||||||
videoUrl: `/api/original?path=${encodeURIComponent(rel)}`,
|
videoUrl: `/api/original?path=${encodeURIComponent(composite)}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** meetus store's /api/runs/:id shape (id = meeting rel path). */
|
|
||||||
async function getRunShaped(res: ServerResponse, id: string): Promise<void> {
|
async function getRunShaped(res: ServerResponse, id: string): Promise<void> {
|
||||||
const { dir } = meetusDirFor(id)
|
const sp = splitPath(id)
|
||||||
const data = await meetingData(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
|
let review: unknown = null
|
||||||
try { review = JSON.parse(await fsp.readFile(path.join(dir, 'review.json'), 'utf-8')) } catch { /* none */ }
|
try { review = JSON.parse(await fsp.readFile(path.join(dir, 'review.json'), 'utf-8')) } catch { /* */ }
|
||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
id,
|
id,
|
||||||
manifest: { video: { name: path.basename(id) } },
|
manifest: { video: { name: path.basename(sp.rel) } },
|
||||||
segments: data.segments,
|
segments: data.segments,
|
||||||
frames: data.frames,
|
frames: data.frames,
|
||||||
enhancedAvailable: data.enhancedAvailable,
|
enhancedAvailable: data.enhancedAvailable,
|
||||||
@@ -274,9 +335,10 @@ export function doocusApi(opts: Options): Plugin {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist the review sidecar next to the meeting's .meetus output (atomic). */
|
|
||||||
async function saveRunReview(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
async function saveRunReview(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
||||||
const { dir } = meetusDirFor(id)
|
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 }
|
if (!fs.existsSync(dir)) { sendJson(res, 404, { error: 'no meetus output for this meeting' }); return }
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
try { parsed = JSON.parse((await readBody(req)) || '{}') } catch { sendJson(res, 400, { error: 'invalid JSON' }); return }
|
try { parsed = JSON.parse((await readBody(req)) || '{}') } catch { sendJson(res, 400, { error: 'invalid JSON' }); return }
|
||||||
@@ -286,8 +348,10 @@ export function doocusApi(opts: Options): Plugin {
|
|||||||
sendJson(res, 200, { ok: true })
|
sendJson(res, 200, { ok: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
function serveMeetingFrame(res: ServerResponse, rel: string, file: string): void {
|
function serveMeetingFrame(res: ServerResponse, composite: string, file: string): void {
|
||||||
const { dir } = meetusDirFor(rel)
|
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 safe = path.basename(file)
|
||||||
const full = path.join(dir, 'frames', safe)
|
const full = path.join(dir, 'frames', safe)
|
||||||
if (safe !== file || !fs.existsSync(full)) { sendJson(res, 404, { error: 'frame not found' }); return }
|
if (safe !== file || !fs.existsSync(full)) { sendJson(res, 404, { error: 'frame not found' }); return }
|
||||||
@@ -296,63 +360,24 @@ export function doocusApi(opts: Options): Plugin {
|
|||||||
fs.createReadStream(full).pipe(res)
|
fs.createReadStream(full).pipe(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDetail(res: ServerResponse, rel: string): Promise<void> {
|
async function search(res: ServerResponse, query: string, activeCsv: string | null): Promise<void> {
|
||||||
const index = readIndex()
|
const qq = query.trim().toLowerCase()
|
||||||
if (!index) { sendJson(res, 404, { error: 'no index' }); return }
|
const active = activeCsv ? new Set(activeCsv.split(',').filter(Boolean)) : null
|
||||||
const node = index.files.find((f) => f.path === rel)
|
const cols = collections.filter((c) => !active || active.has(c.id))
|
||||||
if (!node) { sendJson(res, 404, { error: 'node not found' }); return }
|
let matches: string[] = []
|
||||||
|
let bytes = 0
|
||||||
let content: string | null = null // extracted search text (heavy formats)
|
if (qq) {
|
||||||
let meta: unknown = null
|
for (const c of cols) {
|
||||||
let text: string | null = null // inline text for text-native originals
|
let cur = 0
|
||||||
|
try { cur = fs.statSync(c.indexPath).mtimeMs } catch { /* */ }
|
||||||
if (node.mode === 'extracted' && node.out) {
|
const cached = searchCache.get(c.id)
|
||||||
try { content = await fsp.readFile(path.join(outputDir, node.out, 'content.md'), 'utf-8') } catch { /* */ }
|
if (!cached || cached.mtime !== cur) await buildSearchIndex(c)
|
||||||
try { meta = JSON.parse(await fsp.readFile(path.join(outputDir, node.out, 'meta.json'), 'utf-8')) } catch { /* */ }
|
for (const e of searchCache.get(c.id)?.entries ?? []) {
|
||||||
} else if (node.mode === 'link' && TEXT_EXTS.has(node.ext)) {
|
if (e.hay.includes(qq)) { matches.push(e.path); bytes += e.bytes }
|
||||||
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
|
sendJson(res, 200, { query: qq, matches, count: matches.length, bytes })
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import SplitPane from '@framework/components/SplitPane.vue'
|
|||||||
import FileTree from '@/components/FileTree.vue'
|
import FileTree from '@/components/FileTree.vue'
|
||||||
import DocDetail from '@/components/DocDetail.vue'
|
import DocDetail from '@/components/DocDetail.vue'
|
||||||
import ExportBar from '@/components/ExportBar.vue'
|
import ExportBar from '@/components/ExportBar.vue'
|
||||||
import { store, loadTree, runSearch, visibleTree, selectMatches, humanBytes } from '@/store'
|
import { ref } from 'vue'
|
||||||
|
import { store, loadTree, runSearch, visibleTree, selectMatches, humanBytes, toggleCollection } from '@/store'
|
||||||
|
|
||||||
onMounted(loadTree)
|
onMounted(loadTree)
|
||||||
|
|
||||||
|
const showCols = ref(false)
|
||||||
|
|
||||||
const detailTitle = computed(() =>
|
const detailTitle = computed(() =>
|
||||||
store.detail?.node.mode === 'meeting' ? 'Meeting' : 'File')
|
store.detail?.node.mode === 'meeting' ? 'Meeting' : 'File')
|
||||||
const barLabel = computed(() => store.detail?.node.name ?? 'viewer')
|
const barLabel = computed(() => store.detail?.node.name ?? 'viewer')
|
||||||
@@ -23,6 +26,27 @@ function onSearch(e: Event) {
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<span class="brand">doocus</span>
|
<span class="brand">doocus</span>
|
||||||
|
|
||||||
|
<div class="collections">
|
||||||
|
<button
|
||||||
|
class="col-btn"
|
||||||
|
:title="`Sources — ${store.activeCollections.size}/${store.collections.length} active`"
|
||||||
|
@click="showCols = !showCols"
|
||||||
|
>⧉</button>
|
||||||
|
<div v-if="showCols" class="col-menu" @mouseleave="showCols = false">
|
||||||
|
<div class="col-title">Output sources</div>
|
||||||
|
<label v-for="c in store.collections" :key="c.id" :class="{ off: !c.available }">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="store.activeCollections.has(c.id)"
|
||||||
|
:disabled="!c.available"
|
||||||
|
@change="toggleCollection(c.id)"
|
||||||
|
/>
|
||||||
|
<span class="col-name">{{ c.name }}</span>
|
||||||
|
<span class="dim">{{ c.available ? c.count : 'no index' }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<input
|
<input
|
||||||
class="search-input"
|
class="search-input"
|
||||||
@@ -93,6 +117,22 @@ function onSearch(e: Event) {
|
|||||||
background: var(--surface-1); border-bottom: var(--panel-border); flex-shrink: 0;
|
background: var(--surface-1); border-bottom: var(--panel-border); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.brand { font-weight: 700; letter-spacing: 0.02em; flex-shrink: 0; }
|
.brand { font-weight: 700; letter-spacing: 0.02em; flex-shrink: 0; }
|
||||||
|
.collections { position: relative; flex-shrink: 0; }
|
||||||
|
.col-btn { padding: var(--space-1) var(--space-2); font-size: 15px; line-height: 1; }
|
||||||
|
.col-menu {
|
||||||
|
position: absolute; top: 110%; left: 0; z-index: 20; min-width: 220px;
|
||||||
|
background: var(--surface-1); border: var(--panel-border); border-radius: var(--panel-radius);
|
||||||
|
padding: var(--space-2); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.col-title { font-size: 11px; color: var(--text-secondary); margin-bottom: var(--space-1); }
|
||||||
|
.col-menu label {
|
||||||
|
display: flex; align-items: center; gap: var(--space-2);
|
||||||
|
padding: var(--space-1); border-radius: 3px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.col-menu label:hover { background: var(--surface-2); }
|
||||||
|
.col-menu label.off { opacity: 0.5; }
|
||||||
|
.col-name { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dim { color: var(--text-secondary); font-size: var(--font-size-sm); }
|
||||||
.search { flex: 1; display: flex; align-items: center; justify-content: center; gap: var(--space-2); }
|
.search { flex: 1; display: flex; align-items: center; justify-content: center; gap: var(--space-2); }
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 100%; max-width: 520px;
|
width: 100%; max-width: 520px;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
import { reactive, computed } from 'vue'
|
import { reactive, computed } from 'vue'
|
||||||
|
|
||||||
export interface FileNode {
|
export interface FileNode {
|
||||||
path: string
|
path: string // composite: "<collectionId>/<relpath>"
|
||||||
|
collection?: string
|
||||||
name: string
|
name: string
|
||||||
ext: string
|
ext: string
|
||||||
family: string
|
family: string
|
||||||
@@ -22,6 +23,13 @@ export interface FileNode {
|
|||||||
warnings?: string[]
|
warnings?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CollectionInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
available: boolean
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface Detail {
|
export interface Detail {
|
||||||
node: FileNode
|
node: FileNode
|
||||||
content: string | null // extracted search text (heavy formats), markdown
|
content: string | null // extracted search text (heavy formats), markdown
|
||||||
@@ -53,7 +61,8 @@ export const TARGETS: Target[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
root: string
|
collections: CollectionInfo[]
|
||||||
|
activeCollections: Set<string>
|
||||||
files: FileNode[]
|
files: FileNode[]
|
||||||
tree: TreeItem[]
|
tree: TreeItem[]
|
||||||
counts: Record<string, number>
|
counts: Record<string, number>
|
||||||
@@ -74,7 +83,8 @@ interface State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const store = reactive<State>({
|
export const store = reactive<State>({
|
||||||
root: '',
|
collections: [],
|
||||||
|
activeCollections: new Set<string>(),
|
||||||
files: [],
|
files: [],
|
||||||
tree: [],
|
tree: [],
|
||||||
counts: {},
|
counts: {},
|
||||||
@@ -110,7 +120,7 @@ export function runSearch(q: string): void {
|
|||||||
store.searching = true
|
store.searching = true
|
||||||
searchTimer = setTimeout(async () => {
|
searchTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
|
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&collections=${encodeURIComponent(activeCsv())}`)
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const d = await res.json()
|
const d = await res.json()
|
||||||
if (store.query !== q) return // a newer query superseded this one
|
if (store.query !== q) return // a newer query superseded this one
|
||||||
@@ -163,14 +173,31 @@ function buildTree(files: FileNode[]): TreeItem[] {
|
|||||||
return sort(root.children!)
|
return sort(root.children!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activeCsv(): string {
|
||||||
|
return [...store.activeCollections].join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load available collections; default every available one to active. */
|
||||||
|
export async function loadCollections(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/collections')
|
||||||
|
const data = await res.json()
|
||||||
|
store.collections = data.collections ?? []
|
||||||
|
// Default: all available collections on.
|
||||||
|
store.activeCollections = new Set(store.collections.filter((c) => c.available).map((c) => c.id))
|
||||||
|
} catch (e) {
|
||||||
|
store.error = String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadTree(): Promise<void> {
|
export async function loadTree(): Promise<void> {
|
||||||
store.loading = true
|
store.loading = true
|
||||||
store.error = ''
|
store.error = ''
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tree')
|
if (!store.collections.length) await loadCollections()
|
||||||
|
const res = await fetch(`/api/tree?collections=${encodeURIComponent(activeCsv())}`)
|
||||||
if (!res.ok) { store.error = (await res.json()).error ?? `HTTP ${res.status}`; return }
|
if (!res.ok) { store.error = (await res.json()).error ?? `HTTP ${res.status}`; return }
|
||||||
const index = await res.json()
|
const index = await res.json()
|
||||||
store.root = index.root
|
|
||||||
store.files = index.files
|
store.files = index.files
|
||||||
store.counts = index.counts
|
store.counts = index.counts
|
||||||
store.tree = buildTree(index.files)
|
store.tree = buildTree(index.files)
|
||||||
@@ -181,6 +208,14 @@ export async function loadTree(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle a collection on/off and re-merge the tree (and re-run any search). */
|
||||||
|
export async function toggleCollection(id: string): Promise<void> {
|
||||||
|
if (store.activeCollections.has(id)) store.activeCollections.delete(id)
|
||||||
|
else store.activeCollections.add(id)
|
||||||
|
await loadTree()
|
||||||
|
if (store.query) runSearch(store.query)
|
||||||
|
}
|
||||||
|
|
||||||
export async function select(path: string): Promise<void> {
|
export async function select(path: string): Promise<void> {
|
||||||
store.selectedPath = path
|
store.selectedPath = path
|
||||||
store.detailCollapsed = false // opening a file expands the viewer
|
store.detailCollapsed = false // opening a file expands the viewer
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import vue from '@vitejs/plugin-vue'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { doocusApi } from './server/doocusApi'
|
import { doocusApi } from './server/doocusApi'
|
||||||
|
|
||||||
// Output directory produced by the doocus pipeline (process_doc.py /
|
// Output directories produced by the doocus pipeline. One or many, so several
|
||||||
// ctrl/batch_docs.sh). Override with DOOCUS_OUTPUT.
|
// sources can be joined in the tree/search. DOOCUS_OUTPUTS is comma-separated;
|
||||||
const outputDir = process.env.DOOCUS_OUTPUT
|
// DOOCUS_OUTPUT (single) is still honored for back-compat.
|
||||||
?? fileURLToPath(new URL('../../docs-output', import.meta.url))
|
const outputEnv = process.env.DOOCUS_OUTPUTS ?? process.env.DOOCUS_OUTPUT
|
||||||
|
const outputDirs = (outputEnv
|
||||||
|
? outputEnv.split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [fileURLToPath(new URL('../../docs-output', import.meta.url))])
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue(), doocusApi({ outputDir })],
|
plugins: [vue(), doocusApi({ outputDirs })],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@framework': fileURLToPath(new URL('../framework/src', import.meta.url)),
|
'@framework': fileURLToPath(new URL('../framework/src', import.meta.url)),
|
||||||
@@ -20,12 +23,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
fs: {
|
fs: {
|
||||||
// Allow the dev server to read the framework + meetus-app source and the output tree.
|
// Allow the dev server to read the framework + meetus-app source and every output tree.
|
||||||
allow: [
|
allow: [
|
||||||
fileURLToPath(new URL('.', import.meta.url)),
|
fileURLToPath(new URL('.', import.meta.url)),
|
||||||
fileURLToPath(new URL('../framework', import.meta.url)),
|
fileURLToPath(new URL('../framework', import.meta.url)),
|
||||||
fileURLToPath(new URL('../meetus-app', import.meta.url)),
|
fileURLToPath(new URL('../meetus-app', import.meta.url)),
|
||||||
outputDir,
|
...outputDirs,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user