task(F7): wcadlib as real zip package - jszip export-zip/import-zip endpoints with manifest/blocks/thumbs, frontend zip buttons
This commit is contained in:
Generated
+77
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string, string>();
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>',
|
||||
},
|
||||
});
|
||||
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('<svg');
|
||||
});
|
||||
|
||||
it('Export ohne Auth → 401', async () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
||||
const [loading, setLoading] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const wcadInputRef = useRef<HTMLInputElement>(null);
|
||||
const zipInputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
|
||||
|
||||
// ─── Task F2: Suche, Favoriten, Thumbnail-Grid ───────────
|
||||
@@ -113,6 +116,34 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ 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<HTMLInputElement>) => {
|
||||
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<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<button
|
||||
className="global-lib-add-btn"
|
||||
title="Bibliothek als ZIP-Paket exportieren (Task F7)"
|
||||
onClick={handleZipExport}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 8v13H3V8"/><rect x="1" y="3" width="22" height="5"/><path d="M10 12h4"/></svg>
|
||||
</button>
|
||||
<button
|
||||
className="global-lib-add-btn"
|
||||
title=".wcadlib-ZIP-Paket importieren (Task F7)"
|
||||
onClick={() => zipInputRef.current?.click()}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 8v13H3V8"/><rect x="1" y="3" width="22" height="5"/><path d="M15 12l-3-3-3 3"/><path d="M12 9v8"/></svg>
|
||||
</button>
|
||||
<button
|
||||
className="global-lib-add-btn"
|
||||
title="SVG in globale Bibliothek importieren"
|
||||
@@ -524,6 +569,13 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".svg,image/svg+xml" style={{ display: 'none' }} onChange={handleSvgImport} />
|
||||
<input ref={wcadInputRef} type="file" accept=".wcadlib,application/json" style={{ display: 'none' }} onChange={handleWcadFile} />
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,.wcadlib.zip,application/zip"
|
||||
ref={zipInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleZipImport}
|
||||
/>
|
||||
</div>
|
||||
<div className="global-lib-toolbar">
|
||||
<input
|
||||
|
||||
@@ -854,3 +854,28 @@ export async function importLibrary(token: string, pkg: { format: string; versio
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Task F7: .wcadlib als echtes ZIP-Paket ──────────────
|
||||
|
||||
/** Library als ZIP-Paket (manifest.json, blocks.json, thumbs/) herunterladen. */
|
||||
export async function exportLibraryZip(token: string): Promise<Blob> {
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user