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",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^11.0.0",
|
||||||
"fastify": "^5.9.0",
|
"fastify": "^5.9.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"y-leveldb": "^0.2.0",
|
"y-leveldb": "^0.2.0",
|
||||||
"y-protocols": "^1.0.7",
|
"y-protocols": "^1.0.7",
|
||||||
"yjs": "^13.6.0"
|
"yjs": "^13.6.0"
|
||||||
@@ -1503,6 +1504,11 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -1926,6 +1937,11 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/isomorphic.js": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
|
"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": {
|
"node_modules/level": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/level/-/level-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/level/-/level-8.0.1.tgz",
|
||||||
@@ -2135,6 +2189,14 @@
|
|||||||
"url": "https://github.com/sponsors/dmonad"
|
"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": {
|
"node_modules/light-my-request": {
|
||||||
"version": "6.6.0",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||||
@@ -2587,6 +2649,11 @@
|
|||||||
"wrappy": "1"
|
"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": {
|
"node_modules/path-scurry": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||||
@@ -2722,6 +2789,11 @@
|
|||||||
"node": ">= 0.6.0"
|
"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": {
|
"node_modules/process-warning": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
"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": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^11.0.0",
|
||||||
"fastify": "^5.9.0",
|
"fastify": "^5.9.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"y-leveldb": "^0.2.0",
|
"y-leveldb": "^0.2.0",
|
||||||
"y-protocols": "^1.0.7",
|
"y-protocols": "^1.0.7",
|
||||||
"yjs": "^13.6.0"
|
"yjs": "^13.6.0"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { DatabaseInterface } from '../database/DatabaseInterface.js';
|
|||||||
import { requireAuth } from '../auth/authMiddleware.js';
|
import { requireAuth } from '../auth/authMiddleware.js';
|
||||||
import type { AuthService } from '../auth/AuthService.js';
|
import type { AuthService } from '../auth/AuthService.js';
|
||||||
import { validateName, validateIdParam } from '../utils/validation.js';
|
import { validateName, validateIdParam } from '../utils/validation.js';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
|
||||||
export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) {
|
||||||
// ─── Global Block Folders ──────────────────────────────
|
// ─── 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 });
|
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) {
|
export async function createServer(opts: ServerOptions) {
|
||||||
const fastify = Fastify({ logger: true });
|
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
|
// CORS — restrictive origin list
|
||||||
await fastify.register(cors, {
|
await fastify.register(cors, {
|
||||||
origin: (origin: string | undefined, cb: (err: Error | null, allow: boolean) => void) => {
|
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,
|
setGlobalBlockFavorite,
|
||||||
exportLibrary,
|
exportLibrary,
|
||||||
importLibrary,
|
importLibrary,
|
||||||
|
exportLibraryZip,
|
||||||
|
importLibraryZip,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import { getActiveDocument } from '../kernel/document/documentService';
|
import { getActiveDocument } from '../kernel/document/documentService';
|
||||||
import { generateBlockSvg } from '../utils/blockThumbnail';
|
import { generateBlockSvg } from '../utils/blockThumbnail';
|
||||||
@@ -45,6 +47,7 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const wcadInputRef = useRef<HTMLInputElement>(null);
|
const wcadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const zipInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
|
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
|
||||||
|
|
||||||
// ─── Task F2: Suche, Favoriten, Thumbnail-Grid ───────────
|
// ─── Task F2: Suche, Favoriten, Thumbnail-Grid ───────────
|
||||||
@@ -113,6 +116,34 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
|||||||
|
|
||||||
// ─── Task F1: Library-Paket Export/Import ────────────────
|
// ─── 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 () => {
|
const handleLibraryExport = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const pkg = await exportLibrary(token);
|
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>
|
<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>
|
||||||
|
<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
|
<button
|
||||||
className="global-lib-add-btn"
|
className="global-lib-add-btn"
|
||||||
title="SVG in globale Bibliothek importieren"
|
title="SVG in globale Bibliothek importieren"
|
||||||
@@ -524,6 +569,13 @@ const BlockLibraryTree: React.FC<BlockLibraryTreeProps> = ({ token, onBlockDragS
|
|||||||
</button>
|
</button>
|
||||||
<input ref={fileInputRef} type="file" accept=".svg,image/svg+xml" style={{ display: 'none' }} onChange={handleSvgImport} />
|
<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 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>
|
||||||
<div className="global-lib-toolbar">
|
<div className="global-lib-toolbar">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -854,3 +854,28 @@ export async function importLibrary(token: string, pkg: { format: string; versio
|
|||||||
}
|
}
|
||||||
return res.json();
|
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