@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
@@ -2,21 +2,12 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { DesignSchema, normalizeDesignGroup, } from "../../shared/schemas/design.schema.js";
4
4
  import { DesignError } from "./node-factory.js";
5
+ import { withFileLock } from "./file-lock.js";
5
6
  import { normalizeHierarchy } from "./hierarchy.js";
6
7
  import { migrateDesign } from "./migrate.js";
7
8
  import { shortId, slugify } from "./ids.js";
8
9
  import { countChanges } from "./diff.js";
9
- /** Au-delà de ce délai, un verrou est réputé abandonné (processus tué en cours d'écriture). */
10
- const LOCK_STALE_MS = 10_000;
11
- const LOCK_RETRY_MS = 25;
12
- const LOCK_TIMEOUT_MS = 5_000;
13
- /**
14
- * Pause bloquante. Le store est entièrement synchrone — le rendre asynchrone
15
- * pour l'attente d'un verrou contaminerait tous les appelants.
16
- */
17
- function sleepSync(ms) {
18
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
19
- }
10
+ import { ImageStore } from "./image-store.js";
20
11
  /**
21
12
  * Persistance des designs : un fichier JSON par design dans <dataDir>/designs.
22
13
  * Pas de base de données — un design tient dans quelques dizaines de Ko et le
@@ -25,6 +16,8 @@ function sleepSync(ms) {
25
16
  export class DesignStore {
26
17
  dataDir;
27
18
  designsDir;
19
+ /** Images posées sur les planches, partagées par tous les designs du dossier de données. */
20
+ images;
28
21
  /**
29
22
  * Cache mémoire : la GUI sauvegarde à chaque geste, relire le disque à chaque
30
23
  * fois serait inutile. Chaque entrée retient la signature du fichier au moment
@@ -39,6 +32,7 @@ export class DesignStore {
39
32
  this.dataDir = configured ? path.resolve(configured) : path.join(rootDir, ".design-data");
40
33
  this.designsDir = path.join(this.dataDir, "designs");
41
34
  fs.mkdirSync(this.designsDir, { recursive: true });
35
+ this.images = new ImageStore(this.dataDir);
42
36
  }
43
37
  getDataDir() {
44
38
  return this.dataDir;
@@ -75,52 +69,9 @@ export class DesignStore {
75
69
  // prendrait notre propre écriture pour une modification externe.
76
70
  this.remember(design);
77
71
  }
78
- /**
79
- * Sérialise le cycle lire-modifier-écrire entre processus. `wx` échoue si le
80
- * fichier existe déjà : c'est l'opération atomique qui départage deux
81
- * écrivains concurrents. Non réentrant — ne jamais appeler depuis `fn`.
82
- */
72
+ /** Verrou inter-processus du fichier d'un design. Non réentrant — ne jamais appeler depuis `fn`. */
83
73
  withLock(id, fn) {
84
- const lockPath = `${this.filePath(id)}.lock`;
85
- const deadline = Date.now() + LOCK_TIMEOUT_MS;
86
- let fd;
87
- for (;;) {
88
- try {
89
- fd = fs.openSync(lockPath, "wx");
90
- break;
91
- }
92
- catch (err) {
93
- if (err.code !== "EEXIST")
94
- throw err;
95
- try {
96
- // Un processus tué laisse son verrou derrière lui : passé le délai
97
- // d'obsolescence on le casse, sinon le design serait bloqué à vie.
98
- if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
99
- fs.unlinkSync(lockPath);
100
- continue;
101
- }
102
- }
103
- catch {
104
- continue; // Verrou disparu entre le open et le stat : on retente aussitôt.
105
- }
106
- if (Date.now() > deadline) {
107
- throw new DesignError(`Design "${id}" is being written by another editor and did not free up in ${LOCK_TIMEOUT_MS} ms. Try again in a moment.`);
108
- }
109
- sleepSync(LOCK_RETRY_MS);
110
- }
111
- }
112
- try {
113
- return fn();
114
- }
115
- finally {
116
- fs.closeSync(fd);
117
- try {
118
- fs.unlinkSync(lockPath);
119
- }
120
- catch {
121
- // Verrou déjà cassé pour obsolescence par un autre processus.
122
- }
123
- }
74
+ return withFileLock(`${this.filePath(id)}.lock`, `Design "${id}"`, fn);
124
75
  }
