@imenam/mcp-design 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +67 -5
  2. package/dist/shared/feedback.d.ts +128 -0
  3. package/dist/shared/feedback.js +165 -0
  4. package/dist/shared/node-defaults.js +6 -1
  5. package/dist/shared/node-fields.d.ts +2 -0
  6. package/dist/shared/node-fields.js +5 -1
  7. package/dist/shared/render.d.ts +30 -5
  8. package/dist/shared/render.js +58 -11
  9. package/dist/shared/schemas/design.schema.d.ts +36 -1
  10. package/dist/shared/schemas/design.schema.js +10 -1
  11. package/dist/shared/schemas/feedback.schema.d.ts +191 -0
  12. package/dist/shared/schemas/feedback.schema.js +87 -0
  13. package/dist/shared/tree.d.ts +2 -0
  14. package/dist/shared/tree.js +4 -0
  15. package/dist/src/core/design-store.d.ts +4 -5
  16. package/dist/src/core/design-store.js +7 -56
  17. package/dist/src/core/export.d.ts +2 -1
  18. package/dist/src/core/export.js +35 -7
  19. package/dist/src/core/feedback-store.d.ts +73 -0
  20. package/dist/src/core/feedback-store.js +308 -0
  21. package/dist/src/core/file-lock.d.ts +8 -0
  22. package/dist/src/core/file-lock.js +61 -0
  23. package/dist/src/core/image-store.d.ts +45 -0
  24. package/dist/src/core/image-store.js +175 -0
  25. package/dist/src/core/node-factory.js +16 -0
  26. package/dist/src/core/screenshot.d.ts +6 -0
  27. package/dist/src/core/screenshot.js +32 -3
  28. package/dist/src/gui-worker.js +113 -1
  29. package/dist/src/index.js +231 -3
  30. package/dist/src/tools/add-image.d.ts +3 -0
  31. package/dist/src/tools/add-image.js +65 -0
  32. package/dist/src/tools/add-nodes.js +2 -0
  33. package/dist/src/tools/delete-design.d.ts +3 -1
  34. package/dist/src/tools/delete-design.js +4 -1
  35. package/dist/src/tools/export-design.js +1 -1
  36. package/dist/src/tools/get-feedback.d.ts +3 -0
  37. package/dist/src/tools/get-feedback.js +62 -0
  38. package/dist/src/tools/mark-feedback-processed.d.ts +8 -0
  39. package/dist/src/tools/mark-feedback-processed.js +29 -0
  40. package/dist/src/tools/screenshot-design.js +1 -0
  41. package/dist/src/tools/update-nodes.js +4 -1
  42. package/dist/src/version.d.ts +1 -1
  43. package/dist/src/version.js +16 -2
  44. package/gui/dist/assets/index-DNUHDrGi.css +1 -0
  45. package/gui/dist/assets/index-uk5sDkVA.js +15 -0
  46. package/gui/dist/assets/{search-CSwiX0lv.js → search-jSqY2fPS.js} +1 -1
  47. package/gui/dist/index.html +2 -2
  48. package/package.json +2 -3
  49. package/gui/dist/assets/index-DBZNXgF_.js +0 -10
  50. package/gui/dist/assets/index-PQqxUQLx.css +0 -1
  51. package/scripts/generate-version.js +0 -20
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Médiathèque des designs : `<dataDir>/images/<nom>`.
3
+ *
4
+ * Un fichier est nommé d'après l'empreinte de son contenu : importer deux fois
5
+ * la même image ne la stocke qu'une fois, et un nom ne désigne jamais deux
6
+ * contenus différents — ce qui permet de le servir avec un cache permanent.
7
+ * Les images sont partagées par toutes les planches du dossier de données.
8
+ */
9
+ /** Taille maximale d'une image importée. */
10
+ export declare const MAX_IMAGE_BYTES: number;
11
+ export declare const IMAGE_FORMATS: {
12
+ readonly png: "image/png";
13
+ readonly jpg: "image/jpeg";
14
+ readonly gif: "image/gif";
15
+ readonly webp: "image/webp";
16
+ readonly svg: "image/svg+xml";
17
+ };
18
+ export type ImageFormat = keyof typeof IMAGE_FORMATS;
19
+ export interface StoredImage {
20
+ /** Nom du fichier dans la médiathèque, à porter dans le champ `image` d'un noeud. */
21
+ name: string;
22
+ format: ImageFormat;
23
+ mimeType: string;
24
+ bytes: number;
25
+ /** Dimensions naturelles, en pixels. */
26
+ width: number;
27
+ height: number;
28
+ }
29
+ export declare class ImageStore {
30
+ private readonly dir;
31
+ constructor(dataDir: string);
32
+ getDir(): string;
33
+ /** Enregistre une image et renvoie sa fiche ; une image déjà présente n'est pas réécrite. */
34
+ save(data: Buffer): StoredImage;
35
+ private fileOf;
36
+ has(name: string): boolean;
37
+ /** Échoue si un noeud désigne une image absente de la médiathèque. */
38
+ requireStored(name: string): void;
39
+ read(name: string): {
40
+ data: Buffer;
41
+ mimeType: string;
42
+ };
43
+ /** L'image en data URI, pour un SVG ou un HTML qui doit se suffire à lui-même. */
44
+ dataUri(name: string): string;
45
+ }
@@ -0,0 +1,175 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { DesignError } from "./node-factory.js";
5
+ /**
6
+ * Médiathèque des designs : `<dataDir>/images/<nom>`.
7
+ *
8
+ * Un fichier est nommé d'après l'empreinte de son contenu : importer deux fois
9
+ * la même image ne la stocke qu'une fois, et un nom ne désigne jamais deux
10
+ * contenus différents — ce qui permet de le servir avec un cache permanent.
11
+ * Les images sont partagées par toutes les planches du dossier de données.
12
+ */
13
+ /** Taille maximale d'une image importée. */
14
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
15
+ export const IMAGE_FORMATS = {
16
+ png: "image/png",
17
+ jpg: "image/jpeg",
18
+ gif: "image/gif",
19
+ webp: "image/webp",
20
+ svg: "image/svg+xml",
21
+ };
22
+ const IMAGE_NAME = /^img_[0-9a-f]{16}\.(png|jpg|gif|webp|svg)$/;
23
+ /** Format reconnu d'après les premiers octets, jamais d'après l'extension annoncée. */
24
+ function detectFormat(data) {
25
+ if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
26
+ return "png";
27
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
28
+ return "jpg";
29
+ if (data.length >= 6 && /^GIF8[79]a$/.test(data.subarray(0, 6).toString("latin1")))
30
+ return "gif";
31
+ if (data.length >= 12 && data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP") {
32
+ return "webp";
33
+ }
34
+ const head = data.subarray(0, 4096).toString("utf8").replace(/^/, "").trimStart();
35
+ if (/^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)*(<!DOCTYPE[^>]*>\s*)?<svg[\s>]/i.test(head))
36
+ return "svg";
37
+ return null;
38
+ }
39
+ function jpegSize(data) {
40
+ let offset = 2;
41
+ while (offset + 9 < data.length) {
42
+ if (data[offset] !== 0xff)
43
+ return null;
44
+ const marker = data[offset + 1];
45
+ // Marqueurs sans longueur : remplissage, RST, SOI.
46
+ if (marker === 0xff) {
47
+ offset += 1;
48
+ continue;
49
+ }
50
+ if ((marker >= 0xd0 && marker <= 0xd9) || marker === 0x01) {
51
+ offset += 2;
52
+ continue;
53
+ }
54
+ const length = data.readUInt16BE(offset + 2);
55
+ // SOF0..SOF15, hors DHT (C4), JPG (C8) et DAC (CC).
56
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
57
+ return { height: data.readUInt16BE(offset + 5), width: data.readUInt16BE(offset + 7) };
58
+ }
59
+ offset += 2 + length;
60
+ }
61
+ return null;
62
+ }
63
+ function webpSize(data) {
64
+ const chunk = data.subarray(12, 16).toString("latin1");
65
+ if (chunk === "VP8X" && data.length >= 30) {
66
+ return { width: 1 + data.readUIntLE(24, 3), height: 1 + data.readUIntLE(27, 3) };
67
+ }
68
+ if (chunk === "VP8 " && data.length >= 30) {
69
+ return { width: data.readUInt16LE(26) & 0x3fff, height: data.readUInt16LE(28) & 0x3fff };
70
+ }
71
+ if (chunk === "VP8L" && data.length >= 25) {
72
+ const bits = data.readUInt32LE(21);
73
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
74
+ }
75
+ return null;
76
+ }
77
+ /** Longueur SVG en pixels ; null pour une valeur relative (%) ou absente. */
78
+ function svgLength(value) {
79
+ if (value === undefined)
80
+ return null;
81
+ const match = /^\s*([\d.]+)\s*(px)?\s*$/.exec(value);
82
+ return match ? Number(match[1]) : null;
83
+ }
84
+ function svgSize(data) {
85
+ const tag = /<svg\b[^>]*>/i.exec(data.toString("utf8"))?.[0];
86
+ if (!tag)
87
+ return null;
88
+ const attribute = (name) => new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`, "i").exec(tag)?.[1];
89
+ const width = svgLength(attribute("width"));
90
+ const height = svgLength(attribute("height"));
91
+ const viewBox = attribute("viewBox")?.trim().split(/[\s,]+/).map(Number);
92
+ const box = viewBox && viewBox.length === 4 && viewBox[2] > 0 && viewBox[3] > 0 ? viewBox : null;
93
+ if (width && height)
94
+ return { width, height };
95
+ if (box && width)
96
+ return { width, height: (width * box[3]) / box[2] };
97
+ if (box && height)
98
+ return { width: (height * box[2]) / box[3], height };
99
+ if (box)
100
+ return { width: box[2], height: box[3] };
101
+ return null;
102
+ }
103
+ function naturalSize(data, format) {
104
+ switch (format) {
105
+ case "png":
106
+ return data.length >= 24 ? { width: data.readUInt32BE(16), height: data.readUInt32BE(20) } : null;
107
+ case "gif":
108
+ return data.length >= 10 ? { width: data.readUInt16LE(6), height: data.readUInt16LE(8) } : null;
109
+ case "jpg":
110
+ return jpegSize(data);
111
+ case "webp":
112
+ return webpSize(data);
113
+ case "svg":
114
+ return svgSize(data);
115
+ }
116
+ }
117
+ export class ImageStore {
118
+ dir;
119
+ constructor(dataDir) {
120
+ this.dir = path.join(dataDir, "images");
121
+ }
122
+ getDir() {
123
+ return this.dir;
124
+ }
125
+ /** Enregistre une image et renvoie sa fiche ; une image déjà présente n'est pas réécrite. */
126
+ save(data) {
127
+ if (data.length === 0)
128
+ throw new DesignError("The image file is empty.");
129
+ if (data.length > MAX_IMAGE_BYTES) {
130
+ throw new DesignError(`Image too large: ${data.length} bytes, the limit is ${MAX_IMAGE_BYTES}.`);
131
+ }
132
+ const format = detectFormat(data);
133
+ if (!format) {
134
+ throw new DesignError(`Unsupported image format. Accepted: ${Object.keys(IMAGE_FORMATS).join(", ")}.`);
135
+ }
136
+ const size = naturalSize(data, format);
137
+ if (!size || !(size.width > 0) || !(size.height > 0)) {
138
+ throw new DesignError(`Could not read the dimensions of this ${format} image: the file looks damaged.`);
139
+ }
140
+ const hash = crypto.createHash("sha256").update(data).digest("hex").slice(0, 16);
141
+ const name = `img_${hash}.${format}`;
142
+ const file = path.join(this.dir, name);
143
+ if (!fs.existsSync(file)) {
144
+ fs.mkdirSync(this.dir, { recursive: true });
145
+ fs.writeFileSync(file, data);
146
+ }
147
+ return { name, format, mimeType: IMAGE_FORMATS[format], bytes: data.length, ...size };
148
+ }
149
+ fileOf(name) {
150
+ if (!IMAGE_NAME.test(name))
151
+ throw new DesignError(`"${name}" is not an image name of the media library.`);
152
+ return path.join(this.dir, name);
153
+ }
154
+ has(name) {
155
+ return IMAGE_NAME.test(name) && fs.existsSync(path.join(this.dir, name));
156
+ }
157
+ /** Échoue si un noeud désigne une image absente de la médiathèque. */
158
+ requireStored(name) {
159
+ if (!this.has(name)) {
160
+ throw new DesignError(`Image "${name}" is not in the media library. Import the picture with add_image, which returns the name to use.`);
161
+ }
162
+ }
163
+ read(name) {
164
+ const file = this.fileOf(name);
165
+ if (!fs.existsSync(file))
166
+ throw new DesignError(`Image "${name}" is missing from ${this.dir}.`);
167
+ const format = path.extname(name).slice(1);
168
+ return { data: fs.readFileSync(file), mimeType: IMAGE_FORMATS[format] };
169
+ }
170
+ /** L'image en data URI, pour un SVG ou un HTML qui doit se suffire à lui-même. */
171
+ dataUri(name) {
172
+ const { data, mimeType } = this.read(name);
173
+ return `data:${mimeType};base64,${data.toString("base64")}`;
174
+ }
175
+ }
@@ -30,6 +30,18 @@ function requireIconName(value) {
30
30
  (suggestions.length > 0 ? ` Closest matches: ${suggestions.join(", ")}.` : "") +
31
31
  ` Call search_icons to search the ${iconCount()} available icons by description.`);
32
32
  }
33
+ /**
34
+ * Valide le champ `image` d'un noeud `image`. L'existence du fichier dans la
35
+ * mediatheque, elle, est verifiee par les outils, qui ont acces au stockage.
36
+ */
37
+ function requireImageName(value) {
38
+ const name = typeof value === "string" ? value.trim() : "";
39
+ if (name === "") {
40
+ throw new DesignError(`An "image" node needs an "image" field naming a stored image, e.g. "img_3f9a1c2b7d4e5f60.png". ` +
41
+ `Call add_image to import a picture file: it stores the file and places the node in one step.`);
42
+ }
43
+ return name;
44
+ }
33
45
  /** Champs geometriques réellement modifiables pour un type donné. */
34
46
  function geometryFieldsFor(type) {
35
47
  return SIZE_DERIVED_KINDS.includes(type) ? GEOMETRY.filter((field) => !DERIVED.includes(field)) : GEOMETRY;
@@ -97,6 +109,8 @@ export function buildNode(input, existingIds, index) {
97
109
  }
98
110
  if (type === "instance")
99
111
  draft.componentId = requireComponentId(draft.componentId);
112
+ if (type === "image")
113
+ draft.image = requireImageName(draft.image);
100
114
  if (type === "icon") {
101
115
  draft.icon = requireIconName(draft.icon);
102
116
  // Un calque nomme "Icône 7" ne dit rien au relecteur ; le nom de l'icone,
@@ -148,6 +162,8 @@ export function applyPatch(node, patch) {
148
162
  }
149
163
  if (node.type === "icon" && patch.icon !== undefined)
150
164
  next.icon = requireIconName(patch.icon);
165
+ if (node.type === "image" && patch.image !== undefined)
166
+ next.image = requireImageName(patch.image);
151
167
  // Changer le lien echange l'instance de composant, comme un "swap instance".
152
168
  if (node.type === "instance" && patch.componentId !== undefined)
153
169
  next.componentId = requireComponentId(patch.componentId);
@@ -1,4 +1,6 @@
1
1
  import type { Design } from "../../shared/schemas/design.schema.js";
2
+ import type { ImageStore } from "./image-store.js";
3
+ import type { Box } from "../../shared/tree.js";
2
4
  /**
3
5
  * Capture d'écran d'un design.
4
6
  *
@@ -12,11 +14,15 @@ export declare const DEFAULT_MAX_DIMENSION = 1568;
12
14
  export interface ScreenshotOptions {
13
15
  /** Cadre la capture sur ce noeud (typiquement un groupe) au lieu de tout l'artboard. */
14
16
  nodeId?: string;
17
+ /** Cadre la capture sur cette zone, en coordonnées document. Exclusif de `nodeId`. */
18
+ region?: Box;
15
19
  /** Facteur de zoom appliqué avant l'écrêtage par maxDimension (défaut 1). */
16
20
  scale?: number;
17
21
  /** Marge, en pixels du design, ajoutée autour du noeud capturé. */
18
22
  padding?: number;
19
23
  maxDimension?: number;
24
+ /** Médiathèque où lire les images de la planche. Obligatoire dès qu'elle en contient. */
25
+ images?: ImageStore;
20
26
  }
21
27
  export interface Screenshot {
22
28
  png: Buffer;
@@ -1,5 +1,5 @@
1
1
  import { Resvg } from "@resvg/resvg-js";
2
- import { documentToSvg, subtreeToSvg } from "../../shared/render.js";
2
+ import { regionToSvg, subtreeToSvg } from "../../shared/render.js";
3
3
  /**
4
4
  * Capture d'écran d'un design.
5
5
  *
@@ -40,10 +40,39 @@ export function rasterize(svg, scale) {
40
40
  const rendered = resvg.render();
41
41
  return { png: Buffer.from(rendered.asPng()), width: rendered.width, height: rendered.height };
42
42
  }
43
+ function artboardOf(doc) {
44
+ return { x: 0, y: 0, width: doc.width, height: doc.height };
45
+ }
46
+ /**
47
+ * Rendu d'une zone de la planche, découpé dans le rendu de la planche entière.
48
+ *
49
+ * resvg panique, en emportant le processus, sur un élément semi-transparent
50
+ * dont le dessin tombe entièrement hors du cadre — et la boîte d'un élément ne
51
+ * dit pas où tombe son dessin : un texte occupe rarement toute la largeur de sa
52
+ * boîte. Dans la planche entière, tout ce qui est visible est dans le cadre ; la
53
+ * zone en est ensuite extraite par une image, qui ne porte aucune opacité.
54
+ */
55
+ function rasterizeRegion(doc, region, scale, render) {
56
+ const artboard = rasterize(regionToSvg(doc, artboardOf(doc), render), scale);
57
+ const crop = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" ` +
58
+ `width="${region.width}" height="${region.height}" ` +
59
+ `viewBox="${region.x} ${region.y} ${region.width} ${region.height}">` +
60
+ `<image x="0" y="0" width="${doc.width}" height="${doc.height}" preserveAspectRatio="none" ` +
61
+ `xlink:href="data:image/png;base64,${artboard.png.toString("base64")}" />` +
62
+ `</svg>`;
63
+ return rasterize(crop, scale);
64
+ }
43
65
  export function screenshotDesign(design, options = {}) {
44
66
  const doc = toDocument(design);
45
67
  const padding = Math.max(0, options.padding ?? 0);
46
- const svg = options.nodeId ? subtreeToSvg(doc, options.nodeId, padding) : documentToSvg(doc);
68
+ const region = options.region;
69
+ const images = options.images;
70
+ const render = images ? { imageHref: (name) => images.dataUri(name) } : {};
71
+ // La planche entière est rendue comme une zone : un élément semi-transparent
72
+ // posé hors de l'artboard en est omis.
73
+ const svg = options.nodeId
74
+ ? subtreeToSvg(doc, options.nodeId, padding, render)
75
+ : regionToSvg(doc, region ?? artboardOf(doc), render);
47
76
  const source = svgSize(svg);
48
77
  if (!(source.width > 0) || !(source.height > 0)) {
49
78
  throw new Error("Nothing to capture: the requested area has no size.");
@@ -52,7 +81,7 @@ export function screenshotDesign(design, options = {}) {
52
81
  const requested = Math.max(0.05, Math.min(8, options.scale ?? 1));
53
82
  // L'écrêtage ne peut que réduire : un zoom demandé plus petit reste respecté.
54
83
  const scale = Math.min(requested, maxDimension / Math.max(source.width, source.height));
55
- const rendered = rasterize(svg, scale);
84
+ const rendered = region ? rasterizeRegion(doc, region, scale, render) : rasterize(svg, scale);
56
85
  return {
57
86
  png: rendered.png,
58
87
  width: rendered.width,
@@ -115,7 +115,7 @@ function ipcRequest(type, data, timeout = 5000) {
115
115
  }
116
116
  });
117
117
  }
118
- const PUSHED_EVENTS = new Set(["INITIAL_STATE", "DESIGNS_UPDATED", "DESIGN_UPDATED"]);
118
+ const PUSHED_EVENTS = new Set(["INITIAL_STATE", "DESIGNS_UPDATED", "DESIGN_UPDATED", "REMARKS_UPDATED"]);
119
119
  function setupIpcErrorHandlers() {
120
120
  process.on("error", (err) => {
121
121
  if (err.code === "ERR_IPC_CHANNEL_CLOSED")
@@ -270,6 +270,118 @@ app.delete("/api/designs/:id", async (c) => {
270
270
  return c.json({ error: result.error }, 404);
271
271
  return c.json(result.data);
272
272
  });
273
+ app.get("/api/remarks", async (c) => {
274
+ const result = await ipcRequest("LIST_REMARKS");
275
+ if (result.error)
276
+ return c.json({ error: result.error }, 500);
277
+ return c.json(result.data);
278
+ });
279
+ app.post("/api/remarks", async (c) => {
280
+ let body;
281
+ try {
282
+ body = await c.req.json();
283
+ }
284
+ catch {
285
+ return c.json({ error: "Invalid JSON body" }, 400);
286
+ }
287
+ // La capture est rendue côté serveur : un délai plus long qu'une simple écriture.
288
+ const result = await ipcRequest("ADD_REMARK", body, 20000);
289
+ if (result.error)
290
+ return c.json({ error: result.error }, 400);
291
+ return c.json(result.data);
292
+ });
293
+ app.put("/api/remarks/:id", async (c) => {
294
+ let body;
295
+ try {
296
+ body = await c.req.json();
297
+ }
298
+ catch {
299
+ return c.json({ error: "Invalid JSON body" }, 400);
300
+ }
301
+ const result = await ipcRequest("UPDATE_REMARK", { id: c.req.param("id"), text: body?.text }, 10000);
302
+ if (result.error)
303
+ return c.json({ error: result.error }, 400);
304
+ return c.json(result.data);
305
+ });
306
+ app.delete("/api/remarks/:id", async (c) => {
307
+ const result = await ipcRequest("DELETE_REMARK", { id: c.req.param("id") }, 10000);
308
+ if (result.error)
309
+ return c.json({ error: result.error }, 404);
310
+ return c.json(result.data);
311
+ });
312
+ app.post("/api/feedback", async (c) => {
313
+ let body;
314
+ try {
315
+ body = await c.req.json();
316
+ }
317
+ catch {
318
+ return c.json({ error: "Invalid JSON body" }, 400);
319
+ }
320
+ const result = await ipcRequest("SUBMIT_FEEDBACK", { remarkIds: body?.remarkIds, title: body?.title }, 10000);
321
+ if (result.error)
322
+ return c.json({ error: result.error }, 400);
323
+ return c.json(result.data);
324
+ });
325
+ app.put("/api/rounds/:round", async (c) => {
326
+ let body;
327
+ try {
328
+ body = await c.req.json();
329
+ }
330
+ catch {
331
+ return c.json({ error: "Invalid JSON body" }, 400);
332
+ }
333
+ const result = await ipcRequest("RENAME_ROUND", { round: c.req.param("round"), title: body?.title }, 10000);
334
+ if (result.error)
335
+ return c.json({ error: result.error }, 400);
336
+ return c.json(result.data);
337
+ });
338
+ app.put("/api/rounds/:round/remarks/:id", async (c) => {
339
+ let body;
340
+ try {
341
+ body = await c.req.json();
342
+ }
343
+ catch {
344
+ return c.json({ error: "Invalid JSON body" }, 400);
345
+ }
346
+ const result = await ipcRequest("SET_REMARK_PROCESSED", { round: c.req.param("round"), remarkId: c.req.param("id"), processed: body?.processed }, 10000);
347
+ if (result.error)
348
+ return c.json({ error: result.error }, 400);
349
+ return c.json(result.data);
350
+ });
351
+ app.post("/api/feedback/processed", async (c) => {
352
+ let body;
353
+ try {
354
+ body = await c.req.json();
355
+ }
356
+ catch {
357
+ return c.json({ error: "Invalid JSON body" }, 400);
358
+ }
359
+ // Sans numéro de retour, tous les retours sont visés.
360
+ const result = await ipcRequest("SET_ROUNDS_PROCESSED", { round: body?.round, processed: body?.processed }, 10000);
361
+ if (result.error)
362
+ return c.json({ error: result.error }, 400);
363
+ return c.json(result.data);
364
+ });
365
+ app.get("/api/captures/:name", async (c) => {
366
+ const result = await ipcRequest("GET_CAPTURE", { name: c.req.param("name") }, 10000);
367
+ if (result.error)
368
+ return c.json({ error: result.error }, 404);
369
+ return c.body(Buffer.from(result.data.png, "base64"), 200, {
370
+ "Content-Type": "image/png",
371
+ // Une capture n'est jamais réécrite : son nom suit l'id de la remarque.
372
+ "Cache-Control": "private, max-age=31536000, immutable",
373
+ });
374
+ });
375
+ app.get("/api/images/:name", async (c) => {
376
+ const result = await ipcRequest("GET_IMAGE", { name: c.req.param("name") }, 10000);
377
+ if (result.error)
378
+ return c.json({ error: result.error }, 404);
379
+ return c.body(Buffer.from(result.data.data, "base64"), 200, {
380
+ "Content-Type": result.data.mimeType,
381
+ // Le nom d'une image suit l'empreinte de son contenu : il ne désigne jamais deux fichiers différents.
382
+ "Cache-Control": "private, max-age=31536000, immutable",
383
+ });
384
+ });
273
385
  // Fichiers statiques de la SPA
274
386
  app.use("/assets/*", serveStatic({ root: path.relative(process.cwd(), absoluteStaticRoot) }));
275
387
  function getHtmlBaseHref(appPath) {