Skip to content

Commit e7cf88d

Browse files
Add JSON import / export
Allows exporting source and translation text of a whole project to JSON format. Allows importing translation text for a whole project in JSON format.
1 parent 055e723 commit e7cf88d

21 files changed

Lines changed: 746 additions & 22 deletions

koharu-rpc/src/routes/projects.rs

Lines changed: 351 additions & 2 deletions
Large diffs are not rendered by default.

ui/components/MenuBar.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
import { useScene } from '@/hooks/useScene'
2424
import { getConfig, startPipeline } from '@/lib/api/default/default'
2525
import { isTauri, openExternalUrl } from '@/lib/backend'
26-
import { exportCurrentProjectAs, importPages } from '@/lib/io/pagesIo'
26+
import { exportCurrentProjectAs, importPages, importTranslations } from '@/lib/io/pagesIo'
2727
import { closeProject, redoOp, selectAllTextNodesOnCurrentPage, undoOp } from '@/lib/io/scene'
2828
import { formatShortcutForDisplay, getPlatform } from '@/lib/shortcutUtils'
2929
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
@@ -165,6 +165,41 @@ export function MenuBar() {
165165
disabled: !hasScene,
166166
testId: 'menu-file-export-all-rendered',
167167
},
168+
{
169+
label: t('menu.exportSourceTexts'),
170+
onSelect: () => void exportCurrentProjectAs('source_texts'),
171+
disabled: !hasScene,
172+
testId: 'menu-file-export-source-texts',
173+
},
174+
{
175+
label: t('menu.exportTranslations'),
176+
onSelect: () => void exportCurrentProjectAs('translations'),
177+
disabled: !hasScene,
178+
testId: 'menu-file-export-translations',
179+
},
180+
{
181+
label: t('menu.importTranslations'),
182+
onSelect: async () => {
183+
const result = await importTranslations()
184+
if (!result) return
185+
if (result.errors.length > 0) {
186+
window.alert(t('menu.importTranslationsErrors', { errors: result.errors.join('\n') }))
187+
return
188+
}
189+
const skipSummary = result.skipped
190+
.map((s) => `Page ${s.page}: ${s.reason}`)
191+
.join('\n')
192+
window.alert(
193+
t('menu.importTranslationsDone', {
194+
applied: result.applied,
195+
skipped: result.skipped.length,
196+
details: skipSummary,
197+
}),
198+
)
199+
},
200+
disabled: !hasScene,
201+
testId: 'menu-file-import-translations',
202+
},
168203
]
169204