125
76
  /** Identifiant lisible dérivé du nom, suffixé seulement en cas de collision. */
126
77
  allocateId(name) {
@@ -1,6 +1,7 @@
1
1
  import type { Design } from "../../shared/schemas/design.schema.js";
2
+ import type { ImageStore } from "./image-store.js";
2
3
  export declare const EXPORT_FORMATS: readonly ["svg", "html", "jsx", "json", "summary"];
3
4
  export type ExportFormat = (typeof EXPORT_FORMATS)[number];
4
5
  /** Inventaire textuel : ce que l'agent lit quand il veut comprendre sans parser du JSON. */
5
6
  export declare function toSummary(design: Design): string;
6
- export declare function exportDesign(design: Design, format: ExportFormat): string;
7
+ export declare function exportDesign(design: Design, format: ExportFormat, images?: ImageStore): string;
@@ -1,4 +1,4 @@
1
- import { DEFAULT_FONT_FAMILY, documentToSvg, escapeXml, iconElementToSvg, iconElementsOf, iconStyle, normalizedBox, textLines, textMetrics, } from "../../shared/render.js";
1
+ import { DEFAULT_FONT_FAMILY, documentToSvg, escapeXml, iconElementToSvg, iconElementsOf, iconStyle, imageSource, normalizedBox, textLines, textMetrics, } from "../../shared/render.js";
2
2
  import { ICON_GRID } from "../../shared/icons/index.js";
3
3
  import { boundsOf, childrenByParent, computeLayout } from "../../shared/tree.js";
4
4
  import { expandInstances } from "../../shared/instances.js";
@@ -131,7 +131,19 @@ function contextOf(design) {
131
131
  const nodes = toExpandedNodes(design);
132
132
  return { layout: computeLayout(nodes), children: childrenByParent(nodes) };
133
133
  }
134
- function toHtml(design) {
134
+ /**
135
+ * Résolution des images en data URI : un export se suffit à lui-même, il
136
+ * s'ouvre tel quel sans la médiathèque du serveur.
137
+ */
138
+ function renderOptions(images) {
139
+ return images ? { imageHref: (name) => images.dataUri(name) } : {};
140
+ }
141
+ /** Source et cadrage d'une image pour les exports HTML et JSX. */
142
+ function imageTag(node, images) {
143
+ const render = node;
144
+ return { src: imageSource(render, renderOptions(images)), fit: render.fit ?? "cover" };
145
+ }
146
+ function toHtml(design, images) {
135
147
  const { layout, children } = contextOf(design);
136
148
  const rules = [];
137
149
  const walk = (nodes, parentBox, depth) => {
@@ -150,6 +162,12 @@ function toHtml(design) {
150
162
  return [`${pad}<div class="${cls}">${escapeXml(node.text ?? "")}</div>`];
151
163
  if (node.type === "icon")
152
164
  return [`${pad}<div class="${cls}">${iconSvg(node, identity)}</div>`];
165
+ if (node.type === "image") {
166
+ const image = imageTag(node, images);
167
+ return [
168
+ `${pad}<img class="${cls}" src="${escapeXml(image.src)}" alt="${escapeXml(node.name)}" style="object-fit: ${image.fit}" />`,
169
+ ];
170
+ }
153
171
  return [`${pad}<div class="${cls}"></div>`];
154
172
  });
155
173
  };
@@ -189,7 +207,7 @@ function toComponentName(name) {
189
207
  .replace(/^[a-z]/, (letter) => letter.toUpperCase());
190
208
  return /^[A-Za-z]/.test(pascal) ? pascal : "Design";
191
209
  }
192
- function toJsx(design) {
210
+ function toJsx(design, images) {
193
211
  const { layout, children } = contextOf(design);
194
212
  const styleOf = (node, parentBox) => nodeCss(node, layout, parentBox)
195
213
  .map((declaration) => {
@@ -218,6 +236,13 @@ function toJsx(design) {
218
236
  if (node.type === "icon") {
219
237
  return [comment, `${pad}<div style={{ ${style} }}>${iconSvg(node, toCamelCase)}</div>`];
220
238
  }
239
+ if (node.type === "image") {
240
+ const image = imageTag(node, images);
241
+ return [
242
+ comment,
243
+ `${pad}<img src=${JSON.stringify(image.src)} alt=${JSON.stringify(node.name)} style={{ ${style}, objectFit: ${JSON.stringify(image.fit)} }} />`,
244
+ ];
245
+ }
221
246
  return [comment, `${pad}<div style={{ ${style} }} />`];
222
247
  });
223
248
  };
@@ -273,6 +298,9 @@ export function toSummary(design) {
273
298
  lines.push(`${indent}- ${node.id} icon "${node.name}" @ ${geometry} — ${node.icon ?? "?"}, ` +
274
299
  `stroke ${node.stroke ?? "none"} ${node.strokeWidth ?? 2}/24${node.fill && node.fill !== "none" ? `, fill ${node.fill}` : ""}`);
275
300
  }
301
+ else if (node.type === "image") {
302
+ lines.push(`${indent}- ${node.id} image "${node.name}" @ ${geometry} — ${node.image ?? "?"}, fit ${node.fit ?? "cover"}`);
303
+ }
276
304
  else if (node.type === "line") {
277
305
  lines.push(`${indent}- ${node.id} line "${node.name}" @ ${geometry} — ${node.strokeWidth}px ${node.stroke}`);
278
306
  }
@@ -285,7 +313,7 @@ export function toSummary(design) {
285
313
  walk(children.get(null) ?? [], 0);
286
314
  return lines.join("\n");
287
315
  }
288
- export function exportDesign(design, format) {
316
+ export function exportDesign(design, format, images) {
289
317
  switch (format) {
290
318
  case "svg":
291
319
  // `documentToSvg` resout les instances lui-meme : le canvas de la GUI
@@ -296,11 +324,11 @@ export function exportDesign(design, format) {
296
324
  height: design.height,
297
325
  background: design.background,
298
326
  nodes: toRenderNodes(design),
299
- });
327
+ }, renderOptions(images));
300
328
  case "html":
301
- return toHtml(design);
329
+ return toHtml(design, images);
302
330
  case "jsx":
303
- return toJsx(design);
331
+ return toJsx(design, images);
304
332
  case "json":
305
333
  return JSON.stringify({
306
334
  id: design.id,
@@ -0,0 +1,73 @@
1
+ import { type FeedbackRound, type Remark } from "../../shared/feedback.js";
2
+ import { type FeedbackFile } from "../../shared/schemas/feedback.schema.js";
3
+ import type { Box } from "../../shared/tree.js";
4
+ import type { DesignStore } from "./design-store.js";
5
+ /** Cible demandée par la GUI ; le serveur la complète d'après le design. */
6
+ export type RemarkTargetInput = {
7
+ kind: "page";
8
+ } | {
9
+ kind: "node";
10
+ nodeId: string;
11
+ } | {
12
+ kind: "area";
13
+ box: Box;
14
+ elementIds: string[];
15
+ };
16
+ export interface AddRemarkInput {
17
+ designId: string;
18
+ target: RemarkTargetInput;
19
+ text: string;
20
+ /** Joindre une capture de l'élément ou de la zone. Sans effet sur une remarque de page. */
21
+ capture: boolean;
22
+ }
23
+ export interface SetProcessedInput {
24
+ round: number;
25
+ /** Remarques visées ; toutes celles du retour quand omis. */
26
+ remarkIds?: string[];
27
+ processed: boolean;
28
+ by: "agent" | "user";
29
+ note?: string | null;
30
+ }
31
+ /**
32
+ * Remarques de l'utilisateur et retours transmis à l'agent, dans
33
+ * `<dataDir>/feedback/remarks.json`, captures dans `<dataDir>/feedback/captures`.
34
+ *
35
+ * Un seul fichier pour tout le projet : un retour peut couvrir plusieurs pages.
36
+ */
37
+ export declare class FeedbackStore {
38
+ private readonly designs;
39
+ private readonly dir;
40
+ private readonly capturesDir;
41
+ private readonly file;
42
+ constructor(designs: DesignStore);
43
+ getFilePath(): string;
44
+ read(): FeedbackFile;
45
+ private mutate;
46
+ remarks(designId?: string): Remark[];
47
+ rounds(): FeedbackRound[];
48
+ capturePath(name: string): string;
49
+ readCapture(name: string): Buffer;
50
+ /** Complète la cible d'après l'état courant du design. */
51
+ private resolveTarget;
52
+ private renderCapture;
53
+ add(input: AddRemarkInput): Remark;
54
+ private draftOf;
55
+ updateText(id: string, text: string): Remark;
56
+ /**
57
+ * Retire une remarque de la liste. La capture d'un brouillon part avec lui ;
58
+ * celle d'une remarque transmise reste, le retour qui la contient la cite.
59
+ */
60
+ delete(id: string): void;
61
+ /** Retire les remarques d'un design supprimé, selon la même règle que `delete`. */
62
+ deleteDesign(designId: string): void;
63
+ /** Fige les brouillons choisis dans un nouveau retour nommé, et les marque transmis. */
64
+ submit(remarkIds: string[], title: string): FeedbackRound;
65
+ private roundOf;
66
+ rename(number: number, title: string): FeedbackRound;
67
+ /**
68
+ * Déclare des remarques d'un retour traitées, ou les rouvre. Une remarque
69
+ * déjà traitée reste inchangée ; une nouvelle note remplace l'ancienne et
70
+ * son auteur, en gardant la date d'origine.
71
+ */
72
+ setProcessed(input: SetProcessedInput): FeedbackRound;
73
+ }
@@ -0,0 +1,308 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { defaultRoundTitle, elementsInArea, nodeTarget, orderRemarks, roundBox, } from "../../shared/feedback.js";
4
+ import { FeedbackFileSchema, migrateFeedbackFile } from "../../shared/schemas/feedback.schema.js";
5
+ import { withFileLock } from "./file-lock.js";
6
+ import { shortId } from "./ids.js";
7
+ import { DesignError } from "./node-factory.js";
8
+ import { screenshotDesign } from "./screenshot.js";
9
+ /** Marge autour d'un élément capturé, comme `screenshot_design`. */
10
+ const NODE_CAPTURE_PADDING = 16;
11
+ function emptyFile() {
12
+ return { version: 2, remarks: [], rounds: [], lastNumbers: {} };
13
+ }
14
+ function requireText(text) {
15
+ const value = typeof text === "string" ? text.trim() : "";
16
+ if (value === "")
17
+ throw new DesignError("A remark needs some text.");
18
+ return value;
19
+ }
20
+ function requireTitle(title) {
21
+ const value = typeof title === "string" ? title.trim() : "";
22
+ if (value === "")
23
+ throw new DesignError("A feedback round needs a name.");
24
+ return value;
25
+ }
26
+ /**
27
+ * Remarques de l'utilisateur et retours transmis à l'agent, dans
28
+ * `<dataDir>/feedback/remarks.json`, captures dans `<dataDir>/feedback/captures`.
29
+ *
30
+ * Un seul fichier pour tout le projet : un retour peut couvrir plusieurs pages.
31
+ */
32
+ export class FeedbackStore {
33
+ designs;
34
+ dir;
35
+ capturesDir;
36
+ file;
37
+ constructor(designs) {
38
+ this.designs = designs;
39
+ this.dir = path.join(designs.getDataDir(), "feedback");
40
+ this.capturesDir = path.join(this.dir, "captures");
41
+ this.file = path.join(this.dir, "remarks.json");
42
+ fs.mkdirSync(this.capturesDir, { recursive: true });
43
+ }
44
+ getFilePath() {
45
+ return this.file;
46
+ }
47
+ read() {
48
+ if (!fs.existsSync(this.file))
49
+ return emptyFile();
50
+ let raw;
51
+ try {
52
+ raw = JSON.parse(fs.readFileSync(this.file, "utf-8"));
53
+ }
54
+ catch (err) {
55
+ throw new DesignError(`The remarks file ${this.file} is not readable: ${err.message}`);
56
+ }
57
+ // Un fichier d'une version antérieure est lu au format courant ; il n'est
58
+ // réécrit qu'à la prochaine modification.
59
+ const parsed = FeedbackFileSchema.safeParse(migrateFeedbackFile(raw, defaultRoundTitle) ?? raw);
60
+ if (!parsed.success) {
61
+ throw new DesignError(`The remarks file ${this.file} does not match the expected schema: ${parsed.error.issues
62
+ .map((issue) => `${issue.path.join(".")} ${issue.message}`)
63
+ .join("; ")}`);
64
+ }
65
+ return parsed.data;
66
+ }
67
+ mutate(fn) {
68
+ return withFileLock(`${this.file}.lock`, "The remarks file", () => {
69
+ const draft = structuredClone(this.read());
70
+ const result = fn(draft);
71
+ const parsed = FeedbackFileSchema.safeParse(draft);
72
+ if (!parsed.success) {
73
+ throw new DesignError(`Resulting remarks file is invalid: ${parsed.error.issues
74
+ .map((issue) => `${issue.path.join(".")} ${issue.message}`)
75
+ .join("; ")}`);
76
+ }
77
+ const tmp = `${this.file}.tmp`;
78
+ fs.writeFileSync(tmp, JSON.stringify(parsed.data, null, 2), "utf-8");
79
+ fs.renameSync(tmp, this.file);
80
+ return result;
81
+ });
82
+ }
83
+ remarks(designId) {
84
+ const remarks = this.read().remarks;
85
+ return designId === undefined ? remarks : remarks.filter((remark) => remark.designId === designId);
86
+ }
87
+ rounds() {
88
+ return this.read().rounds;
89
+ }
90
+ capturePath(name) {
91
+ if (name !== path.basename(name))
92
+ throw new DesignError(`Invalid capture name "${name}".`);
93
+ return path.join(this.capturesDir, name);
94
+ }
95
+ readCapture(name) {
96
+ const file = this.capturePath(name);
97
+ if (!fs.existsSync(file))
98
+ throw new DesignError(`Capture "${name}" not found in ${this.capturesDir}.`);
99
+ return fs.readFileSync(file);
100
+ }
101
+ /** Complète la cible d'après l'état courant du design. */
102
+ resolveTarget(designId, input) {
103
+ const design = this.designs.get(designId);
104
+ if (input.kind === "page")
105
+ return { kind: "page" };
106
+ if (input.kind === "node") {
107
+ if (!design.nodes.some((node) => node.id === input.nodeId)) {
108
+ throw new DesignError(`Node "${input.nodeId}" not found in design "${design.name}" (${design.id}).`);
109
+ }
110
+ return nodeTarget(design.nodes, input.nodeId);
111
+ }
112
+ const box = roundBox(input.box);
113
+ if (!(box.width >= 1 && box.height >= 1))
114
+ throw new DesignError("A remark area must be at least 1 px wide and high.");
115
+ if (box.x < 0 || box.y < 0 || box.x + box.width > design.width || box.y + box.height > design.height) {
116
+ throw new DesignError(`A remark area must lie within the ${design.width}x${design.height} artboard.`);
117
+ }
118
+ const hits = new Map(elementsInArea(design.nodes, box).map((hit) => [hit.id, hit]));
119
+ const elements = input.elementIds.map((id) => {
120
+ const hit = hits.get(id);
121
+ if (!hit)
122
+ throw new DesignError(`Node "${id}" is not inside the remark area.`);
123
+ return { id: hit.id, name: hit.name, type: hit.type };
124
+ });
125
+ return { kind: "area", box, elements };
126
+ }
127
+ renderCapture(designId, target) {
128
+ const design = this.designs.get(designId);
129
+ if (target.kind === "node") {
130
+ return screenshotDesign(design, { nodeId: target.element.id, padding: NODE_CAPTURE_PADDING, images: this.designs.images }).png;
131
+ }
132
+ if (target.kind === "area")
133
+ return screenshotDesign(design, { region: target.box, images: this.designs.images }).png;
134
+ throw new DesignError("A page remark has no capture.");
135
+ }
136
+ add(input) {
137
+ const text = requireText(input.text);
138
+ const target = this.resolveTarget(input.designId, input.target);
139
+ const id = shortId("rm");
140
+ const capture = input.capture && target.kind !== "page" ? `${id}.png` : null;
141
+ // Rendue hors verrou : la capture ne dépend que du design, et resvg peut
142
+ // prendre plus de temps qu'un autre écrivain n'en accepte d'attendre.
143
+ const png = capture ? this.renderCapture(input.designId, target) : null;
144
+ return this.mutate((draft) => {
145
+ if (target.kind === "page" &&
146
+ draft.remarks.some((remark) => remark.designId === input.designId && remark.status === "draft" && remark.target.kind === "page")) {
147
+ throw new DesignError("This page already has a remark on the whole page: edit it instead of adding another one.");
148
+ }
149
+ let number = null;
150
+ if (target.kind !== "page") {
151
+ number = (draft.lastNumbers[input.designId] ?? 0) + 1;
152
+ draft.lastNumbers[input.designId] = number;
153
+ }
154
+ if (capture && png)
155
+ fs.writeFileSync(this.capturePath(capture), png);
156
+ const now = new Date().toISOString();
157
+ const remark = {
158
+ id,
159
+ designId: input.designId,
160
+ number,
161
+ target,
162
+ text,
163
+ capture,
164
+ status: "draft",
165
+ round: null,
166
+ createdAt: now,
167
+ updatedAt: now,
168
+ };
169
+ draft.remarks.push(remark);
170
+ return remark;
171
+ });
172
+ }
173
+ draftOf(file, id) {
174
+ const remark = file.remarks.find((candidate) => candidate.id === id);
175
+ if (!remark)
176
+ throw new DesignError(`Remark "${id}" not found.`);
177
+ if (remark.status !== "draft") {
178
+ throw new DesignError(`Remark "${id}" was sent with feedback round ${remark.round}: it can no longer be edited.`);
179
+ }
180
+ return remark;
181
+ }
182
+ updateText(id, text) {
183
+ const value = requireText(text);
184
+ return this.mutate((draft) => {
185
+ const remark = this.draftOf(draft, id);
186
+ remark.text = value;
187
+ remark.updatedAt = new Date().toISOString();
188
+ return remark;
189
+ });
190
+ }
191
+ /**
192
+ * Retire une remarque de la liste. La capture d'un brouillon part avec lui ;
193
+ * celle d'une remarque transmise reste, le retour qui la contient la cite.
194
+ */
195
+ delete(id) {
196
+ const removed = this.mutate((draft) => {
197
+ const remark = draft.remarks.find((candidate) => candidate.id === id);
198
+ if (!remark)
199
+ throw new DesignError(`Remark "${id}" not found.`);
200
+ draft.remarks = draft.remarks.filter((candidate) => candidate.id !== id);
201
+ return remark;
202
+ });
203
+ if (removed.status === "draft" && removed.capture)
204
+ fs.rmSync(this.capturePath(removed.capture), { force: true });
205
+ }
206
+ /** Retire les remarques d'un design supprimé, selon la même règle que `delete`. */
207
+ deleteDesign(designId) {
208
+ const removed = this.mutate((draft) => {
209
+ const gone = draft.remarks.filter((remark) => remark.designId === designId);
210
+ draft.remarks = draft.remarks.filter((remark) => remark.designId !== designId);
211
+ delete draft.lastNumbers[designId];
212
+ return gone;
213
+ });
214
+ for (const remark of removed) {
215
+ if (remark.status === "draft" && remark.capture)
216
+ fs.rmSync(this.capturePath(remark.capture), { force: true });
217
+ }
218
+ }
219
+ /** Fige les brouillons choisis dans un nouveau retour nommé, et les marque transmis. */
220
+ submit(remarkIds, title) {
221
+ if (remarkIds.length === 0)
222
+ throw new DesignError("Select at least one remark to send.");
223
+ const name = requireTitle(title);
224
+ const names = new Map(this.designs.list().map((design) => [design.id, design.name]));
225
+ return this.mutate((draft) => {
226
+ const number = draft.rounds.length + 1;
227
+ const submittedAt = new Date().toISOString();
228
+ const selected = new Set(remarkIds);
229
+ const remarks = orderRemarks(draft.remarks.filter((remark) => selected.has(remark.id)));
230
+ for (const id of selected)
231
+ this.draftOf(draft, id);
232
+ const round = {
233
+ number,
234
+ title: name,
235
+ submittedAt,
236
+ remarks: remarks.map((remark) => {
237
+ const designName = names.get(remark.designId);
238
+ if (designName === undefined)
239
+ throw new DesignError(`Design "${remark.designId}" of remark "${remark.id}" no longer exists.`);
240
+ return {
241
+ id: remark.id,
242
+ designId: remark.designId,
243
+ designName,
244
+ number: remark.number,
245
+ target: remark.target,
246
+ text: remark.text,
247
+ capture: remark.capture,
248
+ processed: null,
249
+ };
250
+ }),
251
+ };
252
+ for (const remark of remarks) {
253
+ remark.status = "sent";
254
+ remark.round = number;
255
+ remark.updatedAt = submittedAt;
256
+ }
257
+ draft.rounds.push(round);
258
+ return round;
259
+ });
260
+ }
261
+ roundOf(file, number) {
262
+ const round = file.rounds.find((candidate) => candidate.number === number);
263
+ if (!round)
264
+ throw new DesignError(`Feedback round ${number} not found. Rounds 1 to ${file.rounds.length} exist.`);
265
+ return round;
266
+ }
267
+ rename(number, title) {
268
+ const name = requireTitle(title);
269
+ return this.mutate((draft) => {
270
+ const round = this.roundOf(draft, number);
271
+ round.title = name;
272
+ return round;
273
+ });
274
+ }
275
+ /**
276
+ * Déclare des remarques d'un retour traitées, ou les rouvre. Une remarque
277
+ * déjà traitée reste inchangée ; une nouvelle note remplace l'ancienne et
278
+ * son auteur, en gardant la date d'origine.
279
+ */
280
+ setProcessed(input) {
281
+ const note = typeof input.note === "string" && input.note.trim() !== "" ? input.note.trim() : null;
282
+ return this.mutate((draft) => {
283
+ const round = this.roundOf(draft, input.round);
284
+ const ids = input.remarkIds ?? round.remarks.map((remark) => remark.id);
285
+ if (ids.length === 0)
286
+ throw new DesignError("Name at least one remark of the round.");
287
+ const targets = ids.map((id) => {
288
+ const remark = round.remarks.find((candidate) => candidate.id === id);
289
+ if (!remark)
290
+ throw new DesignError(`Remark "${id}" is not part of feedback round ${round.number}.`);
291
+ return remark;
292
+ });
293
+ const at = new Date().toISOString();
294
+ for (const remark of targets) {
295
+ if (!input.processed) {
296
+ remark.processed = null;
297
+ continue;
298
+ }
299
+ // Une remarque déjà traitée le reste telle quelle, sauf nouvelle note :
300
+ // tout marquer d'un coup ne réattribue pas le travail de l'agent.
301
+ if (remark.processed && note === null)
302
+ continue;
303
+ remark.processed = { at: remark.processed?.at ?? at, by: input.by, note };
304
+ }
305
+ return round;
306
+ });
307
+ }
308
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Sérialise un cycle lire-modifier-écrire entre processus. `wx` échoue si le
3
+ * fichier de verrou existe déjà : c'est l'opération atomique qui départage deux
4
+ * écrivains concurrents. Non réentrant — ne jamais appeler depuis `fn`.
5
+ *
6
+ * `subject` nomme la ressource dans le message d'erreur, p.ex. `Design "landing"`.
7
+ */
8
+ export declare function withFileLock<T>(lockPath: string, subject: string, fn: () => T): T;
@@ -0,0 +1,61 @@
1
+ import fs from "fs";
2
+ import { DesignError } from "./node-factory.js";
3
+ /** Au-delà de ce délai, un verrou est réputé abandonné (processus tué en cours d'écriture). */
4
+ const LOCK_STALE_MS = 10_000;
5
+ const LOCK_RETRY_MS = 25;
6
+ const LOCK_TIMEOUT_MS = 5_000;
7
+ /**
8
+ * Pause bloquante. Les stores sont entièrement synchrones — les rendre
9
+ * asynchrones pour l'attente d'un verrou contaminerait tous les appelants.
10
+ */
11
+ function sleepSync(ms) {
12
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
13
+ }
14
+ /**
15
+ * Sérialise un cycle lire-modifier-écrire entre processus. `wx` échoue si le
16
+ * fichier de verrou existe déjà : c'est l'opération atomique qui départage deux
17
+ * écrivains concurrents. Non réentrant — ne jamais appeler depuis `fn`.
18
+ *
19
+ * `subject` nomme la ressource dans le message d'erreur, p.ex. `Design "landing"`.
20
+ */
21
+ export function withFileLock(lockPath, subject, fn) {
22
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
23
+ let fd;
24
+ for (;;) {
25
+ try {
26
+ fd = fs.openSync(lockPath, "wx");
27
+ break;
28
+ }
29
+ catch (err) {
30
+ if (err.code !== "EEXIST")
31
+ throw err;
32
+ try {
33
+ // Un processus tué laisse son verrou derrière lui : passé le délai
34
+ // d'obsolescence on le casse, sinon la ressource serait bloquée à vie.
35
+ if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
36
+ fs.unlinkSync(lockPath);
37
+ continue;
38
+ }
39
+ }
40
+ catch {
41
+ continue; // Verrou disparu entre le open et le stat : on retente aussitôt.
42
+ }
43
+ if (Date.now() > deadline) {
44
+ throw new DesignError(`${subject} is being written by another editor and did not free up in ${LOCK_TIMEOUT_MS} ms. Try again in a moment.`);
45
+ }
46
+ sleepSync(LOCK_RETRY_MS);
47
+ }
48
+ }
49
+ try {
50
+ return fn();
51
+ }
52
+ finally {
53
+ fs.closeSync(fd);
54
+ try {
55
+ fs.unlinkSync(lockPath);
56
+ }
57
+ catch {
58
+ // Verrou déjà cassé pour obsolescence par un autre processus.
59
+ }
60
+ }
61
+ }