diff --git a/ui/doocus-app/server/doocusApi.ts b/ui/doocus-app/server/doocusApi.ts index 72c8dc7..15ebc35 100644 --- a/ui/doocus-app/server/doocusApi.ts +++ b/ui/doocus-app/server/doocusApi.ts @@ -12,7 +12,7 @@ * GET /api/original?path= stream the original file (native viewer / packaging) */ import type { Plugin } from 'vite' -import type { ServerResponse } from 'node:http' +import type { IncomingMessage, ServerResponse } from 'node:http' import fs from 'node:fs' import fsp from 'node:fs/promises' import path from 'node:path' @@ -80,7 +80,7 @@ export function doocusApi(opts: Options): Plugin { 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) => { + 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() }) @@ -88,7 +88,8 @@ export function doocusApi(opts: Options): Plugin { }, } - async function handle(res: ServerResponse, parts: string[], q: URLSearchParams, range?: string): Promise { + async function handle(req: IncomingMessage, res: ServerResponse, parts: string[], q: URLSearchParams): Promise { + const range = req.headers.range if (parts[0] === 'tree') { const index = readIndex() if (!index) { sendJson(res, 404, { error: `index.json not found in ${outputDir}` }); return true } @@ -117,6 +118,14 @@ export function doocusApi(opts: Options): Plugin { 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) { + 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 } @@ -125,10 +134,16 @@ export function doocusApi(opts: Options): Plugin { return { dir: path.join(outputDir, rel + '.meetus'), stem: path.parse(rel).name } } - async function getMeeting(res: ServerResponse, rel: string): Promise { + 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 + } + + /** Read a meeting's .meetus output into the shared review shape. */ + async function meetingData(rel: string): Promise { const { dir, stem } = meetusDirFor(rel) - // Whisper segments (may be null-flagged in manifest but present on disk). - const segments: Array<{ start: number; end: number; text: string; speaker: string | null }> = [] + const segments: MeetingData['segments'] = [] const whisper = path.join(dir, `${stem}.json`) if (fs.existsSync(whisper)) { try { @@ -159,28 +174,66 @@ export function doocusApi(opts: Options): Plugin { } catch { /* none */ } } - const frames: Array<{ file: string; url: string; time: number | null }> = [] + const frames: MeetingData['frames'] = [] try { - for (const file of (await fsp.readdir(path.join(dir, 'frames'))).filter((f) => f.toLowerCase().endsWith('.jpg')).sort()) { + 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(rel)}&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, rel: string): Promise { + const { dir } = meetusDirFor(rel) + const data = await meetingData(rel) sendJson(res, 200, { path: rel, hasOutput: fs.existsSync(dir), - segments, - frames, - enhancedAvailable, + ...data, videoUrl: `/api/original?path=${encodeURIComponent(rel)}`, }) } + /** meetus store's /api/runs/:id shape (id = meeting rel path). */ + async function getRunShaped(res: ServerResponse, id: string): Promise { + const { dir } = meetusDirFor(id) + const data = await meetingData(id) + let review: unknown = null + try { review = JSON.parse(await fsp.readFile(path.join(dir, 'review.json'), 'utf-8')) } catch { /* none */ } + sendJson(res, 200, { + id, + manifest: { video: { name: path.basename(id) } }, + segments: data.segments, + frames: data.frames, + enhancedAvailable: data.enhancedAvailable, + hasVideo: true, + videoUrl: `/api/original?path=${encodeURIComponent(id)}`, + review, + }) + } + + /** Persist the review sidecar next to the meeting's .meetus output (atomic). */ + async function saveRunReview(req: IncomingMessage, res: ServerResponse, id: string): Promise { + const { dir } = meetusDirFor(id) + 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, rel: string, file: string): void { const { dir } = meetusDirFor(rel) const safe = path.basename(file) @@ -262,3 +315,12 @@ function sendJson(res: ServerResponse, status: number, obj: unknown): void { function isFile(p: string): boolean { try { return fs.statSync(p).isFile() } catch { return false } } + +function readBody(req: IncomingMessage): Promise { + 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) + }) +} diff --git a/ui/doocus-app/src/components/MeetingReview.vue b/ui/doocus-app/src/components/MeetingReview.vue index fff9812..39ad83a 100644 --- a/ui/doocus-app/src/components/MeetingReview.vue +++ b/ui/doocus-app/src/components/MeetingReview.vue @@ -1,93 +1,30 @@ diff --git a/ui/doocus-app/tsconfig.json b/ui/doocus-app/tsconfig.json index 4a7f106..d9410a0 100644 --- a/ui/doocus-app/tsconfig.json +++ b/ui/doocus-app/tsconfig.json @@ -14,6 +14,7 @@ "baseUrl": ".", "paths": { "@framework/*": ["../framework/src/*"], + "@meetus/*": ["../meetus-app/src/*"], "@/*": ["src/*"], "vue": ["node_modules/vue"] } diff --git a/ui/doocus-app/vite.config.ts b/ui/doocus-app/vite.config.ts index f98d9a5..e2bb177 100644 --- a/ui/doocus-app/vite.config.ts +++ b/ui/doocus-app/vite.config.ts @@ -13,15 +13,18 @@ export default defineConfig({ resolve: { alias: { '@framework': fileURLToPath(new URL('../framework/src', import.meta.url)), + // Compose meetus's own review components (single source; portable imports). + '@meetus': fileURLToPath(new URL('../meetus-app/src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, server: { fs: { - // Allow the dev server to read the framework source and the docs-output tree. + // Allow the dev server to read the framework + meetus-app source and the output tree. allow: [ fileURLToPath(new URL('.', import.meta.url)), fileURLToPath(new URL('../framework', import.meta.url)), + fileURLToPath(new URL('../meetus-app', import.meta.url)), outputDir, ], }, diff --git a/ui/meetus-app/src/App.vue b/ui/meetus-app/src/App.vue index 9071bca..858287b 100644 --- a/ui/meetus-app/src/App.vue +++ b/ui/meetus-app/src/App.vue @@ -1,14 +1,7 @@ diff --git a/ui/meetus-app/src/components/FrameSelector.vue b/ui/meetus-app/src/components/FrameSelector.vue index ccd383b..7cb01f9 100644 --- a/ui/meetus-app/src/components/FrameSelector.vue +++ b/ui/meetus-app/src/components/FrameSelector.vue @@ -1,7 +1,7 @@ + + + + diff --git a/ui/meetus-app/src/components/TranscriptEditor.vue b/ui/meetus-app/src/components/TranscriptEditor.vue index 005823f..f64efaf 100644 --- a/ui/meetus-app/src/components/TranscriptEditor.vue +++ b/ui/meetus-app/src/components/TranscriptEditor.vue @@ -1,13 +1,17 @@