Compare commits

...

2 Commits

8 changed files with 457 additions and 3 deletions
+4 -3
View File
@@ -627,7 +627,7 @@ Kurz-Tasks (je gleiche Struktur: implementieren im passenden builtin-Paket + Wor
- [x] F4 LibraryProviderExtension anbinden: builtin-catalog JSONs (event/architecture/landscape Startersets je ~15-30 Blöcke), user-library, global-library *(2026-08-28: builtin-library-Plugin mit 3 Katalogen je 15 Blöcke, user/global-Provider als Fabriken, LibraryProviderPanel über existing Drop-Channel; Log F4)*
- [x] F5 DXF Parser erweitern: ELLIPSE, SPLINE(grad≤3→Polyline approx), MTEXT, POINT, HATCH(basic solid/lines), ATTDEF/ATTR, INSERT→Blockinstanz; Fehlerreport (Liste skipped entities) *(2026-08-29: alle 6 Typen + MINSERT-Arrays + skippedEntities-Report; HATCH defensiv mapping-fähig da dxf-parser 1.1.x echte HATCH-Entities noch nicht durchreicht; Log F5)*
- [x] F6 DXF Writer erweitern: BLOCKS+INSERTS, ATTR; Version AC1009 (R12) + AC1015 Ziel *(2026-08-29: BLOCKS-Sektion + INSERT Name-Mapping/Sanitizing, ATTDEF in Blockdefinitionen, ATTRIB/SEQEND bei attrValues, AC1009-Default/AC1015-Option, R12-korrektes POLYLINE+VERTEX+SEQEND; Roundtrip-Tests deckten 3 latente Konventions-Bugs auf und fixten sie; Log F6)*
- [ ] F7 .wcadlib Paket (zip: manifest.json, blocks.json, thumbs/) Import+Export (Backend endpoint + UI Dialog)
- [x] F7 .wcadlib Paket (zip: manifest.json, blocks.json, thumbs/) Import+Export (Backend endpoint + UI Dialog) *(2026-08-29: jszip export-zip/import-zip Endpoints + Fastify ContentType-Parser für application/zip, Frontend-ZIP-Buttons; Log F7)*
- [ ] F8 SVG-Import als Block (svg→Path→Polyline approximation, simple subset: path/rect/circle/ellipse/line/polygon)
## Phase G — Layouts & Plot
@@ -667,6 +667,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|---|---|---|---|---|---|
| 2026-08-29 | F7 | .wcadlib als echtes ZIP-Paket: (1) Backend GET /api/global-blocks/export-zip — JSZip generiert manifest.json (format wcadlib-zip v1, exported_at, counts), blocks.json (Ordner + Blöcke mit thumbnail-Referenz) und thumbs/<blockId>.svg je SVG-Thumbnail; content-type application/zip + content-disposition attachment; (2) POST /api/global-blocks/import-zip — JSZip.loadAsync, Validierung (kein ZIP → 400, blocks.json fehlt → 400, invalides JSON → 400, manifest.format≠wcadlib-zip → 400), Ordner-Import mit Eltern-zuerst-Sortierung + ID-Mapping, Thumbnail aus thumbs/ referenzgelesen, 201 mit imported_blocks/imported_folders; (3) Fastify addContentTypeParser application/zip + octet-stream + x-zip-compressed als Buffer (Server-Global); (4) Frontend exportLibraryZip/importLibraryZip in api.ts (Blob-Download, Upload mit content-type application/zip), zwei neue Toolbar-Buttons (ZIP-Box-Icons) + hidden file-input in BlockLibraryTree; F1-JSON-Variante bleibt parallel bestehen | BE: 272/272 Tests (+5 globalBlocksZip.test.ts), BE tsc 0; FE: 532/532, FE tsc 0, build OK | d311820 | F7 COMPLETE — ZIP-Roundtrip mit echten write→read-Roundtrip-Tests (Export→Import→DB-Verifikation) |
| 2026-08-29 | F6 | DXF-Writer erweitert: (1) BLOCKS-Sektion mit sanitierten Blocknamen (Leerzeichen→Unterstrich, Kollisions-Dedup, 0 reserviert) + Name-Map blockId→DXF-Name; INSERT referenziert gemappten Namen mit scaleX/scaleY (Fallback scale) + Rotation in Grad direkt; (2) ATTDEF-Emission in Blockdefinitionen (text-Elemente mit attdef:true+tag → ATTDEF mit tag/prompt/textHeight); ATTRIB/SEQEND + Gruppe-66 an Instanzen mit attrValues; (3) DXFWriterOptions.version AC1009(R12)-Default/AC1015 im Header; (4) R12-korrektes POLYLINE+VERTEX+SEQEND statt LWPOLYLINE (erst ab R13) inkl. rect-Zentrums→Ecken-Konvertierung und BBox-Fallbacks; **ROUNDTRIP write→parse aufgedeckte 3 latente Bugs, alle gefixt**: Writer circle-Center war x+width/2 obwohl beide Renderer el.x/el.y ALS Zentrum lesen (exportierte Kreise wanderten); Parser CIRCLE/ARC mappten auf min-corner statt Zentrum (importierte Kreise verschoben); dxf-parser liefert ARC-Winkel intern in Radiant, App nutzt Grad → toDeg-Fix im Parser-ARC-Zweig (importierte Bögen falsch); block_instance ohne Blockdef → BBox-Fallback statt blindem INSERT | Frontend: 532/532 Tests (+13 dxfWriterF6.test.ts), tsc 0, build OK | 75de6dd | F6 COMPLETE — DXF-Roundtrip (F5-Import + F6-Export) Ende-zu-Ende mit echten Roundtrip-Tests geschlossen; nächster Task: F7 (.wcadlib ZIP-Paket) |
| 2026-08-29 | F5 | DXF-Parser erweitert: (1) ELLIPSE → Polyline-Approx (64 Segmente Voll-Ellipse, partiell proportional, Rotation über majorAxisEndPoint, außerhalb 0..2π normalisiert); (2) SPLINE Grad≤3 → Cox-de-Boor-Approximation (40 Punkte, Default-Knotenvektor wenn knotValues fehlen, Grad>3 → skip mit Report-Grund); (3) MTEXT → text-Element mit Formatcode-Cleanup (\P→Zeilenumbroch, Inline-Codes \A1;/\H2x; entfernt); (4) POINT → Markerkreis r=1.5 mit point-Marker; (5) ATTDEF → text tag=Wert mit tag/prompt/attdef-Metadaten; (6) HATCH → polygon mit hatchPattern (forward-kompatibel via Entity-Injektion getestet, da dxf-parser 1.1.x echte HATCH nicht durchreicht); INSERT erweitert: MINSERT-Arrays (columnCount/rowCount/spacing) via neuem expandInsert, scaleX/scaleY getrennt; DXFImportResult um skippedEntities {type,reason} erweitert, skipReason-Fabrik je Typ, LINE-BBox-Form vereinfacht (minXY+abs); WARNUNG dokumentiert: dxf-parser verschluckt völlig unbekannte Typen (FOOBAR-Report-Test ersetzt durch validen Grad-4-SPLINE, der die Library passiert und gemappt skippt) | Frontend: 519/519 Tests (+16 dxfParserF5.test.ts), tsc 0, build OK | 6857694 | F5 COMPLETE; Zwischenfall: erster Suite-Lauf hing in dxf-parser-Endlosschleife bei malformed LINE-Test-STRING (nicht unserem Code) — Test auf validen SPLINE-Grad-4-Fall umgestellt, Escape-Fehler in Testdatei selbst erkannt und repariert; nächster Task: F6 (DXF Writer erweitern) |
| 2026-08-28 | F4 | LibraryProviderExtension angeschlossen: (1) builtinLibraryPlugin (V2, category library, enabledByDefault) mit builtin-catalog-JSONs event/architecture/landscape je 15 Blöcken (Stühle, Tische, Bühnen, Türen, Fenster, Bäume u.a.) als statische Startersets unter plugins/builtin/library/catalogs/; (2) makeUserLibraryProvider (zeichnungsscoped Drawing-Blöcke, elements_json-Parsing inkl. Wrapped-Form); (3) makeGlobalLibraryProvider (global-blocks API gegen Token, Ordnerhierarchie); LibBlock um optionales payload-Feld erweitert (JSON-String CADElement-Array) — Panel nutzt den bestehenden text/global-block-data Drop-Kanal, CanvasArea unverändert; LibraryProviderPanel-Komponente (Sektionen je Ordner, SVG-Thumbnails via generateBlockSvg mit Cache, DragSource-Buttons); RightSidebar-Memo vereint Registry-Provider + builtin + user + global mit Dedup; Doppel-ID a2c im architekt-Katalog beim Schreiben selbst korrigiert (a2d); Duplikat-Provider via pluginRegistry UND direktem builtinCatalogProvider-Fallback ausgeschlossen (Set-Dedup in RightSidebar) | Frontend: 503/503 Tests (+8 libraryProviders.test.tsx), tsc 0, build OK | 2efbbf9 | F4 COMPLETE — drei Bibliotheksquellen Ende-zu-Ende: builtin/user/global mit DragDrop ins Canvas; nächster Task: F5 (DXF Parser erweitern) |
@@ -728,5 +729,5 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
---
*Nächster offener Schritt: **F7** (.wcadlib als echtes ZIP-Paket: manifest.json, blocks.json, thumbs/ — Backend-Endpoint + UI-Dialog). Alternativ: F8 (SVG-Import als Block), Legacy-Entsorgung, oder Phase G (Layouts/PDF).*
*Status 2026-08-29: Phasen B+A+C+D+E+F1F6 komplett (DXF-Roundtrip geschlossen), BUG-1-Kollisionsklasse geschlossen. FE 532/532 Tests, BE 267/267, tsc/Build grün. Alle Commits auf Forgejo main.*
*Nächster offener Schritt: **F8** (SVG-Import als Block: svg→Path→Polyline-Approximation, simples Subset path/rect/circle/ellipse/line/polygon). Danach: Phase-D-Diskrepanz klären, E7, Phase G/H/I.*
*Status 2026-08-29: Phasen B+A+C+D+E+F1F7 komplett (DXF-Roundtrip + ZIP-Paket geschlossen), BUG-1-Kollisionsklasse geschlossen. FE 532/532 Tests, BE 272/272, tsc/Build grün. Alle Commits auf Forgejo main.*
+77
View File
@@ -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",
+1
View File
@@ -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"
+129
View File
@@ -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 });
});
}
+5
View File
@@ -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) => {
+164
View File
@@ -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
+25
View File
@@ -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();
}