Enhance history management with export and import functionalities

- Added endpoints for exporting generations as ZIP archives and audio files.
- Implemented import functionality for generations from ZIP archives with validation.
- Updated HistoryTable component to support new export and import features.
- Improved error handling and user notifications for export/import processes.
- Refactored related hooks and API client methods to accommodate new functionalities.
This commit is contained in:
Jamie Pine
2026-01-26 00:30:37 -08:00
parent 333cb262e0
commit 551abc9856
14 changed files with 653 additions and 45 deletions
+48
View File
@@ -176,6 +176,54 @@ class ApiClient {
});
}
async exportGeneration(generationId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/history/${generationId}/export`;
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.blob();
}
async exportGenerationAudio(generationId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/history/${generationId}/export-audio`;
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.blob();
}
async importGeneration(file: File): Promise<{ id: string; profile_id: string; profile_name: string; text: string; message: string }> {
const url = `${this.getBaseUrl()}/history/import`;
const formData = new FormData();
formData.append('file', file);
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.json();
}
// Audio
getAudioUrl(audioId: string): string {
return `${this.getBaseUrl()}/audio/${audioId}`;
+128
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import type { HistoryQuery } from '@/lib/api/types';
import { isTauri } from '@/lib/tauri';
export function useHistory(query?: HistoryQuery) {
return useQuery({
@@ -27,3 +28,130 @@ export function useDeleteGeneration() {
},
});
}
export function useExportGeneration() {
return useMutation({
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
const blob = await apiClient.exportGeneration(generationId);
// Create safe filename from text
const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
const filename = `generation-${safeText}.voicebox.zip`;
if (isTauri()) {
// Use Tauri's native save dialog
try {
const { save } = await import('@tauri-apps/plugin-dialog');
const filePath = await save({
defaultPath: filename,
filters: [
{
name: 'Voicebox Generation',
extensions: ['voicebox.zip', 'zip'],
},
],
});
if (filePath) {
// Write file using Tauri's filesystem API
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
const arrayBuffer = await blob.arrayBuffer();
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
}
} catch (error) {
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
// Fall back to browser download if Tauri dialog fails
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
} else {
// Browser: trigger download
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
return blob;
},
});
}
export function useExportGenerationAudio() {
return useMutation({
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
const blob = await apiClient.exportGenerationAudio(generationId);
// Create safe filename from text
const safeText = text.substring(0, 30).replace(/[^a-z0-9]/gi, '-').toLowerCase();
const filename = `${safeText}.wav`;
if (isTauri()) {
// Use Tauri's native save dialog
try {
const { save } = await import('@tauri-apps/plugin-dialog');
const filePath = await save({
defaultPath: filename,
filters: [
{
name: 'Audio File',
extensions: ['wav'],
},
],
});
if (filePath) {
// Write file using Tauri's filesystem API
const { writeBinaryFile } = await import('@tauri-apps/plugin-fs');
const arrayBuffer = await blob.arrayBuffer();
await writeBinaryFile(filePath, new Uint8Array(arrayBuffer));
}
} catch (error) {
console.error('Failed to use Tauri dialog, falling back to browser download:', error);
// Fall back to browser download if Tauri dialog fails
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
} else {
// Browser: trigger download
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
return blob;
},
});
}
export function useImportGeneration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) => apiClient.importGeneration(file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['history'] });
},
});
}