@imenam/mcp-design 1.0.13 → 1.0.15

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.
@@ -0,0 +1,211 @@
1
+ import { defaultRoundTitle, elementsInArea, nodeTarget, orderRemarks, roundBox, } from "../../shared/feedback.js";
2
+ import { FeedbackFileSchema, migrateFeedbackFile } from "../../shared/schemas/feedback.schema.js";
3
+ import { DesignError } from "./node-factory.js";
4
+ /**
5
+ * Règles des remarques et des retours, indépendantes de leur stockage. Chaque
6
+ * opération modifie un brouillon du fichier des remarques ; l'appelant le
7
+ * valide puis l'enregistre — dans `remarks.json` pour le serveur, dans le
8
+ * projet en mémoire pour la version autonome de la GUI.
9
+ */
10
+ /** Marge autour d'un élément capturé, comme `screenshot_design`. */
11
+ export const NODE_CAPTURE_PADDING = 16;
12
+ export function emptyFeedbackFile() {
13
+ return { version: 2, remarks: [], rounds: [], lastNumbers: {} };
14
+ }
15
+ function issuesOf(error) {
16
+ return error.issues.map((issue) => `${issue.path.map(String).join(".")} ${issue.message}`).join("; ");
17
+ }
18
+ /** Lit un fichier des remarques brut, après mise à niveau de son format. */
19
+ export function parseFeedbackFile(raw, label) {
20
+ const parsed = FeedbackFileSchema.safeParse(migrateFeedbackFile(raw, defaultRoundTitle) ?? raw);
21
+ if (!parsed.success)
22
+ throw new DesignError(`${label} does not match the expected schema: ${issuesOf(parsed.error)}`);
23
+ return parsed.data;
24
+ }
25
+ /** Valide un brouillon modifié avant son enregistrement. */
26
+ export function validatedFeedbackFile(draft) {
27
+ const parsed = FeedbackFileSchema.safeParse(draft);
28
+ if (!parsed.success)
29
+ throw new DesignError(`Resulting remarks file is invalid: ${issuesOf(parsed.error)}`);
30
+ return parsed.data;
31
+ }
32
+ export function requireRemarkText(text) {
33
+ const value = typeof text === "string" ? text.trim() : "";
34
+ if (value === "")
35
+ throw new DesignError("A remark needs some text.");
36
+ return value;
37
+ }
38
+ function requireTitle(title) {
39
+ const value = typeof title === "string" ? title.trim() : "";
40
+ if (value === "")
41
+ throw new DesignError("A feedback round needs a name.");
42
+ return value;
43
+ }
44
+ /** Complète la cible d'après l'état courant du design. */
45
+ export function resolveRemarkTarget(design, input) {
46
+ if (input.kind === "page")
47
+ return { kind: "page" };
48
+ if (input.kind === "node") {
49
+ if (!design.nodes.some((node) => node.id === input.nodeId)) {
50
+ throw new DesignError(`Node "${input.nodeId}" not found in design "${design.name}" (${design.id}).`);
51
+ }
52
+ return nodeTarget(design.nodes, input.nodeId);
53
+ }
54
+ const box = roundBox(input.box);
55
+ if (!(box.width >= 1 && box.height >= 1))
56
+ throw new DesignError("A remark area must be at least 1 px wide and high.");
57
+ if (box.x < 0 || box.y < 0 || box.x + box.width > design.width || box.y + box.height > design.height) {
58
+ throw new DesignError(`A remark area must lie within the ${design.width}x${design.height} artboard.`);
59
+ }
60
+ const hits = new Map(elementsInArea(design.nodes, box).map((hit) => [hit.id, hit]));
61
+ const elements = input.elementIds.map((id) => {
62
+ const hit = hits.get(id);
63
+ if (!hit)
64
+ throw new DesignError(`Node "${id}" is not inside the remark area.`);
65
+ return { id: hit.id, name: hit.name, type: hit.type };
66
+ });
67
+ return { kind: "area", box, elements };
68
+ }
69
+ /** Ajoute un brouillon ; `capture` est le nom du fichier de capture, ou null. */
70
+ export function addRemark(draft, input) {
71
+ if (input.target.kind === "page" &&
72
+ draft.remarks.some((remark) => remark.designId === input.designId && remark.status === "draft" && remark.target.kind === "page")) {
73
+ throw new DesignError("This page already has a remark on the whole page: edit it instead of adding another one.");
74
+ }
75
+ let number = null;
76
+ if (input.target.kind !== "page") {
77
+ number = (draft.lastNumbers[input.designId] ?? 0) + 1;
78
+ draft.lastNumbers[input.designId] = number;
79
+ }
80
+ const now = new Date().toISOString();
81
+ const remark = {
82
+ id: input.id,
83
+ designId: input.designId,
84
+ number,
85
+ target: input.target,
86
+ text: requireRemarkText(input.text),
87
+ capture: input.capture,
88
+ status: "draft",
89
+ round: null,
90
+ createdAt: now,
91
+ updatedAt: now,
92
+ };
93
+ draft.remarks.push(remark);
94
+ return remark;
95
+ }
96
+ function draftOf(file, id) {
97
+ const remark = file.remarks.find((candidate) => candidate.id === id);
98
+ if (!remark)
99
+ throw new DesignError(`Remark "${id}" not found.`);
100
+ if (remark.status !== "draft") {
101
+ throw new DesignError(`Remark "${id}" was sent with feedback round ${remark.round}: it can no longer be edited.`);
102
+ }
103
+ return remark;
104
+ }
105
+ export function updateRemarkText(draft, id, text) {
106
+ const value = requireRemarkText(text);
107
+ const remark = draftOf(draft, id);
108
+ remark.text = value;
109
+ remark.updatedAt = new Date().toISOString();
110
+ return remark;
111
+ }
112
+ /** Retire une remarque de la liste et la renvoie. */
113
+ export function deleteRemark(draft, id) {
114
+ const remark = draft.remarks.find((candidate) => candidate.id === id);
115
+ if (!remark)
116
+ throw new DesignError(`Remark "${id}" not found.`);
117
+ draft.remarks = draft.remarks.filter((candidate) => candidate.id !== id);
118
+ return remark;
119
+ }
120
+ /** Retire les remarques d'un design supprimé et les renvoie. */
121
+ export function deleteDesignRemarks(draft, designId) {
122
+ const gone = draft.remarks.filter((remark) => remark.designId === designId);
123
+ draft.remarks = draft.remarks.filter((remark) => remark.designId !== designId);
124
+ delete draft.lastNumbers[designId];
125
+ return gone;
126
+ }
127
+ /**
128
+ * Fige les brouillons choisis dans un nouveau retour nommé, et les marque
129
+ * transmis. `names` donne le nom de chaque design existant.
130
+ */
131
+ export function submitRound(draft, remarkIds, title, names) {
132
+ if (remarkIds.length === 0)
133
+ throw new DesignError("Select at least one remark to send.");
134
+ const name = requireTitle(title);
135
+ const number = draft.rounds.length + 1;
136
+ const submittedAt = new Date().toISOString();
137
+ const selected = new Set(remarkIds);
138
+ const remarks = orderRemarks(draft.remarks.filter((remark) => selected.has(remark.id)));
139
+ for (const id of selected)
140
+ draftOf(draft, id);
141
+ const round = {
142
+ number,
143
+ title: name,
144
+ submittedAt,
145
+ remarks: remarks.map((remark) => {
146
+ const designName = names.get(remark.designId);
147
+ if (designName === undefined)
148
+ throw new DesignError(`Design "${remark.designId}" of remark "${remark.id}" no longer exists.`);
149
+ return {
150
+ id: remark.id,
151
+ designId: remark.designId,
152
+ designName,
153
+ number: remark.number,
154
+ target: remark.target,
155
+ text: remark.text,
156
+ capture: remark.capture,
157
+ processed: null,
158
+ };
159
+ }),
160
+ };
161
+ for (const remark of remarks) {
162
+ remark.status = "sent";
163
+ remark.round = number;
164
+ remark.updatedAt = submittedAt;
165
+ }
166
+ draft.rounds.push(round);
167
+ return round;
168
+ }
169
+ function roundOf(file, number) {
170
+ const round = file.rounds.find((candidate) => candidate.number === number);
171
+ if (!round)
172
+ throw new DesignError(`Feedback round ${number} not found. Rounds 1 to ${file.rounds.length} exist.`);
173
+ return round;
174
+ }
175
+ export function renameRound(draft, number, title) {
176
+ const name = requireTitle(title);
177
+ const round = roundOf(draft, number);
178
+ round.title = name;
179
+ return round;
180
+ }
181
+ /**
182
+ * Déclare des remarques d'un retour traitées, ou les rouvre. Une remarque
183
+ * déjà traitée reste inchangée ; une nouvelle note remplace l'ancienne et
184
+ * son auteur, en gardant la date d'origine.
185
+ */
186
+ export function setRemarksProcessed(draft, input) {
187
+ const note = typeof input.note === "string" && input.note.trim() !== "" ? input.note.trim() : null;
188
+ const round = roundOf(draft, input.round);
189
+ const ids = input.remarkIds ?? round.remarks.map((remark) => remark.id);
190
+ if (ids.length === 0)
191
+ throw new DesignError("Name at least one remark of the round.");
192
+ const targets = ids.map((id) => {
193
+ const remark = round.remarks.find((candidate) => candidate.id === id);
194
+ if (!remark)
195
+ throw new DesignError(`Remark "${id}" is not part of feedback round ${round.number}.`);
196
+ return remark;
197
+ });
198
+ const at = new Date().toISOString();
199
+ for (const remark of targets) {
200
+ if (!input.processed) {
201
+ remark.processed = null;
202
+ continue;
203
+ }
204
+ // Une remarque déjà traitée le reste telle quelle, sauf nouvelle note :
205
+ // tout marquer d'un coup ne réattribue pas le travail de l'agent.
206
+ if (remark.processed && note === null)
207
+ continue;
208
+ remark.processed = { at: remark.processed?.at ?? at, by: input.by, note };
209
+ }
210
+ return round;
211
+ }
@@ -1,18 +1,8 @@
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";
1
+ import type { FeedbackRound, Remark } from "../../shared/feedback.js";
2
+ import type { FeedbackFile } from "../../shared/schemas/feedback.schema.js";
4
3
  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
