From 6a6ed5487d066bf87f10c4b4bc41796b02aa8558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 4 Mar 2026 23:52:57 +0000 Subject: [PATCH] save ocr/tts/transcription results next to source file from context menu Co-Authored-By: Claude Opus 4.6 --- src/servers/api/file-browser/router.ts | 73 ++++++++++++++++++- .../FileBrowserApp/useFileBrowserApp.ts | 15 ++-- .../officerdev/src/hooks/useFilesAPI.ts | 12 +-- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 7a9420ee..cd566e83 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -391,7 +391,7 @@ router.post('/save-result', async (ctx) => { // Text-to-speech with caching router.post('/tts', async (ctx) => { const user = ctx.get('user'); - const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; + const { path: filePath, root, saveNextTo } = ctx.get('body') as { path: string; root?: string; saveNextTo?: boolean }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); @@ -411,7 +411,22 @@ router.post('/tts', async (ctx) => { const cacheRel = dir ? `cache/tts/${voicePrefix}${dir}/${name}.mp3` : `cache/tts/${voicePrefix}${name}.mp3`; const cacheAbs = resolve(userDataDir, cacheRel); + if (saveNextTo) { + const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`; + const siblingAbs = resolveUserPath(rootDir, siblingRel); + if (existsSync(siblingAbs)) { + return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: true }); + } + } + if (existsSync(cacheAbs)) { + if (saveNextTo) { + const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`; + const siblingAbs = resolveUserPath(rootDir, siblingRel); + const cached = await readFile(cacheAbs); + await Bun.write(siblingAbs, cached); + return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: true }); + } return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' }); } @@ -441,6 +456,13 @@ router.post('/tts', async (ctx) => { const buffer = await res.arrayBuffer(); await Bun.write(cacheAbs, buffer); + if (saveNextTo) { + const siblingRel = dir ? `${dir}/${name}.mp3` : `${name}.mp3`; + const siblingAbs = resolveUserPath(rootDir, siblingRel); + await Bun.write(siblingAbs, buffer); + return ctx.json({ audioPath: `/${siblingRel}`, audioRoot: root ?? 'home', cached: false }); + } + return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' }); }); @@ -495,7 +517,7 @@ router.post('/tts-text', async (ctx) => { // OCR image via vision model with caching router.post('/ocr', async (ctx) => { const user = ctx.get('user'); - const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; + const { path: filePath, root, saveNextTo } = ctx.get('body') as { path: string; root?: string; saveNextTo?: boolean }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); @@ -508,7 +530,23 @@ router.post('/ocr', async (ctx) => { const cacheRel = dir ? `cache/ocr/${dir}/${name}.md` : `cache/ocr/${name}.md`; const cacheAbs = resolve(userDataDir, cacheRel); + // When saving next to file, check if the sibling .md already exists + if (saveNextTo) { + const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`; + const siblingAbs = resolveUserPath(rootDir, siblingRel); + if (existsSync(siblingAbs)) { + return ctx.json({ ocrPath: `/${siblingRel}`, ocrRoot: root ?? 'home', cached: true }); + } + } + if (existsSync(cacheAbs)) { + if (saveNextTo) { + const text = await readFile(cacheAbs, 'utf-8'); + const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`; + const siblingAbs = resolveUserPath(rootDir, siblingRel); + await Bun.write(siblingAbs, text); + return ctx.json({ ocrPath: `/${siblingRel}`, ocrRoot: root ?? 'home', cached: true }); + } const text = await readFile(cacheAbs, 'utf-8'); return ctx.json({ text, ocrPath: cacheRel, ocrRoot: 'user-data', cached: true }); } @@ -566,6 +604,13 @@ router.post('/ocr', async (ctx) => { await mkdir(dirname(cacheAbs), { recursive: true }); await Bun.write(cacheAbs, text); + if (saveNextTo) { + const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`; + const siblingAbs = resolveUserPath(rootDir, siblingRel); + await Bun.write(siblingAbs, text); + return ctx.json({ ocrPath: `/${siblingRel}`, ocrRoot: root ?? 'home', cached: false }); + } + return ctx.json({ text, ocrPath: cacheRel, ocrRoot: 'user-data', cached: false }); }); @@ -696,7 +741,7 @@ router.post('/extract', async (ctx) => { // Workflow: detect language → check user's spoken languages → translate if needed → transcribe router.post('/transcribe', async (ctx) => { const user = ctx.get('user'); - const { path: filePath, root } = ctx.get('body') as { path: string; root?: string }; + const { path: filePath, root, saveNextTo } = ctx.get('body') as { path: string; root?: string; saveNextTo?: boolean }; if (!filePath) throw errors.BAD_REQUEST('path is required'); const rootDir = getRootDir(user, root); @@ -709,7 +754,22 @@ router.post('/transcribe', async (ctx) => { const cacheRel = dir ? `cache/transcriptions/${dir}/${name}.md` : `cache/transcriptions/${name}.md`; const cacheAbs = resolve(userDataDir, cacheRel); + if (saveNextTo) { + const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`; + const siblingAbs = resolveUserPath(rootDir, siblingRel); + if (existsSync(siblingAbs)) { + return ctx.json({ transcriptionPath: `/${siblingRel}`, transcriptionRoot: root ?? 'home', cached: true }); + } + } + if (existsSync(cacheAbs)) { + if (saveNextTo) { + const text = await readFile(cacheAbs, 'utf-8'); + const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`; + const siblingAbs = resolveUserPath(rootDir, siblingRel); + await Bun.write(siblingAbs, text); + return ctx.json({ transcriptionPath: `/${siblingRel}`, transcriptionRoot: root ?? 'home', cached: true }); + } return ctx.json({ transcriptionPath: cacheRel, transcriptionRoot: 'user-data', cached: true }); } @@ -762,6 +822,13 @@ router.post('/transcribe', async (ctx) => { await mkdir(dirname(cacheAbs), { recursive: true }); await Bun.write(cacheAbs, text); + if (saveNextTo) { + const siblingRel = dir ? `${dir}/${name}.md` : `${name}.md`; + const siblingAbs = resolveUserPath(rootDir, siblingRel); + await Bun.write(siblingAbs, text); + return ctx.json({ transcriptionPath: `/${siblingRel}`, transcriptionRoot: root ?? 'home', cached: false }); + } + return ctx.json({ transcriptionPath: cacheRel, transcriptionRoot: 'user-data', cached: false }); }); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index b074a5cd..f42558e0 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -413,9 +413,10 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi const filePath = entryPath(entry.name); const toastId = toast.loading('Generating speech audio...'); try { - const { audioPath, audioRoot } = await files.tts(filePath); + const { audioPath } = await files.tts(filePath, { saveNextTo: true }); toast.dismiss(toastId); - setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot }); + refresh(); + setSearchParams({ view: audioPath }); } catch { toast.error('Failed to generate speech audio', { id: toastId }); } @@ -425,9 +426,10 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi const filePath = entryPath(entry.name); const toastId = toast.loading('Extracting text from image...'); try { - const { ocrPath, ocrRoot } = await files.ocr(filePath); + const { ocrPath } = await files.ocr(filePath, { saveNextTo: true }); toast.dismiss(toastId); - setSearchParams({ view: filePath, ephemeral: ocrPath, ephemeralRoot: ocrRoot }); + refresh(); + setSearchParams({ view: ocrPath }); } catch { toast.error('Failed to extract text from image', { id: toastId }); } @@ -437,9 +439,10 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi const filePath = entryPath(entry.name); const toastId = toast.loading('Transcribing audio...'); try { - const { transcriptionPath, transcriptionRoot } = await files.transcribe(filePath); + const { transcriptionPath } = await files.transcribe(filePath, { saveNextTo: true }); toast.dismiss(toastId); - setSearchParams({ view: filePath, ephemeral: transcriptionPath, ephemeralRoot: transcriptionRoot }); + refresh(); + setSearchParams({ view: transcriptionPath }); } catch { toast.error('Failed to transcribe audio', { id: toastId }); } diff --git a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts index 66d74b8c..ace219dc 100644 --- a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts +++ b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts @@ -48,17 +48,17 @@ export const useFilesAPI = (root: string = 'home') => { downloadVideo: (url: string, path: string, audioOnly: boolean) => client.post(withRoot('/file-browser/download-video'), { url, path, audioOnly }), - tts: (path: string) => - client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path, root }), + tts: (path: string, opts?: { saveNextTo?: boolean }) => + client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path, root, ...opts }), ttsText: (id: string, text: string) => client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts-text', { id, text }), - ocr: (path: string) => - client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path, root }), + ocr: (path: string, opts?: { saveNextTo?: boolean }) => + client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path, root, ...opts }), - transcribe: (path: string) => - client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path, root }), + transcribe: (path: string, opts?: { saveNextTo?: boolean }) => + client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path, root, ...opts }), extractAudio: (path: string) => client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path, root }),