@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.
- package/README.md +67 -5
- package/dist/shared/feedback.d.ts +128 -0
- package/dist/shared/feedback.js +165 -0
- package/dist/shared/node-defaults.js +6 -1
- package/dist/shared/node-fields.d.ts +2 -0
- package/dist/shared/node-fields.js +5 -1
- package/dist/shared/render.d.ts +30 -5
- package/dist/shared/render.js +58 -11
- package/dist/shared/schemas/design.schema.d.ts +36 -1
- package/dist/shared/schemas/design.schema.js +10 -1
- package/dist/shared/schemas/feedback.schema.d.ts +191 -0
- package/dist/shared/schemas/feedback.schema.js +87 -0
- package/dist/shared/tree.d.ts +2 -0
- package/dist/shared/tree.js +4 -0
- package/dist/src/core/design-store.d.ts +4 -5
- package/dist/src/core/design-store.js +7 -56
- package/dist/src/core/export.d.ts +2 -1
- package/dist/src/core/export.js +35 -7
- package/dist/src/core/feedback-store.d.ts +73 -0
- package/dist/src/core/feedback-store.js +308 -0
- package/dist/src/core/file-lock.d.ts +8 -0
- package/dist/src/core/file-lock.js +61 -0
- package/dist/src/core/image-store.d.ts +45 -0
- package/dist/src/core/image-store.js +175 -0
- package/dist/src/core/node-factory.js +16 -0
- package/dist/src/core/screenshot.d.ts +6 -0
- package/dist/src/core/screenshot.js +32 -3
- package/dist/src/gui-worker.js +113 -1
- package/dist/src/index.js +231 -3
- package/dist/src/tools/add-image.d.ts +3 -0
- package/dist/src/tools/add-image.js +65 -0
- package/dist/src/tools/add-nodes.js +2 -0
- package/dist/src/tools/delete-design.d.ts +3 -1
- package/dist/src/tools/delete-design.js +4 -1
- package/dist/src/tools/export-design.js +1 -1
- package/dist/src/tools/get-feedback.d.ts +3 -0
- package/dist/src/tools/get-feedback.js +62 -0
- package/dist/src/tools/mark-feedback-processed.d.ts +8 -0
- package/dist/src/tools/mark-feedback-processed.js +29 -0
- package/dist/src/tools/screenshot-design.js +1 -0
- package/dist/src/tools/update-nodes.js +4 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +16 -2
- package/gui/dist/assets/index-DNUHDrGi.css +1 -0
- package/gui/dist/assets/index-uk5sDkVA.js +15 -0
- package/gui/dist/assets/{search-CSwiX0lv.js → search-jSqY2fPS.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -3
- package/gui/dist/assets/index-DBZNXgF_.js +0 -10
- package/gui/dist/assets/index-PQqxUQLx.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/src/core/export.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|