170205
const helpMenuItems: MenuItem[] = [

ui/lib/api/default/default.msw.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
AddImageLayerResponse,
2525
AppConfig,
2626
AppEvent,
27+
ImportTranslationsResponse,
2728
CodexAuthStatus,
2829
CodexDeviceLogin,
2930
CodexDeviceLoginStatus,
@@ -752,6 +753,24 @@ export const getPutCurrentProjectResponseMock = (
752753
export const getExportCurrentProjectResponseMock = (): ArrayBuffer =>
753754
new ArrayBuffer(faker.number.int({ min: 1, max: 64 }))
754755

756+
export const getImportTranslationsResponseMock = (
757+
overrideResponse: Partial<Extract<ImportTranslationsResponse, object>> = {},
758+
): ImportTranslationsResponse => ({
759+
applied: faker.number.int({ min: 0 }),
760+
errors: Array.from(
761+
{ length: faker.number.int({ min: 1, max: 10 }) },
762+
(_, i) => i + 1,
763+
).map(() => faker.string.alpha({ length: { min: 10, max: 20 } })),
764+
skipped: Array.from(
765+
{ length: faker.number.int({ min: 1, max: 10 }) },
766+
(_, i) => i + 1,
767+
).map(() => ({
768+
page: faker.number.int({ min: 0 }),
769+
reason: faker.string.alpha({ length: { min: 10, max: 20 } }),
770+
})),
771+
...overrideResponse,
772+
})
773+
755774
export const getImportProjectResponseMock = (
756775
overrideResponse: Partial<Extract<ProjectSummary, object>> = {},
757776
): ProjectSummary => ({
@@ -1981,6 +2000,31 @@ export const getDeleteCurrentProjectMockHandler = (
19812000
)
19822001
}
19832002

2003+
export const getImportTranslationsMockHandler = (
2004+
overrideResponse?:
2005+
| ImportTranslationsResponse
2006+
| ((
2007+
info: Parameters<Parameters<typeof http.post>[1]>[0],
2008+
) => Promise<ImportTranslationsResponse> | ImportTranslationsResponse),
2009+
options?: RequestHandlerOptions,
2010+
) => {
2011+
return http.post(
2012+
'*/projects/current/import-translations',
2013+
async (info: Parameters<Parameters<typeof http.post>[1]>[0]) => {
2014+
await delay(0)
2015+
return HttpResponse.json(
2016+
overrideResponse !== undefined
2017+
? typeof overrideResponse === 'function'
2018+
? await overrideResponse(info)
2019+
: overrideResponse
2020+
: getImportTranslationsResponseMock(),
2021+
{ status: 200 },
2022+
)
2023+
},
2024+
options,
2025+
)
2026+
}
2027+
19842028
export const getExportCurrentProjectMockHandler = (
19852029
overrideResponse?:
19862030
| ArrayBuffer
@@ -2145,6 +2189,7 @@ export const getDefaultMock = () => [
21452189
getCreateProjectMockHandler(),
21462190
getPutCurrentProjectMockHandler(),
21472191
getDeleteCurrentProjectMockHandler(),
2192+
getImportTranslationsMockHandler(),
21482193
getExportCurrentProjectMockHandler(),
21492194
getImportProjectMockHandler(),
21502195
getDeleteProjectMockHandler(),

ui/lib/api/default/default.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import type {
2424
AddImageLayerResponse,
2525
AppConfig,
2626
AppEvent,
27+
ImportTranslationsRequest,
28+
ImportTranslationsResponse,
2729
CodexAuthStatus,
2830
CodexDeviceLogin,
2931
CodexImageGenerationOptions,
@@ -3310,6 +3312,83 @@ export const useImportProject = <TError = unknown, TContext = unknown>(
33103312
): UseMutationResult<Awaited<ReturnType<typeof importProject>>, TError, void, TContext> => {
33113313
return useMutation(getImportProjectMutationOptions(options), queryClient)
33123314
}
3315+
export const getImportTranslationsUrl = () => {
3316+
return `/api/v1/projects/current/import-translations`
3317+
}
3318+
3319+
export const importTranslations = async (
3320+
importTranslationsRequest: ImportTranslationsRequest,
3321+
options?: RequestInit,
3322+
): Promise<ImportTranslationsResponse> => {
3323+
return fetchApi<ImportTranslationsResponse>(getImportTranslationsUrl(), {
3324+
...options,
3325+
method: 'POST',
3326+
headers: { 'Content-Type': 'application/json', ...options?.headers },
3327+
body: JSON.stringify(importTranslationsRequest),
3328+
})
3329+
}
3330+
3331+
export const getImportTranslationsMutationOptions = <
3332+
TError = unknown,
3333+
TContext = unknown,
3334+
>(options?: {
3335+
mutation?: UseMutationOptions<
3336+
Awaited<ReturnType<typeof importTranslations>>,
3337+
TError,
3338+
{ data: ImportTranslationsRequest },
3339+
TContext
3340+
>
3341+
request?: SecondParameter<typeof fetchApi>
3342+
}): UseMutationOptions<
3343+
Awaited<ReturnType<typeof importTranslations>>,
3344+
TError,
3345+
{ data: ImportTranslationsRequest },
3346+
TContext
3347+
> => {
3348+
const mutationKey = ['importTranslations']
3349+
const { mutation: mutationOptions, request: requestOptions } = options
3350+
? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey
3351+
? options
3352+
: { ...options, mutation: { ...options.mutation, mutationKey } }
3353+
: { mutation: { mutationKey }, request: undefined }
3354+
3355+
const mutationFn: MutationFunction<
3356+
Awaited<ReturnType<typeof importTranslations>>,
3357+
{ data: ImportTranslationsRequest }
3358+
> = (props) => {
3359+
const { data } = props ?? {}
3360+
3361+
return importTranslations(data, requestOptions)
3362+
}
3363+
3364+
return { mutationFn, ...mutationOptions }
3365+
}
3366+
3367+
export type ImportTranslationsMutationResult = NonNullable<
3368+
Awaited<ReturnType<typeof importTranslations>>
3369+
>
3370+
export type ImportTranslationsMutationBody = ImportTranslationsRequest
3371+
export type ImportTranslationsMutationError = unknown
3372+
3373+
export const useImportTranslations = <TError = unknown, TContext = unknown>(
3374+
options?: {
3375+
mutation?: UseMutationOptions<
3376+
Awaited<ReturnType<typeof importTranslations>>,
3377+
TError,
3378+
{ data: ImportTranslationsRequest },
3379+
TContext
3380+
>
3381+
request?: SecondParameter<typeof fetchApi>
3382+
},
3383+
queryClient?: QueryClient,
3384+
): UseMutationResult<
3385+
Awaited<ReturnType<typeof importTranslations>>,
3386+
TError,
3387+
{ data: ImportTranslationsRequest },
3388+
TContext
3389+
> => {
3390+
return useMutation(getImportTranslationsMutationOptions(options), queryClient)
3391+
}
33133392
export const getDeleteProjectUrl = (id: string) => {
33143393
return `/api/v1/projects/${id}`
33153394
}

ui/lib/api/schemas/exportFormat.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ export const ExportFormat = {
1111
psd: 'psd',
1212
rendered: 'rendered',
1313
inpainted: 'inpainted',
14+
source_texts: 'source_texts',
15+
translations: 'translations',
1416
} as const
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Generated by orval v8.8.1 🍺
3+
* Do not edit manually.
4+
* OpenAPI spec version: 0.0.1
5+
*/
6+
7+
export interface ImportTranslationsRequest {
8+
/** User-supplied JSON text. May be a raw object, wrapped in a
9+
markdown ```json fence, or surrounded by prose. The server
10+
extracts the first JSON object it can find. */
11+
payload: string
12+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Generated by orval v8.8.1 🍺
3+
* Do not edit manually.
4+
* OpenAPI spec version: 0.0.1
5+
*/
6+
import type { ImportTranslationsSkip } from './importTranslationsSkip'
7+
8+
export interface ImportTranslationsResponse {
9+
/**
10+
* Number of pages whose translations were applied.
11+
* @minimum 0
12+
*/
13+
applied: number
14+
/** Top-level parse errors (e.g. could not extract JSON). Empty on success. */
15+
errors: string[]
16+
/** Per-page reasons for pages that were skipped (missing from response,
17+
length mismatch, etc.). Not an error — partial success is normal. */
18+
skipped: ImportTranslationsSkip[]
19+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Generated by orval v8.8.1 🍺
3+
* Do not edit manually.
4+
* OpenAPI spec version: 0.0.1
5+
*/
6+
7+
export interface ImportTranslationsSkip {
8+
/** @minimum 0 */
9+
page: number
10+
reason: string
11+
}

ui/lib/api/schemas/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
export * from './addImageLayerResponse'
88
export * from './appConfig'
99
export * from './appEvent'
10+
export * from './importTranslationsRequest'
11+
export * from './importTranslationsResponse'
12+
export * from './importTranslationsSkip'
1013
export * from './blobRef'
1114
export * from './codexAuthAttemptStatus'
1215
export * from './codexAuthStatus'

ui/lib/io/openFiles.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,32 @@ export async function openKhrFile(): Promise<File | null> {
111111
}
112112
}
113113

114+
/** Pick a single JSON file. */
115+
export async function openJsonFile(): Promise<File | null> {
116+
if (isTauri()) {
117+
const { open } = await import('@tauri-apps/plugin-dialog')
118+
const picked = await open({
119+
multiple: false,
120+
filters: [{ name: 'JSON', extensions: ['json'] }],
121+
})
122+
if (!picked || typeof picked !== 'string') return null
123+
const [file] = await readTauriFiles([picked])
124+
return file ?? null
125+
}
126+
const { fileOpen } = await import('browser-fs-access')
127+
try {
128+
const result = await fileOpen({
129+
multiple: false,
130+
extensions: ['.json'],
131+
description: 'JSON',
132+
})
133+
return Array.isArray(result) ? (result[0] ?? null) : result
134+
} catch (e) {
135+
if (isAbort(e)) return null
136+
throw e
137+
}
138+
}
139+
114140
// ---------------------------------------------------------------------------
115141
// Helpers
116142
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)