- };
4
+ import { type RemarkTargetInput, type SetProcessedInput } from "./feedback-model.js";
5
+ export type { RemarkTargetInput, SetProcessedInput };
16
6
  export interface AddRemarkInput {
17
7
  designId: string;
18
8
  target: RemarkTargetInput;
@@ -20,14 +10,6 @@ export interface AddRemarkInput {
20
10
  /** Joindre une capture de l'élément ou de la zone. Sans effet sur une remarque de page. */
21
11
  capture: boolean;
22
12
  }
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
13
  /**
32
14
  * Remarques de l'utilisateur et retours transmis à l'agent, dans
33
15
  * `<dataDir>/feedback/remarks.json`, captures dans `<dataDir>/feedback/captures`.
@@ -47,11 +29,8 @@ export declare class FeedbackStore {
47
29
  rounds(): FeedbackRound[];
48
30
  capturePath(name: string): string;
49
31
  readCapture(name: string): Buffer;
50
- /** Complète la cible d'après l'état courant du design. */
51
- private resolveTarget;
52
32
  private renderCapture;
53
33
  add(input: AddRemarkInput): Remark;
54
- private draftOf;
55
34
  updateText(id: string, text: string): Remark;
56
35
  /**
57
36
  * Retire une remarque de la liste. La capture d'un brouillon part avec lui ;
@@ -62,12 +41,6 @@ export declare class FeedbackStore {
62
41
  deleteDesign(designId: string): void;
63
42
  /** Fige les brouillons choisis dans un nouveau retour nommé, et les marque transmis. */
64
43
  submit(remarkIds: string[], title: string): FeedbackRound;
65
- private roundOf;
66
44
  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
45
  setProcessed(input: SetProcessedInput): FeedbackRound;
73
46
  }
