@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.
- package/README.md +454 -403
- package/dist/shared/feedback.d.ts +3 -2
- package/dist/shared/feedback.js +4 -3
- package/dist/src/core/design-model.d.ts +54 -0
- package/dist/src/core/design-model.js +129 -0
- package/dist/src/core/design-store.d.ts +3 -18
- package/dist/src/core/design-store.js +15 -103
- package/dist/src/core/exchange.d.ts +86 -0
- package/dist/src/core/exchange.js +430 -0
- package/dist/src/core/feedback-model.d.ts +64 -0
- package/dist/src/core/feedback-model.js +211 -0
- package/dist/src/core/feedback-store.d.ts +4 -31
- package/dist/src/core/feedback-store.js +14 -195
- package/dist/src/core/ids.js +1 -2
- package/dist/src/gui-worker.js +42 -53
- package/dist/src/index.js +0 -7
- package/gui/dist/assets/index-DTIGOSpK.js +15 -0
- package/gui/dist/assets/{search-DHQx5ZT0.js → search-CH3uadrp.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +3 -2
- package/gui/dist/assets/index-SUmtOh8T.js +0 -15
|
@@ -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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
138
|
-
const target = this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/core/ids.js
CHANGED
|
@@ -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.
|
|
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];
|