diff --git a/backend/package-lock.json b/backend/package-lock.json index d873f9d..da05782 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "bcrypt": "^6.0.0", "better-sqlite3": "^11.0.0", "fastify": "^5.9.0", + "jszip": "^3.10.1", "y-leveldb": "^0.2.0", "y-protocols": "^1.0.7", "yjs": "^13.6.0" @@ -1503,6 +1504,11 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1886,6 +1892,11 @@ } ] }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1926,6 +1937,11 @@ "node": ">=4" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/isomorphic.js": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", @@ -1958,6 +1974,44 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/level": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/level/-/level-8.0.1.tgz", @@ -2135,6 +2189,14 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -2587,6 +2649,11 @@ "wrappy": "1" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -2722,6 +2789,11 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -2969,6 +3041,11 @@ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 3e94c31..dc022ea 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "bcrypt": "^6.0.0", "better-sqlite3": "^11.0.0", "fastify": "^5.9.0", + "jszip": "^3.10.1", "y-leveldb": "^0.2.0", "y-protocols": "^1.0.7", "yjs": "^13.6.0" diff --git a/backend/src/routes/globalBlocks.ts b/backend/src/routes/globalBlocks.ts index 000253c..720003c 100644 --- a/backend/src/routes/globalBlocks.ts +++ b/backend/src/routes/globalBlocks.ts @@ -6,6 +6,7 @@ import type { DatabaseInterface } from '../database/DatabaseInterface.js'; import { requireAuth } from '../auth/authMiddleware.js'; import type { AuthService } from '../auth/AuthService.js'; import { validateName, validateIdParam } from '../utils/validation.js'; +import JSZip from 'jszip'; export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { // ─── Global Block Folders ────────────────────────────── @@ -229,4 +230,132 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database } return reply.code(201).send({ imported_blocks: imported, imported_folders: folderIdMap.size }); }); + + // ─── Task F7: .wcadlib als echtes ZIP-Paket ────────────── + + // ZIP-Export: manifest.json + blocks.json + thumbs/ (SVG je Block) + fastify.get('/api/global-blocks/export-zip', async (request, reply) => { + if (!requireAuth(request, reply, authService)) return; + const folders = db.listGlobalFolders(); + const blocks = db.listGlobalBlocks(); + + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ + format: 'wcadlib-zip', + version: 1, + exported_at: new Date().toISOString(), + block_count: blocks.length, + folder_count: folders.length, + })); + + const blockEntries = [] as Array<{ + name: string; + folder_id: string | null; + block_data: string; + thumbnail: string | null; + }>; + for (const b of blocks) { + let thumbnail: string | null = null; + if (b.svg_data) { + thumbnail = `thumbs/${b.id}.svg`; + zip.file(thumbnail, b.svg_data); + } + blockEntries.push({ + name: b.name, + folder_id: b.folder_id, + block_data: b.block_data, + thumbnail, + }); + } + zip.file('blocks.json', JSON.stringify({ + folders: folders.map((f) => ({ id: f.id, name: f.name, parent_id: f.parent_id })), + blocks: blockEntries, + })); + + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + reply.header('content-type', 'application/zip'); + reply.header('content-disposition', 'attachment; filename="library.wcadlib.zip"'); + return reply.send(buffer); + }); + + // ZIP-Import: .wcadlib-ZIP einspielen (Ordner-Mapping, Blöcke, Thumbnails) + fastify.post('/api/global-blocks/import-zip', async (request, reply) => { + if (!requireAuth(request, reply, authService)) return; + const body = request.body; + if (!Buffer.isBuffer(body) || body.length === 0) { + return reply.code(400).send({ error: 'ZIP body fehlt oder leer' }); + } + + let zip: JSZip; + try { + zip = await JSZip.loadAsync(body); + } catch { + return reply.code(400).send({ error: 'Kein valides ZIP-Format' }); + } + + const blocksFile = zip.file('blocks.json'); + if (!blocksFile) { + return reply.code(400).send({ error: 'blocks.json fehlt im ZIP' }); + } + + let pkg: { folders?: Array<{ id: string; name: string; parent_id: string | null }>; blocks?: Array<{ name: string; folder_id: string | null; block_data: string; thumbnail?: string | null }> }; + try { + pkg = JSON.parse(await blocksFile.async('string')); + } catch { + return reply.code(400).send({ error: 'blocks.json ist kein valides JSON' }); + } + if (!Array.isArray(pkg.blocks)) { + return reply.code(400).send({ error: 'blocks.json enthaelt kein blocks-Array' }); + } + + // Optional: manifest pruefen (falls vorhanden) + const manifestFile = zip.file('manifest.json'); + if (manifestFile) { + try { + const manifest = JSON.parse(await manifestFile.async('string')); + if (manifest.format && manifest.format !== 'wcadlib-zip') { + return reply.code(400).send({ error: `Unbekanntes Paket-Format: ${manifest.format}` }); + } + } catch { + return reply.code(400).send({ error: 'manifest.json ist kein valides JSON' }); + } + } + + // Ordner mit ID-Mapping importieren (Eltern zuerst): + const folderIdMap = new Map(); + const sortedFolders = [...(pkg.folders ?? [])].sort((a, b) => { + if (a.parent_id === null) return -1; + if (b.parent_id === null) return 1; + if (a.parent_id === b.parent_id) return 0; + const aRes = folderIdMap.has(a.parent_id!) ? -1 : 0; + return aRes; + }); + for (const f of sortedFolders) { + const newParent = f.parent_id ? folderIdMap.get(f.parent_id) ?? null : null; + const created = db.createGlobalFolder({ name: f.name, parent_id: newParent }); + folderIdMap.set(f.id, created.id); + } + + // Blöcke importieren; Thumbnail aus thumbs/ lesen wenn referenziert: + let imported = 0; + for (const b of pkg.blocks) { + let svgData: string | null = null; + if (b.thumbnail) { + const thumbFile = zip.file(b.thumbnail); + if (thumbFile) { + try { svgData = await thumbFile.async('string'); } catch { svgData = null; } + } + } + const newFolderId = b.folder_id ? folderIdMap.get(b.folder_id) ?? null : null; + db.createGlobalBlock({ + name: b.name, + folder_id: newFolderId, + block_data: b.block_data, + svg_data: svgData, + }); + imported++; + } + + return reply.code(201).send({ imported_blocks: imported, imported_folders: folderIdMap.size }); + }); } diff --git a/backend/src/server.ts b/backend/src/server.ts index 0abe88a..b862de3 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -22,6 +22,11 @@ export interface ServerOptions { export async function createServer(opts: ServerOptions) { const fastify = Fastify({ logger: true }); + // ─── Task F7: binäre Body für .wcadlib ZIP-Import ─── + fastify.addContentTypeParser('application/zip', { parseAs: 'buffer' }, (_req, body, done) => done(null, body)); + fastify.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_req, body, done) => done(null, body)); + fastify.addContentTypeParser('application/x-zip-compressed', { parseAs: 'buffer' }, (_req, body, done) => done(null, body)); + // CORS — restrictive origin list await fastify.register(cors, { origin: (origin: string | undefined, cb: (err: Error | null, allow: boolean) => void) => { diff --git a/backend/tests/globalBlocksZip.test.ts b/backend/tests/globalBlocksZip.test.ts new file mode 100644 index 0000000..98c1124 --- /dev/null +++ b/backend/tests/globalBlocksZip.test.ts @@ -0,0 +1,164 @@ +/** + * Task F7 – .wcadlib ZIP-Paket Export/Import (Backend). + * + * ZIP-Struktur: manifest.json (format/version/exported_at), blocks.json + * (Ordner + Blöcke), thumbs/ (SVG-Thumbnails je Block-ID). Export liefert + * application/zip; Import parst das ZIP und spielt Ordner-Hierarchie + + * Blöcke mit ID-Mapping ein. Invalides ZIP → 400. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import JSZip from 'jszip'; +import { SqliteAdapter } from '../src/database/SqliteAdapter.js'; +import { createServer } from '../src/server.js'; + +describe('Task F7: .wcadlib ZIP Export/Import', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let token: string; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + + const regRes = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: 'f7-zip@example.com', password: 'Password123!', name: 'F7 Zip', role: 'admin' }, + }); + token = JSON.parse(regRes.body).session.token; + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + async function createFixture(): Promise<{ folderId: string; blockId: string }> { + const folderRes = await app.inject({ + method: 'POST', + url: '/api/global-folders', + headers: { authorization: `Bearer ${token}` }, + payload: { name: 'Event Set', parent_id: null }, + }); + const folderId = JSON.parse(folderRes.body).id; + const blockRes = await app.inject({ + method: 'POST', + url: '/api/global-blocks', + headers: { authorization: `Bearer ${token}` }, + payload: { + name: 'Stuhl-Reihe', + folder_id: folderId, + block_data: JSON.stringify([{ id: 'e1', type: 'chair', x: 0, y: 0, width: 45, height: 45, properties: {} }]), + svg_data: '', + }, + }); + const blockId = JSON.parse(blockRes.body).id; + return { folderId, blockId }; + } + + it('GET /api/global-blocks/export-zip liefert ZIP mit manifest.json, blocks.json und thumbs/', async () => { + const { blockId } = await createFixture(); + const res = await app.inject({ + method: 'GET', + url: '/api/global-blocks/export-zip', + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('application/zip'); + + const zip = await JSZip.loadAsync(res.rawPayload ?? res.body); + expect(zip.file('manifest.json')).toBeDefined(); + expect(zip.file('blocks.json')).toBeDefined(); + + const manifest = JSON.parse(await zip.file('manifest.json')!.async('string')); + expect(manifest.format).toBe('wcadlib-zip'); + expect(manifest.version).toBe(1); + expect(typeof manifest.exported_at).toBe('string'); + + const blocks = JSON.parse(await zip.file('blocks.json')!.async('string')); + expect(blocks.folders.length).toBe(1); + expect(blocks.folders[0].name).toBe('Event Set'); + expect(blocks.blocks.length).toBeGreaterThanOrEqual(1); + const chairBlock = blocks.blocks.find((b: { name: string }) => b.name === 'Stuhl-Reihe'); + expect(chairBlock).toBeDefined(); + expect(chairBlock.thumbnail).toBe(`thumbs/${blockId}.svg`); + + const thumb = zip.file(`thumbs/${blockId}.svg`); + expect(thumb).toBeDefined(); + expect(await thumb!.async('string')).toContain(' { + const res = await app.inject({ method: 'GET', url: '/api/global-blocks/export-zip' }); + expect(res.statusCode).toBe(401); + }); + + it('POST /api/global-blocks/import-zip spielt ZIP ein (Ordner-Mapping + Blöcke + Thumb-Referenz erhalten)', async () => { + const { blockId } = await createFixture(); + const exportRes = await app.inject({ + method: 'GET', + url: '/api/global-blocks/export-zip', + headers: { authorization: `Bearer ${token}` }, + }); + expect(exportRes.statusCode).toBe(200); + const zipBuffer = exportRes.rawPayload ?? exportRes.body; + + const importRes = await app.inject({ + method: 'POST', + url: '/api/global-blocks/import-zip', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/zip', + }, + payload: zipBuffer, + }); + expect(importRes.statusCode).toBe(201); + const body = JSON.parse(importRes.body); + expect(body.imported_blocks).toBeGreaterThanOrEqual(1); + expect(body.imported_folders).toBeGreaterThanOrEqual(1); + + // Verifizieren: importierter Block existiert mit Thumb-Daten + const blocksRes = await app.inject({ + method: 'GET', + url: '/api/global-blocks', + headers: { authorization: `Bearer ${token}` }, + }); + const allBlocks = JSON.parse(blocksRes.body); + const imported = allBlocks.filter((b: { name: string }) => b.name === 'Stuhl-Reihe'); + expect(imported.length).toBeGreaterThanOrEqual(2); // Original + Import + expect(imported.every((b: { svg_data: string | null }) => b.svg_data !== null)).toBe(true); + }); + + it('Import mit Müll-Payload → 400 (kein valides ZIP)', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/global-blocks/import-zip', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/zip', + }, + payload: Buffer.from('dies ist kein zip'), + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toContain('ZIP'); + }); + + it('Import-ZIP ohne blocks.json → 400', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ format: 'wcadlib-zip', version: 1 })); + const buf = await zip.generateAsync({ type: 'nodebuffer' }); + const res = await app.inject({ + method: 'POST', + url: '/api/global-blocks/import-zip', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/zip', + }, + payload: buf, + }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/frontend/src/components/BlockLibraryTree.tsx b/frontend/src/components/BlockLibraryTree.tsx index c7d7dc3..72d600d 100644 --- a/frontend/src/components/BlockLibraryTree.tsx +++ b/frontend/src/components/BlockLibraryTree.tsx @@ -12,6 +12,8 @@ import { setGlobalBlockFavorite, exportLibrary, importLibrary, + exportLibraryZip, + importLibraryZip, } from '../services/api'; import { getActiveDocument } from '../kernel/document/documentService'; import { generateBlockSvg } from '../utils/blockThumbnail'; @@ -45,6 +47,7 @@ const BlockLibraryTree: React.FC = ({ token, onBlockDragS const [loading, setLoading] = useState(true); const fileInputRef = useRef(null); const wcadInputRef = useRef(null); + const zipInputRef = useRef(null); const [dragOverFolderId, setDragOverFolderId] = useState(null); // ─── Task F2: Suche, Favoriten, Thumbnail-Grid ─────────── @@ -113,6 +116,34 @@ const BlockLibraryTree: React.FC = ({ token, onBlockDragS // ─── Task F1: Library-Paket Export/Import ──────────────── + const handleZipExport = useCallback(async () => { + try { + const blob = await exportLibraryZip(token); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `web-cad-library-${new Date().toISOString().slice(0, 10)}.wcadlib.zip`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + alert(`ZIP-Export fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`); + } + }, [token]); + + const handleZipImport = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const result = await importLibraryZip(token, file); + alert(`Bibliothek importiert: ${result.imported_blocks} Blöcke, ${result.imported_folders} Ordner`); + await loadData(); + } catch (err) { + alert(`ZIP-Import fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`); + } finally { + e.target.value = ''; + } + }, [token, loadData]); + const handleLibraryExport = useCallback(async () => { try { const pkg = await exportLibrary(token); @@ -515,6 +546,20 @@ const BlockLibraryTree: React.FC = ({ token, onBlockDragS > + + +
{ + const res = await fetch(`${API_BASE}/api/global-blocks/export-zip`, { + headers: authHeaders(token), + }); + if (!res.ok) throw new Error(`Failed to export library zip: ${res.status}`); + return res.blob(); +} + +/** .wcadlib-ZIP einspielen (Ordner-Mapping, Blöcke, Thumbnails). */ +export async function importLibraryZip(token: string, file: Blob): Promise<{ imported_blocks: number; imported_folders: number }> { + const res = await fetch(`${API_BASE}/api/global-blocks/import-zip`, { + method: 'POST', + headers: { ...authHeaders(token), 'content-type': 'application/zip' }, + body: file, + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`Failed to import library zip: ${res.status} ${err}`); + } + return res.json(); +}