@@ -1,28 +1,10 @@
1
1
  import fs from "fs";
2
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
3
  import { withFileLock } from "./file-lock.js";
6
4
  import { shortId } from "./ids.js";
7
5
  import { DesignError } from "./node-factory.js";
8
6
  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
- }
7
+ import { NODE_CAPTURE_PADDING, addRemark, deleteDesignRemarks, deleteRemark, emptyFeedbackFile, parseFeedbackFile, renameRound, requireRemarkText, resolveRemarkTarget, setRemarksProcessed, submitRound, updateRemarkText, validatedFeedbackFile, } from "./feedback-model.js";
26
8
  /**
27
9
  * Remarques de l'utilisateur et retours transmis à l'agent, dans
28
10
  * `<dataDir>/feedback/remarks.json`, captures dans `<dataDir>/feedback/captures`.
@@ -46,7 +28,7 @@ export class FeedbackStore {
46
28
  }
47
29
  read() {
48
30
  if (!fs.existsSync(this.file))
49
- return emptyFile();
31
+ return emptyFeedbackFile();
50
32
  let raw;
51
33
  try {
52
34
  raw = JSON.parse(fs.readFileSync(this.file, "utf-8"));
@@ -56,26 +38,15 @@ export class FeedbackStore {
56
38
  }
57
39
  // Un fichier d'une version antérieure est lu au format courant ; il n'est
58
40
  // 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;
41
+ return parseFeedbackFile(raw, `The remarks file ${this.file}`);
66
42
  }
67
43
  mutate(fn) {
68
44
  return withFileLock(`${this.file}.lock`, "The remarks file", () => {
69
45
  const draft = structuredClone(this.read());
70
46
  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
- }
47
+ const validated = validatedFeedbackFile(draft);
77
48
  const tmp = `${this.file}.tmp`;
78
- fs.writeFileSync(tmp, JSON.stringify(parsed.data, null, 2), "utf-8");
49
+ fs.writeFileSync(tmp, JSON.stringify(validated, null, 2), "utf-8");
79
50
  fs.renameSync(tmp, this.file);
80
51
  return result;
81
52
  });
@@ -98,32 +69,6 @@ export class FeedbackStore {
98
69
  throw new DesignError(`Capture "${name}" not found in ${this.capturesDir}.`);
99
70
  return fs.readFileSync(file);
100
71
  }
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
72
  renderCapture(designId, target) {
128
73
  const design = this.designs.get(designId);
129
74
  if (target.kind === "node") {
@@ -134,83 +79,35 @@ export class FeedbackStore {
134
79
  throw new DesignError("A page remark has no capture.");
135
80
  }
136
81
  add(input) {
137
- const text = requireText(input.text);
138
- const target = this.resolveTarget(input.designId, input.target);
82
+ const text = requireRemarkText(input.text);
83
+ const target = resolveRemarkTarget(this.designs.get(input.designId), input.target);
139
84
  const id = shortId("rm");
140
85
  const capture = input.capture && target.kind !== "page" ? `${id}.png` : null;
141
86
  // Rendue hors verrou : la capture ne dépend que du design, et resvg peut
142
87
  // prendre plus de temps qu'un autre écrivain n'en accepte d'attendre.
143
88
  const png = capture ? this.renderCapture(input.designId, target) : null;
144
89
  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
- }
90
+ const remark = addRemark(draft, { id, designId: input.designId, target, text, capture });
154
91
  if (capture && png)
155
92
  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
93
  return remark;
171
94
  });
172
95
  }
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
96
  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
- });
97
+ return this.mutate((draft) => updateRemarkText(draft, id, text));
190
98
  }
191
99
  /**
192
100
  * Retire une remarque de la liste. La capture d'un brouillon part avec lui ;
193
101
  * celle d'une remarque transmise reste, le retour qui la contient la cite.
194
102
  */
