Mayo commited on
fix: broken export
Browse files- ui/lib/io/pagesIo.ts +12 -3
- ui/lib/io/saveBlob.ts +4 -1
- ui/lib/io/scene.ts +34 -3
- ui/tests/lib/io/pagesIo.test.ts +31 -4
- ui/tests/lib/io/scene.test.ts +39 -3
ui/lib/io/pagesIo.ts
CHANGED
|
@@ -68,7 +68,16 @@ export async function exportCurrentProjectAs(
|
|
| 68 |
format: 'khr' | 'psd' | 'rendered' | 'inpainted',
|
| 69 |
pages?: string[],
|
| 70 |
): Promise<void> {
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
}
|
|
|
|
| 68 |
format: 'khr' | 'psd' | 'rendered' | 'inpainted',
|
| 69 |
pages?: string[],
|
| 70 |
): Promise<void> {
|
| 71 |
+
try {
|
| 72 |
+
const { blob, filename } = await exportProject({ format, pages })
|
| 73 |
+
const base = sanitiseBaseName(currentProjectName())
|
| 74 |
+
// Prefer the server's Content-Disposition filename (matches the actual
|
| 75 |
+
// bytes — a raw PNG/PSD for single-file responses, a zip for multi).
|
| 76 |
+
// Fall back to our guess only if the header is missing/unparseable.
|
| 77 |
+
const defaultName = filename ?? `${base}.${exportExtension[format]}`
|
| 78 |
+
await saveBlob(blob, defaultName)
|
| 79 |
+
} catch (err) {
|
| 80 |
+
console.error('Export failed:', err)
|
| 81 |
+
throw err
|
| 82 |
+
}
|
| 83 |
}
|
ui/lib/io/saveBlob.ts
CHANGED
|
@@ -18,7 +18,10 @@
|
|
| 18 |
import { isTauri } from '@/lib/backend'
|
| 19 |
|
| 20 |
export async function saveBlob(blob: Blob, defaultName: string): Promise<boolean> {
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
if (isTauri()) {
|
| 24 |
const { open, save } = await import('@tauri-apps/plugin-dialog')
|
|
|
|
| 18 |
import { isTauri } from '@/lib/backend'
|
| 19 |
|
| 20 |
export async function saveBlob(blob: Blob, defaultName: string): Promise<boolean> {
|
| 21 |
+
// Zip detection must come from the actual content type — a single-file
|
| 22 |
+
// export (PNG/PSD/khr) whose filename happens to end in `.zip` would
|
| 23 |
+
// otherwise be fed to `unzipSync` and throw.
|
| 24 |
+
const isZip = blob.type === 'application/zip'
|
| 25 |
|
| 26 |
if (isTauri()) {
|
| 27 |
const { open, save } = await import('@tauri-apps/plugin-dialog')
|
ui/lib/io/scene.ts
CHANGED
|
@@ -6,8 +6,8 @@ import {
|
|
| 6 |
createPagesFromPaths,
|
| 7 |
createProject,
|
| 8 |
deleteCurrentProject,
|
| 9 |
-
exportCurrentProject,
|
| 10 |
getConfig,
|
|
|
|
| 11 |
getGetConfigQueryKey,
|
| 12 |
getGetCurrentLlmQueryKey,
|
| 13 |
getGetSceneJsonQueryKey,
|
|
@@ -18,6 +18,7 @@ import {
|
|
| 18 |
startPipeline,
|
| 19 |
undo,
|
| 20 |
} from '@/lib/api/default/default'
|
|
|
|
| 21 |
import type {
|
| 22 |
ConfigPatch,
|
| 23 |
CreateProjectRequest,
|
|
@@ -27,6 +28,7 @@ import type {
|
|
| 27 |
ProjectSummary,
|
| 28 |
SceneSnapshot,
|
| 29 |
} from '@/lib/api/schemas'
|
|
|
|
| 30 |
import { queryClient } from '@/lib/queryClient'
|
| 31 |
import { useSelectionStore } from '@/lib/stores/selectionStore'
|
| 32 |
|
|
@@ -168,8 +170,37 @@ export async function uploadKhrArchive(file: File): Promise<ProjectSummary> {
|
|
| 168 |
|
| 169 |
// Export ---------------------------------------------------------------------
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
}
|
| 174 |
|
| 175 |
// Config ---------------------------------------------------------------------
|
|
|
|
| 6 |
createPagesFromPaths,
|
| 7 |
createProject,
|
| 8 |
deleteCurrentProject,
|
|
|
|
| 9 |
getConfig,
|
| 10 |
+
getExportCurrentProjectUrl,
|
| 11 |
getGetConfigQueryKey,
|
| 12 |
getGetCurrentLlmQueryKey,
|
| 13 |
getGetSceneJsonQueryKey,
|
|
|
|
| 18 |
startPipeline,
|
| 19 |
undo,
|
| 20 |
} from '@/lib/api/default/default'
|
| 21 |
+
import { ApiError } from '@/lib/api/fetch'
|
| 22 |
import type {
|
| 23 |
ConfigPatch,
|
| 24 |
CreateProjectRequest,
|
|
|
|
| 28 |
ProjectSummary,
|
| 29 |
SceneSnapshot,
|
| 30 |
} from '@/lib/api/schemas'
|
| 31 |
+
import { filenameFromContentDisposition } from '@/lib/io/saveBlob'
|
| 32 |
import { queryClient } from '@/lib/queryClient'
|
| 33 |
import { useSelectionStore } from '@/lib/stores/selectionStore'
|
| 34 |
|
|
|
|
| 170 |
|
| 171 |
// Export ---------------------------------------------------------------------
|
| 172 |
|
| 173 |
+
/**
|
| 174 |
+
* Export wrapper that keeps the server-supplied filename.
|
| 175 |
+
*
|
| 176 |
+
* The backend returns the raw file for single-page exports (e.g. a PNG or
|
| 177 |
+
* PSD with `Content-Type: image/png`), and a zip when the format produces
|
| 178 |
+
* multiple files. The raw-file shortcut means we can't hardcode `.zip` in
|
| 179 |
+
* the UI — we'd end up feeding a PNG to `unzipSync` and crashing. Read
|
| 180 |
+
* the `Content-Disposition` filename so the caller gets the correct
|
| 181 |
+
* extension + `blob.type` to drive the save path.
|
| 182 |
+
*/
|
| 183 |
+
export async function exportProject(
|
| 184 |
+
req: ExportProjectRequest,
|
| 185 |
+
): Promise<{ blob: Blob; filename?: string }> {
|
| 186 |
+
const res = await fetch(getExportCurrentProjectUrl(), {
|
| 187 |
+
method: 'POST',
|
| 188 |
+
headers: { 'Content-Type': 'application/json' },
|
| 189 |
+
body: JSON.stringify(req),
|
| 190 |
+
})
|
| 191 |
+
if (!res.ok) {
|
| 192 |
+
const body = await res.json().catch(() => null)
|
| 193 |
+
const message =
|
| 194 |
+
(body && typeof body === 'object' && 'message' in body && typeof body.message === 'string'
|
| 195 |
+
? body.message
|
| 196 |
+
: null) ??
|
| 197 |
+
res.statusText ??
|
| 198 |
+
`HTTP ${res.status}`
|
| 199 |
+
throw new ApiError(res.status, message, body)
|
| 200 |
+
}
|
| 201 |
+
const blob = await res.blob()
|
| 202 |
+
const filename = filenameFromContentDisposition(res.headers.get('content-disposition'))
|
| 203 |
+
return { blob, filename }
|
| 204 |
}
|
| 205 |
|
| 206 |
// Config ---------------------------------------------------------------------
|
ui/tests/lib/io/pagesIo.test.ts
CHANGED
|
@@ -13,10 +13,16 @@ vi.mock('@/lib/io/openFiles', () => ({
|
|
| 13 |
openImageFolder: vi.fn(),
|
| 14 |
openKhrFile: vi.fn(),
|
| 15 |
}))
|
| 16 |
-
vi.mock('@/lib/io/saveBlob', () =>
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
import { openImageFiles, openImageFolder, openKhrFile } from '@/lib/io/openFiles'
|
| 22 |
import { exportCurrentProjectAs, importKhrFile, importPages } from '@/lib/io/pagesIo'
|
|
@@ -183,4 +189,25 @@ describe('exportCurrentProjectAs', () => {
|
|
| 183 |
const [, filename] = asMock(saveBlob).mock.calls[0]
|
| 184 |
expect(filename).toBe('koharu-export.khr')
|
| 185 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
})
|
|
|
|
| 13 |
openImageFolder: vi.fn(),
|
| 14 |
openKhrFile: vi.fn(),
|
| 15 |
}))
|
| 16 |
+
vi.mock('@/lib/io/saveBlob', async () => {
|
| 17 |
+
// Keep the real `filenameFromContentDisposition` so the export flow can
|
| 18 |
+
// read server-provided filenames from `Content-Disposition`. Only stub
|
| 19 |
+
// `saveBlob` itself, since it touches the filesystem / Tauri dialog.
|
| 20 |
+
const actual = await vi.importActual<typeof import('@/lib/io/saveBlob')>('@/lib/io/saveBlob')
|
| 21 |
+
return {
|
| 22 |
+
...actual,
|
| 23 |
+
saveBlob: vi.fn().mockResolvedValue(true),
|
| 24 |
+
}
|
| 25 |
+
})
|
| 26 |
|
| 27 |
import { openImageFiles, openImageFolder, openKhrFile } from '@/lib/io/openFiles'
|
| 28 |
import { exportCurrentProjectAs, importKhrFile, importPages } from '@/lib/io/pagesIo'
|
|
|
|
| 189 |
const [, filename] = asMock(saveBlob).mock.calls[0]
|
| 190 |
expect(filename).toBe('koharu-export.khr')
|
| 191 |
})
|
| 192 |
+
|
| 193 |
+
it('uses the server-provided filename for single-file exports', async () => {
|
| 194 |
+
// Regression: the backend returns a raw PNG (Content-Type: image/png,
|
| 195 |
+
// Content-Disposition: page-001-abc.png) for single-page exports. The
|
| 196 |
+
// UI previously forced `.zip` into the filename which caused saveBlob
|
| 197 |
+
// to try `unzipSync` on a PNG and silently fail.
|
| 198 |
+
server.use(
|
| 199 |
+
http.post('/api/v1/projects/current/export', () =>
|
| 200 |
+
HttpResponse.arrayBuffer(new Uint8Array([137, 80, 78, 71]).buffer, {
|
| 201 |
+
headers: {
|
| 202 |
+
'content-type': 'image/png',
|
| 203 |
+
'content-disposition': 'attachment; filename="page-001-abc.png"',
|
| 204 |
+
},
|
| 205 |
+
}),
|
| 206 |
+
),
|
| 207 |
+
)
|
| 208 |
+
await exportCurrentProjectAs('rendered', ['p1'])
|
| 209 |
+
const [blob, filename] = asMock(saveBlob).mock.calls[0]
|
| 210 |
+
expect(filename).toBe('page-001-abc.png')
|
| 211 |
+
expect((blob as Blob).type).toBe('image/png')
|
| 212 |
+
})
|
| 213 |
})
|
ui/tests/lib/io/scene.test.ts
CHANGED
|
@@ -147,21 +147,57 @@ describe('pages + archive uploads', () => {
|
|
| 147 |
})
|
| 148 |
|
| 149 |
describe('export', () => {
|
| 150 |
-
it('returns a Blob without invalidating any cache', async () => {
|
| 151 |
server.use(
|
| 152 |
http.post('/api/v1/projects/current/export', () =>
|
| 153 |
HttpResponse.arrayBuffer(new Uint8Array([9, 9, 9]).buffer, {
|
| 154 |
-
headers: {
|
|
|
|
|
|
|
|
|
|
| 155 |
}),
|
| 156 |
),
|
| 157 |
)
|
| 158 |
|
| 159 |
-
const blob = await exportProject({ format: 'rendered' })
|
| 160 |
expect(Object.prototype.toString.call(blob)).toBe('[object Blob]')
|
| 161 |
expect(blob.type).toBe('application/zip')
|
| 162 |
expect(blob.size).toBe(3)
|
|
|
|
| 163 |
expect(isInvalidated(getGetSceneJsonQueryKey())).toBe(false)
|
| 164 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
})
|
| 166 |
|
| 167 |
describe('config + llm invalidation', () => {
|
|
|
|
| 147 |
})
|
| 148 |
|
| 149 |
describe('export', () => {
|
| 150 |
+
it('returns a Blob + filename without invalidating any cache', async () => {
|
| 151 |
server.use(
|
| 152 |
http.post('/api/v1/projects/current/export', () =>
|
| 153 |
HttpResponse.arrayBuffer(new Uint8Array([9, 9, 9]).buffer, {
|
| 154 |
+
headers: {
|
| 155 |
+
'content-type': 'application/zip',
|
| 156 |
+
'content-disposition': 'attachment; filename="project-rendered.zip"',
|
| 157 |
+
},
|
| 158 |
}),
|
| 159 |
),
|
| 160 |
)
|
| 161 |
|
| 162 |
+
const { blob, filename } = await exportProject({ format: 'rendered' })
|
| 163 |
expect(Object.prototype.toString.call(blob)).toBe('[object Blob]')
|
| 164 |
expect(blob.type).toBe('application/zip')
|
| 165 |
expect(blob.size).toBe(3)
|
| 166 |
+
expect(filename).toBe('project-rendered.zip')
|
| 167 |
expect(isInvalidated(getGetSceneJsonQueryKey())).toBe(false)
|
| 168 |
})
|
| 169 |
+
|
| 170 |
+
it('returns the raw file type + single-file filename for single-page exports', async () => {
|
| 171 |
+
server.use(
|
| 172 |
+
http.post('/api/v1/projects/current/export', () =>
|
| 173 |
+
HttpResponse.arrayBuffer(new Uint8Array([137, 80, 78, 71]).buffer, {
|
| 174 |
+
headers: {
|
| 175 |
+
'content-type': 'image/png',
|
| 176 |
+
'content-disposition': 'attachment; filename="page-001-abc.png"',
|
| 177 |
+
},
|
| 178 |
+
}),
|
| 179 |
+
),
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
const { blob, filename } = await exportProject({
|
| 183 |
+
format: 'rendered',
|
| 184 |
+
pages: ['p1'],
|
| 185 |
+
})
|
| 186 |
+
expect(blob.type).toBe('image/png')
|
| 187 |
+
expect(filename).toBe('page-001-abc.png')
|
| 188 |
+
})
|
| 189 |
+
|
| 190 |
+
it('throws a structured error when the server returns 400', async () => {
|
| 191 |
+
server.use(
|
| 192 |
+
http.post('/api/v1/projects/current/export', () =>
|
| 193 |
+
HttpResponse.json({ message: 'no project open' }, { status: 400 }),
|
| 194 |
+
),
|
| 195 |
+
)
|
| 196 |
+
await expect(exportProject({ format: 'khr' })).rejects.toMatchObject({
|
| 197 |
+
status: 400,
|
| 198 |
+
message: 'no project open',
|
| 199 |
+
})
|
| 200 |
+
})
|
| 201 |
})
|
| 202 |
|
| 203 |
describe('config + llm invalidation', () => {
|