Skip to content

Commit 6068013

Browse files
add export/import workspace feature
1 parent 85de909 commit 6068013

File tree

4 files changed

+271
-3
lines changed

4 files changed

+271
-3
lines changed

backend/src/routes/import-export.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ const upload = multer({
2424
}
2525
});
2626

27+
const uploadDb = multer({
28+
storage: multer.memoryStorage(),
29+
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limit for DB files
30+
fileFilter: (_req, file, cb) => {
31+
if (file.originalname.endsWith('.db') || file.mimetype === 'application/octet-stream') {
32+
cb(null, true);
33+
} else {
34+
cb(new Error('Invalid file type. Only .db files are allowed.'));
35+
}
36+
}
37+
});
38+
2739
// Import CSV/TSV to create new workspace
2840
router.post('/import', upload.single('file'), (req: Request, res: Response, next: NextFunction) => {
2941
if (!req.file) {
@@ -104,4 +116,61 @@ router.post('/validate/row', (req: Request, res: Response, next: NextFunction) =
104116
res.json(response);
105117
});
106118

119+
// Download workspace as .db file
120+
router.get('/workspaces/:id/download-db', (req: Request, res: Response, next: NextFunction) => {
121+
const workspace = workspaceService.get(req.params.id);
122+
if (!workspace) {
123+
return next(new AppError(404, 'Workspace not found', 'WORKSPACE_NOT_FOUND'));
124+
}
125+
126+
try {
127+
const buffer = workspaceService.exportDb(req.params.id);
128+
const filename = `${workspace.name.replace(/[^a-zA-Z0-9-_]/g, '_')}.db`;
129+
res.setHeader('Content-Type', 'application/octet-stream');
130+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
131+
res.send(buffer);
132+
} catch (err) {
133+
return next(new AppError(500, 'Failed to export workspace database', 'DB_EXPORT_FAILED'));
134+
}
135+
});
136+
137+
// Upload .db file to create new workspace (requires ALLOW_DB_UPLOAD=true)
138+
router.post('/import-db', uploadDb.single('file'), (req: Request, res: Response, next: NextFunction) => {
139+
if (process.env.ALLOW_DB_UPLOAD !== 'true') {
140+
return next(new AppError(403, 'Database upload is disabled. Set ALLOW_DB_UPLOAD=true to enable.', 'DB_UPLOAD_DISABLED'));
141+
}
142+
143+
if (!req.file) {
144+
return next(new AppError(400, 'No file uploaded', 'NO_FILE'));
145+
}
146+
147+
try {
148+
const name = req.body.name || undefined;
149+
const workspace = workspaceService.importDb(req.file.buffer, name);
150+
151+
const response: ApiResponse = {
152+
success: true,
153+
data: {
154+
workspaceId: workspace.id,
155+
workspaceName: workspace.name
156+
}
157+
};
158+
res.status(201).json(response);
159+
} catch (err) {
160+
const message = err instanceof Error ? err.message : 'Failed to import database';
161+
return next(new AppError(400, message, 'DB_IMPORT_FAILED'));
162+
}
163+
});
164+
165+
// Config endpoint - tells frontend which features are enabled
166+
router.get('/config', (_req: Request, res: Response) => {
167+
const response: ApiResponse = {
168+
success: true,
169+
data: {
170+
allowDbUpload: process.env.ALLOW_DB_UPLOAD === 'true'
171+
}
172+
};
173+
res.json(response);
174+
});
175+
107176
export default router;

backend/src/services/database.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,54 @@ export const workspaceService = {
343343
getUpdatedAt(id: string): number | null {
344344
const row = extractOne<{ updated_at: number }>(masterDb, 'SELECT updated_at FROM workspaces WHERE id = ?', [id]);
345345
return row?.updated_at ?? null;
346+
},
347+
348+
getDbPath(id: string): string {
349+
return join(DATA_DIR, `${id}.db`);
350+
},
351+
352+
exportDb(id: string): Buffer {
353+
// Make sure the on-disk file is up to date
354+
saveWorkspaceDb(id);
355+
const dbPath = join(DATA_DIR, `${id}.db`);
356+
return readFileSync(dbPath);
357+
},
358+
359+
importDb(buffer: Buffer, name?: string): Workspace {
360+
const newId = uuidv4();
361+
const now = Math.floor(Date.now() / 1000);
362+
363+
// Load the uploaded DB to validate it and extract/set the name
364+
const db = new SQL.Database(new Uint8Array(buffer));
365+
366+
// Validate it has the expected structure
367+
const tables = extractRows<{ name: string }>(db, "SELECT name FROM sqlite_master WHERE type='table'");
368+
const tableNames = tables.map(t => t.name);
369+
if (!tableNames.includes('_metadata') || !tableNames.includes('_shapes')) {
370+
db.close();
371+
throw new Error('Invalid workspace database: missing required tables (_metadata, _shapes)');
372+
}
373+
374+
// Extract or override the workspace name
375+
let workspaceName = name;
376+
if (!workspaceName) {
377+
const row = extractOne<{ value: string }>(db, "SELECT value FROM _metadata WHERE key = 'name'");
378+
workspaceName = row?.value || 'Imported Workspace';
379+
}
380+
381+
// Update the name in the imported DB
382+
db.run('UPDATE _metadata SET value = ?, updated_at = ? WHERE key = ?', [workspaceName, now, 'name']);
383+
384+
// Write the workspace DB to disk
385+
const data = db.export();
386+
writeFileSync(join(DATA_DIR, `${newId}.db`), Buffer.from(data));
387+
db.close();
388+
389+
// Register in master DB
390+
masterDb.run('INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)', [newId, workspaceName, now, now]);
391+
saveMasterDb();
392+
393+
return { id: newId, name: workspaceName, createdAt: now, updatedAt: now };
346394
}
347395
};
348396

frontend/src/services/api.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,26 @@ export const importExportApi = {
327327

328328
getExportUrl(workspaceId: string, format: 'csv' | 'tsv' = 'csv'): string {
329329
return `${getBasePath()}api/workspaces/${workspaceId}/export?format=${format}`;
330+
},
331+
332+
getDownloadDbUrl(workspaceId: string): string {
333+
return `${getBasePath()}api/workspaces/${workspaceId}/download-db`;
334+
},
335+
336+
async importDb(file: File, name?: string): Promise<{ workspaceId: string; workspaceName: string }> {
337+
try {
338+
const formData = new FormData();
339+
formData.append('file', file);
340+
if (name) {
341+
formData.append('name', name);
342+
}
343+
const response = await api.post<ApiResponse<{ workspaceId: string; workspaceName: string }>>('/import-db', formData, {
344+
headers: { 'Content-Type': 'multipart/form-data' }
345+
});
346+
return response.data.data!;
347+
} catch (error) {
348+
handleError(error as AxiosError<ApiResponse>);
349+
}
330350
}
331351
};
332352

@@ -399,6 +419,18 @@ export const startingPointApi = {
399419
}
400420
};
401421

422+
// Config API
423+
export const configApi = {
424+
async get(): Promise<{ allowDbUpload: boolean }> {
425+
try {
426+
const response = await api.get<ApiResponse<{ allowDbUpload: boolean }>>('/config');
427+
return response.data.data!;
428+
} catch (error) {
429+
handleError(error as AxiosError<ApiResponse>);
430+
}
431+
}
432+
};
433+
402434
// Folder API
403435
export const folderApi = {
404436
async list(workspaceId: string): Promise<Folder[]> {

frontend/src/views/HomeView.vue

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
<button class="btn btn-secondary" @click="triggerMarvaImport">
1717
Import Marva Profile
1818
</button>
19+
<button v-if="allowDbUpload" class="btn btn-secondary" @click="triggerDbImport">
20+
Load Workspace (.db)
21+
</button>
1922
<input
2023
ref="fileInput"
2124
type="file"
@@ -30,6 +33,13 @@
3033
style="display: none"
3134
@change="handleMarvaFileSelect"
3235
/>
36+
<input
37+
ref="dbFileInput"
38+
type="file"
39+
accept=".db"
40+
style="display: none"
41+
@change="handleDbFileSelect"
42+
/>
3343
</div>
3444

3545
<div v-if="loading" class="loading">Loading workspaces...</div>
@@ -69,6 +79,9 @@
6979
<button class="btn btn-secondary" @click="duplicateWorkspace(workspace)">
7080
Duplicate
7181
</button>
82+
<a class="btn btn-secondary" :href="getDownloadDbUrl(workspace.id)" title="Download workspace as .db file">
83+
Download
84+
</a>
7285
<button
7386
class="btn btn-danger"
7487
@click="confirmDelete(workspace)"
@@ -211,13 +224,41 @@
211224
</form>
212225
</div>
213226
</div>
227+
228+
<!-- DB Import Dialog -->
229+
<div v-if="showDbImportDialog" class="dialog-overlay" @click.self="cancelDbImport">
230+
<div class="dialog">
231+
<h2>Load Workspace Database</h2>
232+
<p class="dialog-info">File: {{ dbFileName }}</p>
233+
<form @submit.prevent="doDbImport">
234+
<div class="form-group">
235+
<label for="dbWorkspaceName">Workspace Name</label>
236+
<input
237+
id="dbWorkspaceName"
238+
v-model="dbWorkspaceName"
239+
type="text"
240+
placeholder="Enter workspace name (or leave blank to use name from file)"
241+
autofocus
242+
/>
243+
</div>
244+
<div class="dialog-actions">
245+
<button type="button" class="btn btn-secondary" @click="cancelDbImport">
246+
Cancel
247+
</button>
248+
<button type="submit" class="btn btn-primary" :disabled="dbImporting">
249+
{{ dbImporting ? 'Loading...' : 'Load' }}
250+
</button>
251+
</div>
252+
</form>
253+
</div>
254+
</div>
214255
</div>
215256
</template>
216257

217258
<script lang="ts">
218259
import { defineComponent, ref, computed, onMounted } from 'vue';
219260
import { useRouter } from 'vue-router';
220-
import { workspaceApi, importExportApi, marvaProfileApi } from '@/services/api';
261+
import { workspaceApi, importExportApi, marvaProfileApi, configApi } from '@/services/api';
221262
import type { Workspace } from '@/types';
222263
223264
export default defineComponent({
@@ -254,6 +295,15 @@ export default defineComponent({
254295
const marvaWorkspaceName = ref('');
255296
const marvaImporting = ref(false);
256297
298+
// DB import states
299+
const dbFileInput = ref<HTMLInputElement | null>(null);
300+
const allowDbUpload = ref(false);
301+
const showDbImportDialog = ref(false);
302+
const dbFileName = ref('');
303+
const dbWorkspaceName = ref('');
304+
const dbImporting = ref(false);
305+
const dbFileToImport = ref<File | null>(null);
306+
257307
// Computed property to sort workspaces with locked ones at the top
258308
const sortedWorkspaces = computed(() => {
259309
return [...workspaces.value].sort((a, b) => {
@@ -442,7 +492,65 @@ export default defineComponent({
442492
marvaWorkspaceName.value = '';
443493
}
444494
445-
onMounted(loadWorkspaces);
495+
function getDownloadDbUrl(workspaceId: string): string {
496+
return importExportApi.getDownloadDbUrl(workspaceId);
497+
}
498+
499+
function triggerDbImport() {
500+
dbFileInput.value?.click();
501+
}
502+
503+
function handleDbFileSelect(event: Event) {
504+
const input = event.target as HTMLInputElement;
505+
const file = input.files?.[0];
506+
if (!file) return;
507+
508+
dbFileToImport.value = file;
509+
dbFileName.value = file.name;
510+
dbWorkspaceName.value = '';
511+
showDbImportDialog.value = true;
512+
input.value = '';
513+
}
514+
515+
async function doDbImport() {
516+
if (!dbFileToImport.value) return;
517+
518+
dbImporting.value = true;
519+
try {
520+
const name = dbWorkspaceName.value.trim() || undefined;
521+
const result = await importExportApi.importDb(dbFileToImport.value, name);
522+
showDbImportDialog.value = false;
523+
dbFileToImport.value = null;
524+
dbFileName.value = '';
525+
dbWorkspaceName.value = '';
526+
router.push({ name: 'workspace', params: { id: result.workspaceId } });
527+
} catch (e) {
528+
alert((e as Error).message);
529+
} finally {
530+
dbImporting.value = false;
531+
}
532+
}
533+
534+
function cancelDbImport() {
535+
showDbImportDialog.value = false;
536+
dbFileToImport.value = null;
537+
dbFileName.value = '';
538+
dbWorkspaceName.value = '';
539+
}
540+
541+
async function loadConfig() {
542+
try {
543+
const config = await configApi.get();
544+
allowDbUpload.value = config.allowDbUpload;
545+
} catch {
546+
// Config endpoint not available, leave defaults
547+
}
548+
}
549+
550+
onMounted(() => {
551+
loadWorkspaces();
552+
loadConfig();
553+
});
446554
447555
return {
448556
workspaces,
@@ -451,6 +559,7 @@ export default defineComponent({
451559
error,
452560
fileInput,
453561
marvaFileInput,
562+
dbFileInput,
454563
showNewDialog,
455564
newWorkspaceName,
456565
showDuplicateDialog,
@@ -466,6 +575,11 @@ export default defineComponent({
466575
marvaProfiles,
467576
marvaWorkspaceName,
468577
marvaImporting,
578+
allowDbUpload,
579+
showDbImportDialog,
580+
dbFileName,
581+
dbWorkspaceName,
582+
dbImporting,
469583
loadWorkspaces,
470584
createWorkspace,
471585
openWorkspace,
@@ -481,7 +595,12 @@ export default defineComponent({
481595
triggerMarvaImport,
482596
handleMarvaFileSelect,
483597
doMarvaImport,
484-
cancelMarvaImport
598+
cancelMarvaImport,
599+
getDownloadDbUrl,
600+
triggerDbImport,
601+
handleDbFileSelect,
602+
doDbImport,
603+
cancelDbImport
485604
};
486605
}
487606
});

0 commit comments

Comments
 (0)