195
103
  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
- });
104
+ const removed = this.mutate((draft) => deleteRemark(draft, id));
203
105
  if (removed.status === "draft" && removed.capture)
204
106
  fs.rmSync(this.capturePath(removed.capture), { force: true });
205
107
  }
206
108
  /** Retire les remarques d'un design supprimé, selon la même règle que `delete`. */
207
109
  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
- });
110
+ const removed = this.mutate((draft) => deleteDesignRemarks(draft, designId));
214
111
  for (const remark of removed) {
215
112
  if (remark.status === "draft" && remark.capture)
216
113
  fs.rmSync(this.capturePath(remark.capture), { force: true });
@@ -218,91 +115,13 @@ export class FeedbackStore {
218
115
  }
219
116
  /** Fige les brouillons choisis dans un nouveau retour nommé, et les marque transmis. */
220
117
  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
118
  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;
119
+ return this.mutate((draft) => submitRound(draft, remarkIds, title, names));
266
120
  }
267
121
  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
- });
122
+ return this.mutate((draft) => renameRound(draft, number, title));
274
123
  }
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
124
  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
- });
125
+ return this.mutate((draft) => setRemarksProcessed(draft, input));
307
126
  }
308
127
  }
@@ -1,8 +1,7 @@
1
- import crypto from "crypto";
2
1
  const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
3
2
  /** Identifiant court, lisible dans un JSON lu par un agent. */
4
3
  export function shortId(prefix, length = 8) {
5
- const bytes = crypto.randomBytes(length);
4
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(length));
6
5
  let out = "";
7
6
  for (const byte of bytes)
8
7
  out += ALPHABET[byte % ALPHABET.length];