Mayo commited on
Commit
2337389
·
unverified ·
1 Parent(s): b0bc54c

fix: broken export

Browse files
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
- const blob = await exportProject({ format, pages })
72
- const base = sanitiseBaseName(currentProjectName())
73
- await saveBlob(blob, `${base}.${exportExtension[format]}`)
 
 
 
 
 
 
 
 
 
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
- const isZip = blob.type === 'application/zip' || defaultName.toLowerCase().endsWith('.zip')
 
 
 
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
- export async function exportProject(req: ExportProjectRequest): Promise<Blob> {
172
- return exportCurrentProject(req)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- saveBlob: vi.fn().mockResolvedValue(true),
18
- filenameFromContentDisposition: () => undefined,
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: { 'content-type': 'application/zip' },
 
 
 
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', () => {