From 34c6cb7ac001e5d90ff4d17e4df68fcd5ef290f5 Mon Sep 17 00:00:00 2001 From: Fermin Cirella Date: Wed, 29 Jul 2026 19:52:49 -0300 Subject: [PATCH 1/7] feat: Add export and import options for original and translated scripts --- crates/koharu-app/src/llm.rs | 72 +------- crates/koharu-app/src/utils.rs | 66 +++++++ crates/koharu-rpc/src/routes/projects.rs | 226 ++++++++++++++++++++++- ui/components/MenuBar.tsx | 45 ++++- ui/lib/api/default/default.ts | 5 + ui/lib/api/schemas/exportFormat.ts | 1 + ui/lib/io/openFiles.ts | 28 +++ ui/lib/io/pagesIo.ts | 19 +- ui/lib/io/scene.ts | 19 ++ ui/public/locales/en-US/translation.json | 4 + 10 files changed, 410 insertions(+), 75 deletions(-) diff --git a/crates/koharu-app/src/llm.rs b/crates/koharu-app/src/llm.rs index f237e0f4a..3cba92798 100644 --- a/crates/koharu-app/src/llm.rs +++ b/crates/koharu-app/src/llm.rs @@ -28,6 +28,8 @@ use koharu_runtime::RuntimeManager; use strum::IntoEnumIterator; use tokio::sync::{RwLock, broadcast}; +use super::utils; + // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- @@ -213,7 +215,7 @@ impl Model { let target_language = target_language .and_then(Language::parse) .unwrap_or(Language::English); - let body = format_sources(sources); + let body = utils::format_sources(sources); let mut guard = self.state.write().await; let translation = match &mut *guard { @@ -237,7 +239,7 @@ impl Model { }?; let translation = strip_thinking_block(&translation); - let out = match parse_tagged_blocks(translation, sources.len())? { + let out = match utils::parse_tagged_blocks(translation, sources.len())? { Some(blocks) => blocks, None => split_legacy_lines(translation, sources.len()), }; @@ -436,72 +438,6 @@ pub fn provider_config_from_settings( // Tag formatting + response parsing // --------------------------------------------------------------------------- -fn format_sources(sources: &[String]) -> String { - sources - .iter() - .enumerate() - .map(|(idx, text)| format!("[{}]{}", idx + 1, text)) - .collect::>() - .join("\n") -} - -fn parse_block_tag(text: &str) -> Option<(usize, usize)> { - let bytes = text.as_bytes(); - if bytes.first()? != &b'[' { - return None; - } - let end = text[1..].find(']')?; - let num_str = &text[1..1 + end]; - let id_1based: usize = num_str.parse().ok()?; - if id_1based == 0 { - return None; - } - Some((1 + end + 1, id_1based - 1)) -} - -fn find_next_tag(text: &str) -> Option<(usize, usize, usize)> { - let mut line_start = 0; - while line_start <= text.len() { - let line = &text[line_start..]; - let indent = line - .as_bytes() - .iter() - .take_while(|&&byte| matches!(byte, b' ' | b'\t')) - .count(); - let offset = line_start + indent; - if let Some((len, id)) = parse_block_tag(&text[offset..]) { - return Some((offset, len, id)); - } - let Some(next_newline) = line.find('\n') else { - break; - }; - line_start += next_newline + 1; - } - None -} - -fn parse_tagged_blocks(translation: &str, expected_blocks: usize) -> Result>> { - if find_next_tag(translation).is_none() { - return Ok(None); - } - let mut blocks = vec![String::new(); expected_blocks]; - let mut cursor = translation; - let mut found_any = false; - while let Some((offset, len, id)) = find_next_tag(cursor) { - found_any = true; - cursor = &cursor[offset + len..]; - let content_end = find_next_tag(cursor) - .map(|(next_offset, _, _)| next_offset) - .unwrap_or(cursor.len()); - let content = cursor[..content_end].trim().to_string(); - if id < expected_blocks { - blocks[id] = content; - } - cursor = &cursor[content_end..]; - } - Ok(found_any.then_some(blocks)) -} - fn split_legacy_lines(translation: &str, expected_blocks: usize) -> Vec { let mut lines: Vec = translation .lines() diff --git a/crates/koharu-app/src/utils.rs b/crates/koharu-app/src/utils.rs index 2f7e3da3f..8d59869a9 100644 --- a/crates/koharu-app/src/utils.rs +++ b/crates/koharu-app/src/utils.rs @@ -47,3 +47,69 @@ pub fn mime_from_ext(ext: &str) -> &'static str { pub fn blank_rgba(width: u32, height: u32, color: image::Rgba) -> DynamicImage { DynamicImage::ImageRgba8(RgbaImage::from_pixel(width, height, color)) } + +pub fn format_sources(sources: &[String]) -> String { + sources + .iter() + .enumerate() + .map(|(idx, text)| format!("[{}]{}", idx + 1, text)) + .collect::>() + .join("\n") +} + +fn parse_block_tag(text: &str) -> Option<(usize, usize)> { + let bytes = text.as_bytes(); + if bytes.first()? != &b'[' { + return None; + } + let end = text[1..].find(']')?; + let num_str = &text[1..1 + end]; + let id_1based: usize = num_str.parse().ok()?; + if id_1based == 0 { + return None; + } + Some((1 + end + 1, id_1based - 1)) +} + +fn find_next_tag(text: &str) -> Option<(usize, usize, usize)> { + let mut line_start = 0; + while line_start <= text.len() { + let line = &text[line_start..]; + let indent = line + .as_bytes() + .iter() + .take_while(|&&byte| matches!(byte, b' ' | b'\t')) + .count(); + let offset = line_start + indent; + if let Some((len, id)) = parse_block_tag(&text[offset..]) { + return Some((offset, len, id)); + } + let Some(next_newline) = line.find('\n') else { + break; + }; + line_start += next_newline + 1; + } + None +} + +pub fn parse_tagged_blocks(translation: &str, expected_blocks: usize) -> anyhow::Result>> { + if find_next_tag(translation).is_none() { + return Ok(None); + } + let mut blocks = vec![String::new(); expected_blocks]; + let mut cursor = translation; + let mut found_any = false; + while let Some((offset, len, id)) = find_next_tag(cursor) { + found_any = true; + cursor = &cursor[offset + len..]; + let content_end = find_next_tag(cursor) + .map(|(next_offset, _, _)| next_offset) + .unwrap_or(cursor.len()); + let content = cursor[..content_end].trim().to_string(); + if id < expected_blocks { + blocks[id] = content; + } + cursor = &cursor[content_end..]; + } + Ok(found_any.then_some(blocks)) +} diff --git a/crates/koharu-rpc/src/routes/projects.rs b/crates/koharu-rpc/src/routes/projects.rs index d3d918e13..8fc79e8bd 100644 --- a/crates/koharu-rpc/src/routes/projects.rs +++ b/crates/koharu-rpc/src/routes/projects.rs @@ -14,8 +14,11 @@ use axum::body::{Body, Bytes}; use axum::extract::{Path, State}; use axum::http::{HeaderValue, header}; use axum::response::{IntoResponse, Response}; -use koharu_app::projects as project_dirs; -use koharu_core::{ImageRole, PageId, ProjectSummary}; +use koharu_app::pipeline::support::text_nodes; +use koharu_app::{projects as project_dirs, utils}; +use koharu_core::{ + ImageRole, NodeDataPatch, NodeId, NodePatch, Op, PageId, ProjectSummary, Scene, TextDataPatch, +}; use serde::{Deserialize, Serialize}; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -31,6 +34,7 @@ pub fn router() -> OpenApiRouter { .routes(routes!(delete_current_project)) .routes(routes!(delete_project)) .routes(routes!(export_current_project)) + .routes(routes!(import_script)) } // --------------------------------------------------------------------------- @@ -249,6 +253,8 @@ pub enum ExportFormat { Rendered, /// One `.png` per page (the Inpainted layer). Inpainted, + /// One '.txt' per page. + Script, } #[utoipa::path( @@ -337,6 +343,7 @@ async fn export_current_project( ) .await } + ExportFormat::Script => export_script(&session, req.pages.as_deref(), &project_name).await, } } @@ -373,6 +380,208 @@ async fn export_image_role( files_to_response(files, project_name, role_ext(role)) } +async fn export_script( + session: &std::sync::Arc, + pages: Option<&[PageId]>, + project_name: &str, +) -> ApiResult { + let page_ids = resolve_page_ids(session, pages)?; + if page_ids.is_empty() { + return Err(ApiError::bad_request("no pages in selection")); + } + + let scene = session.scene.read(); + + let mut page_blocks: Vec<(PageId, Vec<(NodeId, String)>)> = Vec::new(); + + for &page_id in &page_ids { + let targets = collect_translation_targets_from(&scene, page_id); + if targets.is_empty() { + continue; + } + page_blocks.push((page_id, targets)); + } + + if page_blocks.is_empty() { + return Err(ApiError::bad_request("no pages with text blocks to export")); + } + + let single_page = page_blocks.len() == 1; + + let mut body = String::new(); + + for (page_id, blocks) in page_blocks.iter() { + if !body.is_empty() { + body.push('\n'); + } + + if !single_page { + let page_index = scene + .pages + .get_index_of(page_id) + .map(|i| i + 1) + .unwrap_or(page_blocks.len()); + body.push_str(&format!("Page {}", page_index)); + } + + if !body.is_empty() { + body.push('\n'); + } + + let formatted = utils::format_sources( + &blocks + .iter() + .map(|block| block.1.clone()) + .collect::>(), + ); + body.push_str(&formatted); + } + + let file = ("script.txt".to_string(), body.into_bytes()); + + files_to_response(vec![file], project_name, "txt") +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportScriptRequest { + pub body: String, + #[serde(default)] + pub page_id: Option, +} + +#[utoipa::path( + post, + path = "/projects/current/import-script", + request_body = ImportScriptRequest, + responses((status = 200, description = "Translations applied"), + (status = 400, description = "Parse error or invalid request")) +)] +async fn import_script( + State(app): State, + Json(req): Json, +) -> ApiResult<()> { + let session = app + .current_session() + .ok_or_else(|| ApiError::bad_request("no project open"))?; + + let scene = session.scene_snapshot(); + + let entries = parse_script_body(&req.body, req.page_id, &scene)?; + if entries.is_empty() { + return Err(ApiError::bad_request("no valid entries found in script")); + } + + // Build UpdateNode ops for each (page, node, translation) entry + let mut ops = Vec::new(); + for (page_id, node_id, translation) in entries { + ops.push(Op::UpdateNode { + page: page_id, + id: node_id, + patch: NodePatch { + data: Some(NodeDataPatch::Text(TextDataPatch { + translation: Some(Some(translation)), + ..Default::default() + })), + transform: None, + visible: None, + }, + prev: NodePatch::default(), + }); + } + + let batch = Op::Batch { + ops, + label: "Import script translations".into(), + }; + + session + .apply(batch) + .map_err(|e| ApiError::internal(e.into()))?; + + Ok(()) +} + +/// Parse the script body and produce (page_id, node_id, translation_text) tuples. +fn parse_script_body( + body: &str, + page_id: Option, + scene: &Scene, +) -> ApiResult> { + let mut entries = Vec::new(); + + if let Some(page_id) = page_id { + let targets = collect_translation_targets_from(scene, page_id); + if let Some(translation_texts) = utils::parse_tagged_blocks(&body, targets.len())? { + for ((node_id, _), translation) in targets.into_iter().zip(translation_texts) { + entries.push((page_id, node_id, translation)); + } + } else { + return Err(ApiError::bad_request( + "script file has no translation lines but page has text nodes", + )); + } + } else { + let sections = split_into_page_sections(body); + for (page_number, section_lines) in sections { + if page_number < 1 { + return Err(ApiError::bad_request("page numbers must be >= 1")); + } + let (page_id, _) = scene.pages.get_index(page_number - 1).ok_or_else(|| { + ApiError::bad_request(format!("page {} not found in project", page_number)) + })?; + + let targets = collect_translation_targets_from(scene, *page_id); + if let Some(translation_texts) = + utils::parse_tagged_blocks(§ion_lines, targets.len())? + { + for ((node_id, _), translation) in targets.into_iter().zip(translation_texts) { + entries.push((*page_id, node_id, translation)); + } + } + } + } + + Ok(entries) +} + +/// Split multi-page script body into `(page_number, page_body)` pairs. +/// Lines starting with "Page " and containing a page number are treated as section headers. +fn split_into_page_sections(body: &str) -> Vec<(usize, String)> { + let mut sections: Vec<(usize, String)> = Vec::new(); + let mut current_page: Option = None; + let mut current_lines: Vec<&str> = Vec::new(); + + for line in body.lines() { + let parsed_page = line + .strip_prefix("Page ") + .and_then(|rest| rest.trim().parse::().ok()); + + if let Some(page) = parsed_page { + if let Some(prev_page) = current_page.take() { + let section_body = current_lines.join("\n"); + if !section_body.trim().is_empty() { + sections.push((prev_page, section_body)); + } + } + + current_lines.clear(); + current_page = Some(page); + } else { + current_lines.push(line); + } + } + + if let Some(page) = current_page { + let section_body = current_lines.join("\n"); + if !section_body.trim().is_empty() { + sections.push((page, section_body)); + } + } + + sections +} + fn resolve_page_ids( session: &koharu_app::ProjectSession, requested: Option<&[PageId]>, @@ -411,6 +620,7 @@ fn files_to_response( "psd" => "image/vnd.adobe.photoshop", "png" => "image/png", "khr" => "application/octet-stream", + "txt" => "text/plain", _ => "application/octet-stream", }; return Ok(bytes_response_with_filename(bytes, &fname, content_type)); @@ -456,3 +666,15 @@ fn sanitize(name: &str, fallback: &str) -> String { s } } + +/// Collect all the non-empty text blocks from the specified page in the scene +fn collect_translation_targets_from(scene: &Scene, page: PageId) -> Vec<(NodeId, String)> { + text_nodes(scene, page) + .into_iter() + .filter_map(|(id, _, text_data)| { + let text = text_data.text.as_ref()?; + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| (id, text.clone())) + }) + .collect() +} diff --git a/ui/components/MenuBar.tsx b/ui/components/MenuBar.tsx index d66523e44..d59ae9d8f 100644 --- a/ui/components/MenuBar.tsx +++ b/ui/components/MenuBar.tsx @@ -23,7 +23,11 @@ import { import { useScene } from '@/hooks/useScene' import { getConfig, startPipeline } from '@/lib/api/default/default' import { isTauri, openExternalUrl } from '@/lib/backend' -import { exportCurrentProjectAs, importPages } from '@/lib/io/pagesIo' +import { + exportCurrentProjectAs, + importPages, + promptImportScript, +} from '@/lib/io/pagesIo' import { clearSelectionOnCurrentPage, closeProject, @@ -173,6 +177,33 @@ export function MenuBar() { }, ] + const scriptItems: MenuItem[] = [ + { + label: t('menu.importScript'), + onSelect: () => void promptImportScript(requirePageId()), + disabled: !hasPage, + testId: 'menu-file-import-script', + }, + { + label: t('menu.exportScript'), + onSelect: () => void exportCurrentProjectAs('script', [requirePageId()]), + disabled: !hasPage, + testId: 'menu-file-export-script', + }, + { + label: t('menu.importAllScript'), + onSelect: () => void promptImportScript(), + disabled: !hasScene, + testId: 'menu-file-import-all-script', + }, + { + label: t('menu.exportAllScript'), + onSelect: () => void exportCurrentProjectAs('script'), + disabled: !hasScene, + testId: 'menu-file-export-all-script', + }, + ] + const helpMenuItems: MenuItem[] = [ { label: t('menu.discord'), onSelect: () => openExternalUrl('https://discord.gg/mHvHkxGnUY') }, { @@ -237,6 +268,18 @@ export function MenuBar() { ))} + {scriptItems.map((item) => ( + void item.onSelect?.() : undefined} + > + {item.label} + + ))} + ( ): UseMutationResult>, TError, void, TContext> => { return useMutation(getImportProjectMutationOptions(options), queryClient) } + +export const getImportScriptUrl = () => { + return `/api/v1/projects/current/import-script` +} + export const getDeleteProjectUrl = (id: string) => { return `/api/v1/projects/${id}` } diff --git a/ui/lib/api/schemas/exportFormat.ts b/ui/lib/api/schemas/exportFormat.ts index 913c42a8a..b793cd509 100644 --- a/ui/lib/api/schemas/exportFormat.ts +++ b/ui/lib/api/schemas/exportFormat.ts @@ -11,4 +11,5 @@ export const ExportFormat = { psd: 'psd', rendered: 'rendered', inpainted: 'inpainted', + script: 'script', } as const diff --git a/ui/lib/io/openFiles.ts b/ui/lib/io/openFiles.ts index 965fa6552..d006b6d6b 100644 --- a/ui/lib/io/openFiles.ts +++ b/ui/lib/io/openFiles.ts @@ -84,6 +84,33 @@ export async function openImageFolder(): Promise { } } +/** Pick a `.txt` script file. Returns `null` if cancelled. */ +export async function openTextFile(): Promise { + if (isTauri()) { + const { open } = await import('@tauri-apps/plugin-dialog') + const picked = await open({ + multiple: false, + filters: [{ name: 'Text files', extensions: ['txt'] }], + }) + if (!picked || typeof picked !== 'string') return null + const [file] = await readTauriFiles([picked]) + return file ?? null + } + + const { fileOpen } = await import('browser-fs-access') + try { + const result = await fileOpen({ + multiple: false, + extensions: ['.txt'], + description: 'Text files', + }) + return Array.isArray(result) ? (result[0] ?? null) : result + } catch (e) { + if (isAbort(e)) return null + throw e + } +} + /** Pick one `.khr` archive file. Returns `null` if cancelled. */ export async function openKhrFile(): Promise { if (isTauri()) { @@ -133,6 +160,7 @@ function mimeFromName(name: string): string { if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg' if (lower.endsWith('.webp')) return 'image/webp' if (lower.endsWith('.khr')) return 'application/zip' + if (lower.endsWith('.txt')) return 'text/plain' return 'application/octet-stream' } diff --git a/ui/lib/io/pagesIo.ts b/ui/lib/io/pagesIo.ts index b2a1d23cc..0a3e63d93 100644 --- a/ui/lib/io/pagesIo.ts +++ b/ui/lib/io/pagesIo.ts @@ -2,9 +2,9 @@ import { getGetSceneJsonQueryKey } from '@/lib/api/default/default' import type { SceneSnapshot } from '@/lib/api/schemas' -import { openImageFiles, openImageFolder, openKhrFile } from '@/lib/io/openFiles' +import { openImageFiles, openImageFolder, openKhrFile, openTextFile } from '@/lib/io/openFiles' import { saveBlob } from '@/lib/io/saveBlob' -import { exportProject, uploadKhrArchive, uploadPages, uploadPagesByPaths } from '@/lib/io/scene' +import { exportProject, importScript, uploadKhrArchive, uploadPages, uploadPagesByPaths } from '@/lib/io/scene' import { queryClient } from '@/lib/queryClient' import { usePreferencesStore } from '@/lib/stores/preferencesStore' @@ -43,11 +43,15 @@ export async function importKhrFile(): Promise { // Export (server returns bytes; saveBlob dispatches Tauri-dialog / web-FS) // --------------------------------------------------------------------------- -const exportExtension: Record<'khr' | 'psd' | 'rendered' | 'inpainted', string> = { +const exportExtension: Record< + 'khr' | 'psd' | 'rendered' | 'inpainted' | 'script', + string +> = { khr: 'khr', psd: 'zip', rendered: 'zip', inpainted: 'zip', + script: 'txt', } /** Sanitise an arbitrary project name for use as a filename stem. */ @@ -66,7 +70,7 @@ function currentProjectName(): string | undefined { } export async function exportCurrentProjectAs( - format: 'khr' | 'psd' | 'rendered' | 'inpainted', + format: 'khr' | 'psd' | 'rendered' | 'inpainted' | 'script', pages?: string[], ): Promise { try { @@ -83,3 +87,10 @@ export async function exportCurrentProjectAs( throw err } } + +export async function promptImportScript(pageId?: string): Promise { + const file = await openTextFile() + if (!file) return + const body = await file.text() + await importScript(body, pageId) +} diff --git a/ui/lib/io/scene.ts b/ui/lib/io/scene.ts index 8a2519319..43c0a74e9 100644 --- a/ui/lib/io/scene.ts +++ b/ui/lib/io/scene.ts @@ -11,6 +11,7 @@ import { getGetConfigQueryKey, getGetCurrentLlmQueryKey, getGetSceneJsonQueryKey, + getImportScriptUrl, importProject, patchConfig, putCurrentProject, @@ -252,6 +253,24 @@ export async function exportProject( return { blob, filename } } +// Script import -------------------------------------------------------------- + +export async function importScript( + body: string, + pageId?: string, +): Promise { + const res = await fetch(getImportScriptUrl(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body, pageId }), + }) + if (!res.ok) { + const msg = await res.text().catch(() => res.statusText) + throw new ApiError(res.status, msg) + } + await invalidateScene() +} + // Config --------------------------------------------------------------------- export async function updateConfig(patch: ConfigPatch): Promise { diff --git a/ui/public/locales/en-US/translation.json b/ui/public/locales/en-US/translation.json index 5cdb145ac..8a4b28a8f 100644 --- a/ui/public/locales/en-US/translation.json +++ b/ui/public/locales/en-US/translation.json @@ -30,6 +30,10 @@ "exportPsd": "Export PSD...", "exportAllInpainted": "Export All Inpainted...", "exportAllRendered": "Export All Rendered...", + "exportScript": "Export Text Blocks...", + "exportAllScript": "Export All Text Blocks...", + "importScript": "Import Text Blocks...", + "importAllScript": "Import All Text Blocks...", "view": "View", "fitWindow": "Fit Window", "originalSize": "Original Size", From dc232defa21a32e8f2ef875ef47b7175a1cf5cea Mon Sep 17 00:00:00 2001 From: Fermin Cirella Date: Wed, 29 Jul 2026 20:12:59 -0300 Subject: [PATCH 2/7] Apply formatting --- crates/koharu-app/src/utils.rs | 5 ++++- ui/components/MenuBar.tsx | 6 +----- ui/lib/io/pagesIo.ts | 13 ++++++++----- ui/lib/io/scene.ts | 5 +---- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/crates/koharu-app/src/utils.rs b/crates/koharu-app/src/utils.rs index 8d59869a9..82de4b83c 100644 --- a/crates/koharu-app/src/utils.rs +++ b/crates/koharu-app/src/utils.rs @@ -92,7 +92,10 @@ fn find_next_tag(text: &str) -> Option<(usize, usize, usize)> { None } -pub fn parse_tagged_blocks(translation: &str, expected_blocks: usize) -> anyhow::Result>> { +pub fn parse_tagged_blocks( + translation: &str, + expected_blocks: usize, +) -> anyhow::Result>> { if find_next_tag(translation).is_none() { return Ok(None); } diff --git a/ui/components/MenuBar.tsx b/ui/components/MenuBar.tsx index d59ae9d8f..e1ff92e64 100644 --- a/ui/components/MenuBar.tsx +++ b/ui/components/MenuBar.tsx @@ -23,11 +23,7 @@ import { import { useScene } from '@/hooks/useScene' import { getConfig, startPipeline } from '@/lib/api/default/default' import { isTauri, openExternalUrl } from '@/lib/backend' -import { - exportCurrentProjectAs, - importPages, - promptImportScript, -} from '@/lib/io/pagesIo' +import { exportCurrentProjectAs, importPages, promptImportScript } from '@/lib/io/pagesIo' import { clearSelectionOnCurrentPage, closeProject, diff --git a/ui/lib/io/pagesIo.ts b/ui/lib/io/pagesIo.ts index 0a3e63d93..5b64edd43 100644 --- a/ui/lib/io/pagesIo.ts +++ b/ui/lib/io/pagesIo.ts @@ -4,7 +4,13 @@ import { getGetSceneJsonQueryKey } from '@/lib/api/default/default' import type { SceneSnapshot } from '@/lib/api/schemas' import { openImageFiles, openImageFolder, openKhrFile, openTextFile } from '@/lib/io/openFiles' import { saveBlob } from '@/lib/io/saveBlob' -import { exportProject, importScript, uploadKhrArchive, uploadPages, uploadPagesByPaths } from '@/lib/io/scene' +import { + exportProject, + importScript, + uploadKhrArchive, + uploadPages, + uploadPagesByPaths, +} from '@/lib/io/scene' import { queryClient } from '@/lib/queryClient' import { usePreferencesStore } from '@/lib/stores/preferencesStore' @@ -43,10 +49,7 @@ export async function importKhrFile(): Promise { // Export (server returns bytes; saveBlob dispatches Tauri-dialog / web-FS) // --------------------------------------------------------------------------- -const exportExtension: Record< - 'khr' | 'psd' | 'rendered' | 'inpainted' | 'script', - string -> = { +const exportExtension: Record<'khr' | 'psd' | 'rendered' | 'inpainted' | 'script', string> = { khr: 'khr', psd: 'zip', rendered: 'zip', diff --git a/ui/lib/io/scene.ts b/ui/lib/io/scene.ts index 43c0a74e9..6c0c6cdd8 100644 --- a/ui/lib/io/scene.ts +++ b/ui/lib/io/scene.ts @@ -255,10 +255,7 @@ export async function exportProject( // Script import -------------------------------------------------------------- -export async function importScript( - body: string, - pageId?: string, -): Promise { +export async function importScript(body: string, pageId?: string): Promise { const res = await fetch(getImportScriptUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, From fb8bbfdfc964fa174f292d50e5505869b46ea59c Mon Sep 17 00:00:00 2001 From: Fermin Cirella Date: Wed, 29 Jul 2026 20:18:11 -0300 Subject: [PATCH 3/7] Include translations --- ui/public/locales/en-US/translation.json | 8 ++++---- ui/public/locales/es-ES/translation.json | 4 ++++ ui/public/locales/ja-JP/translation.json | 4 ++++ ui/public/locales/ko-KR/translation.json | 4 ++++ ui/public/locales/pt-BR/translation.json | 4 ++++ ui/public/locales/ru-RU/translation.json | 4 ++++ ui/public/locales/tr-TR/translation.json | 4 ++++ ui/public/locales/zh-CN/translation.json | 4 ++++ ui/public/locales/zh-TW/translation.json | 4 ++++ 9 files changed, 36 insertions(+), 4 deletions(-) diff --git a/ui/public/locales/en-US/translation.json b/ui/public/locales/en-US/translation.json index 8a4b28a8f..d1ae79998 100644 --- a/ui/public/locales/en-US/translation.json +++ b/ui/public/locales/en-US/translation.json @@ -30,10 +30,10 @@ "exportPsd": "Export PSD...", "exportAllInpainted": "Export All Inpainted...", "exportAllRendered": "Export All Rendered...", - "exportScript": "Export Text Blocks...", - "exportAllScript": "Export All Text Blocks...", - "importScript": "Import Text Blocks...", - "importAllScript": "Import All Text Blocks...", + "exportScript": "Export Source Text...", + "exportAllScript": "Export All Source Text...", + "importScript": "Import Translation...", + "importAllScript": "Import All Translations...", "view": "View", "fitWindow": "Fit Window", "originalSize": "Original Size", diff --git a/ui/public/locales/es-ES/translation.json b/ui/public/locales/es-ES/translation.json index 17084fbac..6ad7b057a 100644 --- a/ui/public/locales/es-ES/translation.json +++ b/ui/public/locales/es-ES/translation.json @@ -24,6 +24,10 @@ "exportPsd": "Exportar PSD...", "exportAllInpainted": "Exportar todo lo rellenado...", "exportAllRendered": "Exportar todo lo renderizado...", + "exportScript": "Exportar texto original...", + "exportAllScript": "Exportar todo el texto original...", + "importScript": "Importar traducción...", + "importAllScript": "Importar todas las traducciones...", "view": "Ver", "fitWindow": "Ajustar a la ventana", "originalSize": "Tamaño original", diff --git a/ui/public/locales/ja-JP/translation.json b/ui/public/locales/ja-JP/translation.json index 2fe195ab4..6d8b22920 100644 --- a/ui/public/locales/ja-JP/translation.json +++ b/ui/public/locales/ja-JP/translation.json @@ -24,6 +24,10 @@ "exportPsd": "PSD を書き出し...", "exportAllInpainted": "インペイント画像をすべてエクスポート...", "exportAllRendered": "レンダリング画像をすべてエクスポート...", + "exportScript": "原文テキストを書き出し...", + "exportAllScript": "全ページの原文テキストを書き出し...", + "importScript": "翻訳をインポート...", + "importAllScript": "全ページの翻訳をインポート...", "view": "表示", "fitWindow": "ウィンドウに合わせる", "originalSize": "原寸", diff --git a/ui/public/locales/ko-KR/translation.json b/ui/public/locales/ko-KR/translation.json index f9ec55422..d88d45afa 100644 --- a/ui/public/locales/ko-KR/translation.json +++ b/ui/public/locales/ko-KR/translation.json @@ -29,6 +29,10 @@ "exportPsd": "PSD 내보내기...", "exportAllInpainted": "모든 인페인트 이미지 내보내기...", "exportAllRendered": "모든 렌더링된 이미지 내보내기...", + "exportScript": "원본 텍스트 내보내기...", + "exportAllScript": "모든 원본 텍스트 내보내기...", + "importScript": "번역 가져오기...", + "importAllScript": "모든 번역 가져오기...", "view": "보기", "fitWindow": "창에 맞추기", "originalSize": "원본 크기", diff --git a/ui/public/locales/pt-BR/translation.json b/ui/public/locales/pt-BR/translation.json index ad891bfc6..53fe5f2be 100644 --- a/ui/public/locales/pt-BR/translation.json +++ b/ui/public/locales/pt-BR/translation.json @@ -25,6 +25,10 @@ "exportPsd": "Exportar PSD...", "exportAllInpainted": "Exportar todos os inpaintings...", "exportAllRendered": "Exportar todos os renderizados...", + "exportScript": "Exportar texto fonte...", + "exportAllScript": "Exportar todos os textos fonte...", + "importScript": "Importar tradução...", + "importAllScript": "Importar todas as traduções...", "view": "Visualizar", "fitWindow": "Ajustar à janela", "originalSize": "Tamanho original", diff --git a/ui/public/locales/ru-RU/translation.json b/ui/public/locales/ru-RU/translation.json index 62b06211f..53a8f75d1 100644 --- a/ui/public/locales/ru-RU/translation.json +++ b/ui/public/locales/ru-RU/translation.json @@ -24,6 +24,10 @@ "exportPsd": "Экспортировать PSD...", "exportAllInpainted": "Экспортировать все изображения после инпейнтинга...", "exportAllRendered": "Экспортировать все отрендеренные изображения...", + "exportScript": "Экспорт исходного текста...", + "exportAllScript": "Экспорт всего исходного текста...", + "importScript": "Импорт перевода...", + "importAllScript": "Импорт всех переводов...", "view": "Вид", "fitWindow": "По размеру окна", "originalSize": "Исходный размер", diff --git a/ui/public/locales/tr-TR/translation.json b/ui/public/locales/tr-TR/translation.json index b062d6fe9..5a0338b9f 100644 --- a/ui/public/locales/tr-TR/translation.json +++ b/ui/public/locales/tr-TR/translation.json @@ -24,6 +24,10 @@ "exportPsd": "PSD Olarak Dışa Aktar...", "exportAllInpainted": "Tüm İnpaint Görsellerini Dışa Aktar...", "exportAllRendered": "Tüm Render Görsellerini Dışa Aktar...", + "exportScript": "Kaynak Metni Dışa Aktar...", + "exportAllScript": "Tüm Kaynak Metinleri Dışa Aktar...", + "importScript": "Çeviriyi İçe Aktar...", + "importAllScript": "Tüm Çevirileri İçe Aktar...", "view": "Görünüm", "fitWindow": "Pencereye Sığdır", "originalSize": "Özgün Boyut", diff --git a/ui/public/locales/zh-CN/translation.json b/ui/public/locales/zh-CN/translation.json index 60bfeb1af..3660a52f2 100644 --- a/ui/public/locales/zh-CN/translation.json +++ b/ui/public/locales/zh-CN/translation.json @@ -24,6 +24,10 @@ "exportPsd": "导出 PSD...", "exportAllInpainted": "导出所有修复后的图像...", "exportAllRendered": "导出所有渲染后的图像...", + "exportScript": "导出原文文本...", + "exportAllScript": "导出所有原文文本...", + "importScript": "导入翻译...", + "importAllScript": "导入所有翻译...", "view": "视图", "fitWindow": "适应窗口", "originalSize": "原始大小", diff --git a/ui/public/locales/zh-TW/translation.json b/ui/public/locales/zh-TW/translation.json index 973fac145..1eab53412 100644 --- a/ui/public/locales/zh-TW/translation.json +++ b/ui/public/locales/zh-TW/translation.json @@ -24,6 +24,10 @@ "exportPsd": "匯出 PSD...", "exportAllInpainted": "匯出所有修補後的影像...", "exportAllRendered": "匯出所有渲染後的影像...", + "exportScript": "匯出原文文字...", + "exportAllScript": "匯出所有原文文字...", + "importScript": "匯入翻譯...", + "importAllScript": "匯入所有翻譯...", "view": "檢視", "fitWindow": "符合視窗", "originalSize": "原始大小", From 6b3fca5e36ecb2da35688c826416d36bbcd1a46d Mon Sep 17 00:00:00 2001 From: Fermin Cirella Date: Wed, 29 Jul 2026 20:54:53 -0300 Subject: [PATCH 4/7] Apply clippy corrections --- crates/koharu-rpc/src/routes/projects.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/koharu-rpc/src/routes/projects.rs b/crates/koharu-rpc/src/routes/projects.rs index 8fc79e8bd..6d4517aae 100644 --- a/crates/koharu-rpc/src/routes/projects.rs +++ b/crates/koharu-rpc/src/routes/projects.rs @@ -495,9 +495,7 @@ async fn import_script( label: "Import script translations".into(), }; - session - .apply(batch) - .map_err(|e| ApiError::internal(e.into()))?; + session.apply(batch).map_err(|e| ApiError::internal(e))?; Ok(()) } @@ -512,7 +510,7 @@ fn parse_script_body( if let Some(page_id) = page_id { let targets = collect_translation_targets_from(scene, page_id); - if let Some(translation_texts) = utils::parse_tagged_blocks(&body, targets.len())? { + if let Some(translation_texts) = utils::parse_tagged_blocks(body, targets.len())? { for ((node_id, _), translation) in targets.into_iter().zip(translation_texts) { entries.push((page_id, node_id, translation)); } From 52d993bda9fa2bca00c57e50f7b96a80456ceba3 Mon Sep 17 00:00:00 2001 From: Fermin Cirella Date: Wed, 29 Jul 2026 20:57:01 -0300 Subject: [PATCH 5/7] Regenerate OpenAPI spec --- ui/openapi.json | 1067 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 857 insertions(+), 210 deletions(-) diff --git a/ui/openapi.json b/ui/openapi.json index 7433e0084..f86e0c58c 100644 --- a/ui/openapi.json +++ b/ui/openapi.json @@ -650,7 +650,10 @@ "description": "Optional pipeline engine to run after the mask is updated.", "required": false, "schema": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } }, { @@ -659,7 +662,10 @@ "description": "Bounding box for the pipeline run.", "required": false, "schema": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "float" } }, @@ -668,7 +674,10 @@ "in": "query", "required": false, "schema": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "float" } }, @@ -677,7 +686,10 @@ "in": "query", "required": false, "schema": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "float" } }, @@ -686,7 +698,10 @@ "in": "query", "required": false, "schema": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "float" } } @@ -892,6 +907,29 @@ } } }, + "/projects/current/import-script": { + "post": { + "operationId": "import_script", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportScriptRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Translations applied" + }, + "400": { + "description": "Parse error or invalid request" + } + } + } + }, "/projects/import": { "post": { "operationId": "import_project", @@ -979,7 +1017,9 @@ "schemas": { "AddImageLayerResponse": { "type": "object", - "required": ["node"], + "required": [ + "node" + ], "properties": { "node": { "$ref": "#/components/schemas/NodeId" @@ -996,7 +1036,7 @@ } ], "default": { - "path": "C:\\Users\\Mayo\\AppData\\Local\\Koharu" + "path": "/Users/fermin/Library/Application Support/Koharu" } }, "http": { @@ -1022,7 +1062,7 @@ "detector": "pp-doclayout-v3", "font_detector": "yuzumarker-font-detection", "inpainter": "lama-manga", - "ocr": "paddle-ocr-vl-1.5", + "ocr": "paddle-ocr-vl-1.6", "renderer": "koharu-renderer", "segmenter": "comic-text-detector-seg", "translator": "llm" @@ -1041,11 +1081,17 @@ "oneOf": [ { "type": "object", - "required": ["id", "kind", "event"], + "required": [ + "id", + "kind", + "event" + ], "properties": { "event": { "type": "string", - "enum": ["jobStarted"] + "enum": [ + "jobStarted" + ] }, "id": { "type": "string" @@ -1062,11 +1108,15 @@ }, { "type": "object", - "required": ["event"], + "required": [ + "event" + ], "properties": { "event": { "type": "string", - "enum": ["jobProgress"] + "enum": [ + "jobProgress" + ] } } } @@ -1080,11 +1130,15 @@ }, { "type": "object", - "required": ["event"], + "required": [ + "event" + ], "properties": { "event": { "type": "string", - "enum": ["jobWarning"] + "enum": [ + "jobWarning" + ] } } } @@ -1098,11 +1152,15 @@ }, { "type": "object", - "required": ["event"], + "required": [ + "event" + ], "properties": { "event": { "type": "string", - "enum": ["jobFinished"] + "enum": [ + "jobFinished" + ] } } } @@ -1115,11 +1173,15 @@ }, { "type": "object", - "required": ["event"], + "required": [ + "event" + ], "properties": { "event": { "type": "string", - "enum": ["downloadProgress"] + "enum": [ + "downloadProgress" + ] } } } @@ -1127,11 +1189,16 @@ }, { "type": "object", - "required": ["target", "event"], + "required": [ + "target", + "event" + ], "properties": { "event": { "type": "string", - "enum": ["llmLoading"] + "enum": [ + "llmLoading" + ] }, "target": { "$ref": "#/components/schemas/LlmTarget" @@ -1140,11 +1207,16 @@ }, { "type": "object", - "required": ["target", "event"], + "required": [ + "target", + "event" + ], "properties": { "event": { "type": "string", - "enum": ["llmLoaded"] + "enum": [ + "llmLoaded" + ] }, "target": { "$ref": "#/components/schemas/LlmTarget" @@ -1153,11 +1225,15 @@ }, { "type": "object", - "required": ["event"], + "required": [ + "event" + ], "properties": { "event": { "type": "string", - "enum": ["llmFailed"] + "enum": [ + "llmFailed" + ] }, "target": { "oneOf": [ @@ -1173,11 +1249,15 @@ }, { "type": "object", - "required": ["event"], + "required": [ + "event" + ], "properties": { "event": { "type": "string", - "enum": ["llmUnloaded"] + "enum": [ + "llmUnloaded" + ] } } }, @@ -1188,11 +1268,15 @@ }, { "type": "object", - "required": ["event"], + "required": [ + "event" + ], "properties": { "event": { "type": "string", - "enum": ["snapshot"] + "enum": [ + "snapshot" + ] } } } @@ -1206,14 +1290,23 @@ }, "CodexAuthAttemptStatus": { "type": "string", - "enum": ["pending", "succeeded", "failed"] + "enum": [ + "pending", + "succeeded", + "failed" + ] }, "CodexAuthStatus": { "type": "object", - "required": ["signedIn"], + "required": [ + "signedIn" + ], "properties": { "accountId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "login": { "oneOf": [ @@ -1232,7 +1325,13 @@ }, "CodexDeviceLogin": { "type": "object", - "required": ["loginId", "verificationUrl", "userCode", "intervalSeconds", "timeoutSeconds"], + "required": [ + "loginId", + "verificationUrl", + "userCode", + "intervalSeconds", + "timeoutSeconds" + ], "properties": { "intervalSeconds": { "type": "integer", @@ -1257,13 +1356,22 @@ }, "CodexDeviceLoginStatus": { "type": "object", - "required": ["loginId", "status"], + "required": [ + "loginId", + "status" + ], "properties": { "accountId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "error": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "loginId": { "type": "string" @@ -1275,13 +1383,22 @@ }, "CodexImageGenerationOptions": { "type": "object", - "required": ["pageId", "prompt"], + "required": [ + "pageId", + "prompt" + ], "properties": { "instructions": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "model": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "pageId": { "$ref": "#/components/schemas/PageId" @@ -1290,16 +1407,24 @@ "type": "string" }, "quality": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "size": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } } }, "CodexImageGenerationResponse": { "type": "object", - "required": ["operationId"], + "required": [ + "operationId" + ], "properties": { "operationId": { "type": "string" @@ -1341,7 +1466,10 @@ ] }, "providers": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "items": { "$ref": "#/components/schemas/ProviderPatch" }, @@ -1351,7 +1479,9 @@ }, "CreatePagesFromPathsRequest": { "type": "object", - "required": ["paths"], + "required": [ + "paths" + ], "properties": { "paths": { "type": "array", @@ -1366,7 +1496,9 @@ }, "CreatePagesResponse": { "type": "object", - "required": ["pages"], + "required": [ + "pages" + ], "properties": { "pages": { "type": "array", @@ -1378,7 +1510,9 @@ }, "CreateProjectRequest": { "type": "object", - "required": ["name"], + "required": [ + "name" + ], "properties": { "name": { "type": "string" @@ -1387,7 +1521,9 @@ }, "DataConfig": { "type": "object", - "required": ["path"], + "required": [ + "path" + ], "properties": { "path": { "type": "string" @@ -1398,13 +1534,21 @@ "type": "object", "properties": { "path": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } } }, "DownloadProgress": { "type": "object", - "required": ["id", "filename", "downloaded", "status"], + "required": [ + "id", + "filename", + "downloaded", + "status" + ], "properties": { "downloaded": { "type": "integer", @@ -1421,7 +1565,10 @@ "$ref": "#/components/schemas/DownloadStatus" }, "total": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "format": "int64", "minimum": 0 } @@ -1431,44 +1578,61 @@ "oneOf": [ { "type": "object", - "required": ["status"], + "required": [ + "status" + ], "properties": { "status": { "type": "string", - "enum": ["started"] + "enum": [ + "started" + ] } } }, { "type": "object", - "required": ["status"], + "required": [ + "status" + ], "properties": { "status": { "type": "string", - "enum": ["downloading"] + "enum": [ + "downloading" + ] } } }, { "type": "object", - "required": ["status"], + "required": [ + "status" + ], "properties": { "status": { "type": "string", - "enum": ["completed"] + "enum": [ + "completed" + ] } } }, { "type": "object", - "required": ["reason", "status"], + "required": [ + "reason", + "status" + ], "properties": { "reason": { "type": "string" }, "status": { "type": "string", - "enum": ["failed"] + "enum": [ + "failed" + ] } } } @@ -1539,7 +1703,11 @@ }, "EngineCatalogEntry": { "type": "object", - "required": ["id", "name", "produces"], + "required": [ + "id", + "name", + "produces" + ], "properties": { "id": { "type": "string" @@ -1557,21 +1725,35 @@ }, "ExportFormat": { "type": "string", - "enum": ["khr", "psd", "rendered", "inpainted"] + "enum": [ + "khr", + "psd", + "rendered", + "inpainted", + "script" + ] }, "ExportProjectRequest": { "type": "object", - "required": ["format"], + "required": [ + "format" + ], "properties": { "defaultFont": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Optional global font override (from UI preferences)." }, "format": { "$ref": "#/components/schemas/ExportFormat" }, "pages": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "items": { "$ref": "#/components/schemas/PageId" }, @@ -1581,13 +1763,21 @@ }, "FontFaceInfo": { "type": "object", - "required": ["familyName", "postScriptName", "source", "cached"], + "required": [ + "familyName", + "postScriptName", + "source", + "cached" + ], "properties": { "cached": { "type": "boolean" }, "category": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "familyName": { "type": "string" @@ -1665,11 +1855,16 @@ }, "FontSource": { "type": "string", - "enum": ["system", "google"] + "enum": [ + "system", + "google" + ] }, "GoogleFontCatalog": { "type": "object", - "required": ["fonts"], + "required": [ + "fonts" + ], "properties": { "fonts": { "type": "array", @@ -1681,7 +1876,12 @@ }, "GoogleFontEntry": { "type": "object", - "required": ["family", "category", "subsets", "variants"], + "required": [ + "family", + "category", + "subsets", + "variants" + ], "properties": { "category": { "type": "string" @@ -1705,7 +1905,11 @@ }, "GoogleFontVariant": { "type": "object", - "required": ["style", "weight", "filename"], + "required": [ + "style", + "weight", + "filename" + ], "properties": { "filename": { "type": "string" @@ -1724,7 +1928,10 @@ "type": "object", "properties": { "epoch": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "format": "int64", "description": "New epoch. `None` only for a no-op undo/redo at the stack boundary.", "minimum": 0 @@ -1758,17 +1965,26 @@ "type": "object", "properties": { "connectTimeout": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "format": "int64", "minimum": 0 }, "maxRetries": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "format": "int32", "minimum": 0 }, "readTimeout": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "format": "int64", "minimum": 0 } @@ -1776,13 +1992,21 @@ }, "ImageData": { "type": "object", - "required": ["role", "blob", "naturalWidth", "naturalHeight"], + "required": [ + "role", + "blob", + "naturalWidth", + "naturalHeight" + ], "properties": { "blob": { "$ref": "#/components/schemas/BlobRef" }, "name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "naturalHeight": { "type": "integer", @@ -1818,34 +2042,78 @@ ] }, "name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "naturalHeight": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "format": "int32", "minimum": 0 }, "naturalWidth": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "format": "int32", "minimum": 0 }, "opacity": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "float" } } }, "ImageRole": { "type": "string", - "enum": ["source", "inpainted", "rendered", "custom"] + "enum": [ + "source", + "inpainted", + "rendered", + "custom" + ] + }, + "ImportScriptRequest": { + "type": "object", + "required": [ + "body" + ], + "properties": { + "body": { + "type": "string" + }, + "pageId": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PageId" + } + ] + } + } }, "JobFinishedEvent": { "type": "object", - "required": ["id", "status"], + "required": [ + "id", + "status" + ], "properties": { "error": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "id": { "type": "string" @@ -1857,14 +2125,27 @@ }, "JobStatus": { "type": "string", - "enum": ["running", "completed", "completed_with_errors", "cancelled", "failed"] + "enum": [ + "running", + "completed", + "completed_with_errors", + "cancelled", + "failed" + ] }, "JobSummary": { "type": "object", - "required": ["id", "kind", "status"], + "required": [ + "id", + "kind", + "status" + ], "properties": { "error": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "id": { "type": "string" @@ -1880,7 +2161,13 @@ "JobWarningEvent": { "type": "object", "description": "A non-fatal step failure during a pipeline run. The pipeline recovers by\nskipping the rest of the current page's steps and moving on to the next\npage; the UI accumulates these into a list during the job.", - "required": ["jobId", "pageIndex", "totalPages", "stepId", "message"], + "required": [ + "jobId", + "pageIndex", + "totalPages", + "stepId", + "message" + ], "properties": { "jobId": { "type": "string" @@ -1905,7 +2192,9 @@ }, "ListDownloadsResponse": { "type": "object", - "required": ["downloads"], + "required": [ + "downloads" + ], "properties": { "downloads": { "type": "array", @@ -1917,7 +2206,9 @@ }, "ListOperationsResponse": { "type": "object", - "required": ["operations"], + "required": [ + "operations" + ], "properties": { "operations": { "type": "array", @@ -1929,7 +2220,9 @@ }, "ListProjectsResponse": { "type": "object", - "required": ["projects"], + "required": [ + "projects" + ], "properties": { "projects": { "type": "array", @@ -1941,7 +2234,10 @@ }, "LlmCatalog": { "type": "object", - "required": ["localModels", "providers"], + "required": [ + "localModels", + "providers" + ], "properties": { "localModels": { "type": "array", @@ -1959,7 +2255,11 @@ }, "LlmCatalogModel": { "type": "object", - "required": ["target", "name", "languages"], + "required": [ + "target", + "name", + "languages" + ], "properties": { "languages": { "type": "array", @@ -1979,22 +2279,33 @@ "type": "object", "properties": { "customSystemPrompt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "maxTokens": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "format": "int32", "minimum": 0 }, "temperature": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "double" } } }, "LlmLoadRequest": { "type": "object", - "required": ["target"], + "required": [ + "target" + ], "properties": { "options": { "oneOf": [ @@ -2024,10 +2335,16 @@ ], "properties": { "baseUrl": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "error": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "hasApiKey": { "type": "boolean" @@ -2057,14 +2374,23 @@ }, "LlmProviderCatalogStatus": { "type": "string", - "enum": ["ready", "missing_configuration", "discovery_failed"] + "enum": [ + "ready", + "missing_configuration", + "discovery_failed" + ] }, "LlmState": { "type": "object", - "required": ["status"], + "required": [ + "status" + ], "properties": { "error": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "status": { "$ref": "#/components/schemas/LlmStateStatus" @@ -2083,11 +2409,19 @@ }, "LlmStateStatus": { "type": "string", - "enum": ["empty", "loading", "ready", "failed"] + "enum": [ + "empty", + "loading", + "ready", + "failed" + ] }, "LlmTarget": { "type": "object", - "required": ["kind", "modelId"], + "required": [ + "kind", + "modelId" + ], "properties": { "kind": { "$ref": "#/components/schemas/LlmTargetKind" @@ -2096,17 +2430,26 @@ "type": "string" }, "providerId": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } } }, "LlmTargetKind": { "type": "string", - "enum": ["local", "provider"] + "enum": [ + "local", + "provider" + ] }, "MaskData": { "type": "object", - "required": ["role", "blob"], + "required": [ + "role", + "blob" + ], "properties": { "blob": { "$ref": "#/components/schemas/BlobRef" @@ -2133,11 +2476,18 @@ }, "MaskRole": { "type": "string", - "enum": ["brushInpaint", "segment", "bubble"] + "enum": [ + "brushInpaint", + "segment", + "bubble" + ] }, "MetaInfo": { "type": "object", - "required": ["version", "mlDevice"], + "required": [ + "version", + "mlDevice" + ], "properties": { "mlDevice": { "type": "string" @@ -2149,14 +2499,22 @@ }, "NamedFontPrediction": { "type": "object", - "required": ["index", "name", "probability", "serif"], + "required": [ + "index", + "name", + "probability", + "serif" + ], "properties": { "index": { "type": "integer", "minimum": 0 }, "language": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "name": { "type": "string" @@ -2172,7 +2530,11 @@ }, "Node": { "type": "object", - "required": ["id", "visible", "kind"], + "required": [ + "id", + "visible", + "kind" + ], "properties": { "id": { "$ref": "#/components/schemas/NodeId" @@ -2192,7 +2554,9 @@ "oneOf": [ { "type": "object", - "required": ["text"], + "required": [ + "text" + ], "properties": { "text": { "$ref": "#/components/schemas/TextDataPatch" @@ -2201,7 +2565,9 @@ }, { "type": "object", - "required": ["image"], + "required": [ + "image" + ], "properties": { "image": { "$ref": "#/components/schemas/ImageDataPatch" @@ -2210,7 +2576,9 @@ }, { "type": "object", - "required": ["mask"], + "required": [ + "mask" + ], "properties": { "mask": { "$ref": "#/components/schemas/MaskDataPatch" @@ -2227,7 +2595,9 @@ "oneOf": [ { "type": "object", - "required": ["image"], + "required": [ + "image" + ], "properties": { "image": { "$ref": "#/components/schemas/ImageData" @@ -2236,7 +2606,9 @@ }, { "type": "object", - "required": ["text"], + "required": [ + "text" + ], "properties": { "text": { "$ref": "#/components/schemas/TextData" @@ -2245,7 +2617,9 @@ }, { "type": "object", - "required": ["mask"], + "required": [ + "mask" + ], "properties": { "mask": { "$ref": "#/components/schemas/MaskData" @@ -2278,7 +2652,10 @@ ] }, "visible": { - "type": ["boolean", "null"] + "type": [ + "boolean", + "null" + ] } } }, @@ -2286,11 +2663,15 @@ "oneOf": [ { "type": "object", - "required": ["updateProjectMeta"], + "required": [ + "updateProjectMeta" + ], "properties": { "updateProjectMeta": { "type": "object", - "required": ["patch"], + "required": [ + "patch" + ], "properties": { "patch": { "$ref": "#/components/schemas/ProjectMetaPatch" @@ -2304,11 +2685,16 @@ }, { "type": "object", - "required": ["addPage"], + "required": [ + "addPage" + ], "properties": { "addPage": { "type": "object", - "required": ["page", "at"], + "required": [ + "page", + "at" + ], "properties": { "at": { "type": "integer", @@ -2323,11 +2709,17 @@ }, { "type": "object", - "required": ["removePage"], + "required": [ + "removePage" + ], "properties": { "removePage": { "type": "object", - "required": ["id", "prev_page", "prev_index"], + "required": [ + "id", + "prev_page", + "prev_index" + ], "properties": { "id": { "$ref": "#/components/schemas/PageId" @@ -2345,11 +2737,16 @@ }, { "type": "object", - "required": ["updatePage"], + "required": [ + "updatePage" + ], "properties": { "updatePage": { "type": "object", - "required": ["id", "patch"], + "required": [ + "id", + "patch" + ], "properties": { "id": { "$ref": "#/components/schemas/PageId" @@ -2366,11 +2763,16 @@ }, { "type": "object", - "required": ["reorderPages"], + "required": [ + "reorderPages" + ], "properties": { "reorderPages": { "type": "object", - "required": ["order", "prev_order"], + "required": [ + "order", + "prev_order" + ], "properties": { "order": { "type": "array", @@ -2390,11 +2792,17 @@ }, { "type": "object", - "required": ["addNode"], + "required": [ + "addNode" + ], "properties": { "addNode": { "type": "object", - "required": ["page", "node", "at"], + "required": [ + "page", + "node", + "at" + ], "properties": { "at": { "type": "integer", @@ -2412,11 +2820,18 @@ }, { "type": "object", - "required": ["removeNode"], + "required": [ + "removeNode" + ], "properties": { "removeNode": { "type": "object", - "required": ["page", "id", "prev_node", "prev_index"], + "required": [ + "page", + "id", + "prev_node", + "prev_index" + ], "properties": { "id": { "$ref": "#/components/schemas/NodeId" @@ -2437,11 +2852,17 @@ }, { "type": "object", - "required": ["updateNode"], + "required": [ + "updateNode" + ], "properties": { "updateNode": { "type": "object", - "required": ["page", "id", "patch"], + "required": [ + "page", + "id", + "patch" + ], "properties": { "id": { "$ref": "#/components/schemas/NodeId" @@ -2461,11 +2882,17 @@ }, { "type": "object", - "required": ["reorderNodes"], + "required": [ + "reorderNodes" + ], "properties": { "reorderNodes": { "type": "object", - "required": ["page", "order", "prev_order"], + "required": [ + "page", + "order", + "prev_order" + ], "properties": { "order": { "type": "array", @@ -2488,11 +2915,16 @@ }, { "type": "object", - "required": ["batch"], + "required": [ + "batch" + ], "properties": { "batch": { "type": "object", - "required": ["ops", "label"], + "required": [ + "ops", + "label" + ], "properties": { "label": { "type": "string" @@ -2511,7 +2943,9 @@ }, "OpenProjectRequest": { "type": "object", - "required": ["id"], + "required": [ + "id" + ], "properties": { "id": { "type": "string", @@ -2521,7 +2955,13 @@ }, "Page": { "type": "object", - "required": ["id", "name", "width", "height", "nodes"], + "required": [ + "id", + "name", + "width", + "height", + "nodes" + ], "properties": { "height": { "type": "integer", @@ -2560,15 +3000,24 @@ "type": "object", "properties": { "height": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "format": "int32", "minimum": 0 }, "name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "width": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "format": "int32", "minimum": 0 } @@ -2596,7 +3045,7 @@ }, "ocr": { "type": "string", - "default": "paddle-ocr-vl-1.5" + "default": "paddle-ocr-vl-1.6" }, "renderer": { "type": "string", @@ -2616,28 +3065,52 @@ "type": "object", "properties": { "bubbleSegmenter": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "detector": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "fontDetector": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "inpainter": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "ocr": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "renderer": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "segmenter": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "translator": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } } }, @@ -2696,44 +3169,61 @@ "oneOf": [ { "type": "object", - "required": ["status"], + "required": [ + "status" + ], "properties": { "status": { "type": "string", - "enum": ["running"] + "enum": [ + "running" + ] } } }, { "type": "object", - "required": ["status"], + "required": [ + "status" + ], "properties": { "status": { "type": "string", - "enum": ["completed"] + "enum": [ + "completed" + ] } } }, { "type": "object", - "required": ["status"], + "required": [ + "status" + ], "properties": { "status": { "type": "string", - "enum": ["cancelled"] + "enum": [ + "cancelled" + ] } } }, { "type": "object", - "required": ["reason", "status"], + "required": [ + "reason", + "status" + ], "properties": { "reason": { "type": "string" }, "status": { "type": "string", - "enum": ["failed"] + "enum": [ + "failed" + ] } } } @@ -2741,11 +3231,21 @@ }, "PipelineStep": { "type": "string", - "enum": ["detect", "ocr", "inpaint", "llmGenerate", "render"] + "enum": [ + "detect", + "ocr", + "inpaint", + "llmGenerate", + "render" + ] }, "ProjectMeta": { "type": "object", - "required": ["name", "createdAt", "updatedAt"], + "required": [ + "name", + "createdAt", + "updatedAt" + ], "properties": { "createdAt": { "type": "string", @@ -2767,7 +3267,10 @@ "type": "object", "properties": { "name": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "style": { "oneOf": [ @@ -2780,7 +3283,10 @@ ] }, "updatedAt": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "format": "date-time" } } @@ -2789,13 +3295,20 @@ "type": "object", "properties": { "defaultFont": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } } }, "ProjectSummary": { "type": "object", - "required": ["id", "name", "path"], + "required": [ + "id", + "name", + "path" + ], "properties": { "id": { "type": "string", @@ -2818,14 +3331,22 @@ }, "ProviderConfig": { "type": "object", - "required": ["id"], + "required": [ + "id" + ], "properties": { "api_key": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Populated from credential storage on `load()`, never written to config.toml.\nSerializes as `\"[REDACTED]\"` in API responses." }, "base_url": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "id": { "type": "string" @@ -2834,14 +3355,22 @@ }, "ProviderPatch": { "type": "object", - "required": ["id"], + "required": [ + "id" + ], "properties": { "apiKey": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "`\"[REDACTED]\"` → keep existing keyring secret; empty → clear; otherwise save." }, "baseUrl": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "id": { "type": "string" @@ -2850,7 +3379,9 @@ }, "ProviderSecretRequest": { "type": "object", - "required": ["secret"], + "required": [ + "secret" + ], "properties": { "secret": { "type": "string" @@ -2859,7 +3390,10 @@ }, "PutMaskResponse": { "type": "object", - "required": ["node", "blob"], + "required": [ + "node", + "blob" + ], "properties": { "blob": { "$ref": "#/components/schemas/BlobRef" @@ -2871,11 +3405,20 @@ }, "ReadingOrder": { "type": "string", - "enum": ["rtl", "ltr", "custom"] + "enum": [ + "rtl", + "ltr", + "custom" + ] }, "Region": { "type": "object", - "required": ["x", "y", "width", "height"], + "required": [ + "x", + "y", + "width", + "height" + ], "properties": { "height": { "type": "integer", @@ -2901,7 +3444,10 @@ }, "Scene": { "type": "object", - "required": ["project", "pages"], + "required": [ + "project", + "pages" + ], "properties": { "pages": { "type": "object", @@ -2922,7 +3468,10 @@ "SceneSnapshot": { "type": "object", "description": "JSON-shaped scene snapshot for the UI (no postcard decoder in JS).", - "required": ["epoch", "scene"], + "required": [ + "epoch", + "scene" + ], "properties": { "epoch": { "type": "integer", @@ -2936,7 +3485,10 @@ }, "SnapshotEvent": { "type": "object", - "required": ["jobs", "downloads"], + "required": [ + "jobs", + "downloads" + ], "properties": { "downloads": { "type": "array", @@ -2954,7 +3506,9 @@ }, "StartDownloadRequest": { "type": "object", - "required": ["modelId"], + "required": [ + "modelId" + ], "properties": { "modelId": { "type": "string", @@ -2964,7 +3518,9 @@ }, "StartDownloadResponse": { "type": "object", - "required": ["operationId"], + "required": [ + "operationId" + ], "properties": { "operationId": { "type": "string", @@ -2974,13 +3530,21 @@ }, "StartPipelineRequest": { "type": "object", - "required": ["steps"], + "required": [ + "steps" + ], "properties": { "defaultFont": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "pages": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "items": { "$ref": "#/components/schemas/PageId" }, @@ -3015,13 +3579,22 @@ "description": "Engine ids (`inventory::submit!` ids) to run in order." }, "systemPrompt": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "targetLanguage": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "textNodeIds": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "items": { "$ref": "#/components/schemas/NodeId" }, @@ -3031,7 +3604,9 @@ }, "StartPipelineResponse": { "type": "object", - "required": ["operationId"], + "required": [ + "operationId" + ], "properties": { "operationId": { "type": "string" @@ -3040,7 +3615,11 @@ }, "TextAlign": { "type": "string", - "enum": ["left", "center", "right"] + "enum": [ + "left", + "center", + "right" + ] }, "TextData": { "type": "object", @@ -3050,11 +3629,17 @@ "format": "float" }, "detectedFontSizePx": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "float" }, "detector": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "fontPrediction": { "oneOf": [ @@ -3067,7 +3652,10 @@ ] }, "linePolygons": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "items": { "type": "array", "items": { @@ -3093,7 +3681,10 @@ ] }, "rotationDeg": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "float" }, "sourceDirection": { @@ -3107,7 +3698,10 @@ ] }, "sourceLang": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "sprite": { "oneOf": [ @@ -3142,10 +3736,16 @@ ] }, "text": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "translation": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } } }, @@ -3154,15 +3754,24 @@ "description": "For fields where \"set to None\" is meaningful (e.g. clearing a translation),\nthe outer `Option` is \"patch present\", the inner is \"value present\".", "properties": { "confidence": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "float" }, "detectedFontSizePx": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "float" }, "detector": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "fontPrediction": { "oneOf": [ @@ -3175,7 +3784,10 @@ ] }, "linePolygons": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "items": { "type": "array", "items": { @@ -3188,7 +3800,10 @@ } }, "lockLayoutBox": { - "type": ["boolean", "null"] + "type": [ + "boolean", + "null" + ] }, "renderedDirection": { "oneOf": [ @@ -3201,7 +3816,10 @@ ] }, "rotationDeg": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "float" }, "sourceDirection": { @@ -3215,7 +3833,10 @@ ] }, "sourceLang": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "sprite": { "oneOf": [ @@ -3248,17 +3869,26 @@ ] }, "text": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] }, "translation": { - "type": ["string", "null"] + "type": [ + "string", + "null" + ] } } }, "TextDirection": { "type": "string", "description": "Reading axis of a text block.", - "enum": ["horizontal", "vertical"] + "enum": [ + "horizontal", + "vertical" + ] }, "TextShaderEffect": { "type": "object", @@ -3286,14 +3916,20 @@ "type": "boolean" }, "widthPx": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "float" } } }, "TextStyle": { "type": "object", - "required": ["fontFamilies", "color"], + "required": [ + "fontFamilies", + "color" + ], "properties": { "color": { "type": "array", @@ -3320,7 +3956,10 @@ } }, "fontSize": { - "type": ["number", "null"], + "type": [ + "number", + "null" + ], "format": "float" }, "stroke": { @@ -3347,7 +3986,10 @@ }, "TopFont": { "type": "object", - "required": ["index", "score"], + "required": [ + "index", + "score" + ], "properties": { "index": { "type": "integer", @@ -3361,7 +4003,12 @@ }, "Transform": { "type": "object", - "required": ["x", "y", "width", "height"], + "required": [ + "x", + "y", + "width", + "height" + ], "properties": { "height": { "type": "number", @@ -3387,4 +4034,4 @@ } } } -} +} \ No newline at end of file From fd6dc3a634d69a68bb4825d40f8a0cfd44cc5d25 Mon Sep 17 00:00:00 2001 From: Fermin Cirella Date: Wed, 29 Jul 2026 21:08:32 -0300 Subject: [PATCH 6/7] Re-order menu items and apply clippy corrections --- crates/koharu-rpc/src/routes/projects.rs | 2 +- ui/components/MenuBar.tsx | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/koharu-rpc/src/routes/projects.rs b/crates/koharu-rpc/src/routes/projects.rs index 6d4517aae..d9a885637 100644 --- a/crates/koharu-rpc/src/routes/projects.rs +++ b/crates/koharu-rpc/src/routes/projects.rs @@ -495,7 +495,7 @@ async fn import_script( label: "Import script translations".into(), }; - session.apply(batch).map_err(|e| ApiError::internal(e))?; + session.apply(batch).map_err(ApiError::internal)?; Ok(()) } diff --git a/ui/components/MenuBar.tsx b/ui/components/MenuBar.tsx index e1ff92e64..faed3cd06 100644 --- a/ui/components/MenuBar.tsx +++ b/ui/components/MenuBar.tsx @@ -174,30 +174,30 @@ export function MenuBar() { ] const scriptItems: MenuItem[] = [ - { - label: t('menu.importScript'), - onSelect: () => void promptImportScript(requirePageId()), - disabled: !hasPage, - testId: 'menu-file-import-script', - }, { label: t('menu.exportScript'), onSelect: () => void exportCurrentProjectAs('script', [requirePageId()]), disabled: !hasPage, testId: 'menu-file-export-script', }, - { - label: t('menu.importAllScript'), - onSelect: () => void promptImportScript(), - disabled: !hasScene, - testId: 'menu-file-import-all-script', - }, { label: t('menu.exportAllScript'), onSelect: () => void exportCurrentProjectAs('script'), disabled: !hasScene, testId: 'menu-file-export-all-script', }, + { + label: t('menu.importScript'), + onSelect: () => void promptImportScript(requirePageId()), + disabled: !hasPage, + testId: 'menu-file-import-script', + }, + { + label: t('menu.importAllScript'), + onSelect: () => void promptImportScript(), + disabled: !hasScene, + testId: 'menu-file-import-all-script', + }, ] const helpMenuItems: MenuItem[] = [ From 3ae05f6beac5492146f92d05049fcc939a8d0550 Mon Sep 17 00:00:00 2001 From: Fermin Cirella Date: Wed, 29 Jul 2026 21:31:48 -0300 Subject: [PATCH 7/7] Update OpenAPI spec test snapshot --- .../tests/snapshots/openapi__openapi_paths_snapshot.snap | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/koharu-rpc/tests/snapshots/openapi__openapi_paths_snapshot.snap b/crates/koharu-rpc/tests/snapshots/openapi__openapi_paths_snapshot.snap index 433dc8da6..e79a804bc 100644 --- a/crates/koharu-rpc/tests/snapshots/openapi__openapi_paths_snapshot.snap +++ b/crates/koharu-rpc/tests/snapshots/openapi__openapi_paths_snapshot.snap @@ -202,6 +202,12 @@ expression: paths "post", ], ), + ( + "/projects/current/import-script", + [ + "post", + ], + ), ( "/projects/import", [