dsh-figmark 1.1.3 → 1.2.1

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/lib/index.js CHANGED
@@ -22,12 +22,13 @@ const GUIDANCE = [
22
22
  "用户可能在文档预览里直接标注(箭头/框选/椭圆/手绘/单点),发送给你的消息以【附图标注】开头,正文就是**逐条标注清单**,不需要再去读任何附加文件(标注文件是投递用的临时件,发出即删)。收到时按下列纪律处理:",
23
23
  "",
24
24
  "1. 逐条读消息正文的清单:每条形如 `① 箭头 (10,10) → (40,30),批注:标号指错了`。括号里是图面像素坐标,原点在左上角;框选与圈选给的是**左上角-右下角**,手绘线给的是它画在哪个框里。`批注:` 之后是用户写的**详细说明**——那是这条标注唯一的内容,按它判断意图。有的条目在形状后面紧跟一个圆括号,写着该处落在哪个元素上(SVG 给元素标题/`id`/元素文字,HTML 给 `selector`/元素文字),定位时优先用它。",
25
+ "1a. 若某条写的是**元素**而不是形状(`① 元素 …`),那条标注指的不是\"在这里画个框\",而是**那一个元素本身**:圆括号里给的是选中时抽到的名字(元素标题 / `id` / 元素文字 / 选择器),按它去源文件里找到那一个元素,改的就是它,不是它周围的一块区域。这类标注没有可挪动的形状,用户说的\"这里\"就是那个元素。",
25
26
  "2. **PDF 是整份文档一份标注**:每条标注都带 `第 N 页`,坐标是那一页自己的(原点在该页左上角)。看每条标注前先确认它的页码。",
26
27
  "3. 若标注超出原图边界,标注图会把越界的那圈纸也画出来,并用**灰色虚线**框住原图真正的边缘:虚线**里面**是原图,**外面**是你画的标注伸出去的部分——那块不属于图,不要把它当成新增的画布内容。有形状的标注(箭头/框选/椭圆/手绘)会附一张标注图(`<图名>.annotated.png`,PDF 则是一份**带批注的 PDF**,其批注是原生 PDF 注释对象:`/Ink` 手绘、`/Square` 框选、`/Circle` 圈选、`/Line` 箭头),可直接打开核对图面位置。**若消息说「本条标注只有位置点,没有标注图」,那就是全部内容:只有坐标加一句说明,直接按原图定位处理,不要去找不存在的附件。**",
27
28
  "4. 逐条处理标注:每条标注都要落到具体的图面改动,并在答复里逐条回应(编号与标注一致)。无法实现的标注要说明原因,不要静默跳过。",
28
29
  "5. 修改附图以**标注所指的生成源**为准(绘制脚本、SVG 源或 `generate_patent_figure` 的入参),不要直接涂改导出的位图;PDF 同理,改的是生成该 PDF 的源头,不是那份带批注的副本。未标注的部分保持不动。",
29
30
  "6. 改完图后重新渲染并核对:图面尺寸/坐标若变化,旧标注的坐标会失准,此时以 `anchor` 与标注文字为准复核一遍,再回报结果。",
30
- "7. 若消息以【HTML 标注】开头,那是**渲染出来的 HTML 文档**:标注画在固定 1024px 宽的渲染面上,`points` 是那个宽度下的渲染像素。这类标注**没有标注图**(浏览器无法把渲染好的文档栅格化成图片),所以定位只能靠 `marks[].anchor.selector`——那是一条**可直接粘贴使用**的精确路径:每段是 `tag:nth-of-type(n)`,遇到 `#id` 就在那里截断,因此它要么以 `body > …` 开头,要么直接是 `#id > …`;`anchor.text` 是该元素的文字,`points` 只作辅助(渲染宽度一变坐标就会漂)。",
31
+ "7. 若消息以【HTML 标注】开头,那是**渲染出来的 HTML 文档**:标注画在固定 1024px 宽的渲染面上,`points` 是那个宽度下的渲染像素。这类标注**没有标注图**(浏览器无法把渲染好的文档栅格化成图片),所以定位只能靠 `marks[].anchor.selector`——那是一条**可直接粘贴使用**的精确路径:每段是 `tag:nth-of-type(n)`,遇到 `#id` 就在那里截断,因此它要么以 `body > …` 开头,要么直接是 `#id > …`;`anchor.text` 是该元素的文字,`points` 只作辅助(渲染宽度一变坐标就会漂)。SVG 的元素标注同理,只是那条路径以 `<svg>` 元素本身为根(`svg > g:nth-of-type(2) > …`),遇到 `#id` 同样在那里截断——所以它要么以 `svg > …` 开头,要么直接是 `#id > …`。",
31
32
  "8. 处理 HTML 标注时,改的是**生成这份 HTML 的源文件**(模板、生成脚本或 HTML 本体),不是预览里的渲染结果;渲染面按本插件内联的样式与脚本策略绘制,脚本不会执行、外部资源不会加载,所以别把\"预览里看起来不对\"当成文件本身的问题。"
32
33
  ].join("\n");
33
34
  /** Section order used for the Markdown edit-proposal guidance (after the annotation one). */
@@ -82,8 +83,36 @@ function parseFileAddress(address) {
82
83
  }
83
84
  if (rest.startsWith("absolute/")) return { path: decodeSegment(rest.slice(9)) };
84
85
  }
85
- /** Route prefix owned by this plugin. */
86
- const ROUTE_PREFIX = "/dsh-figmark";
86
+ //#endregion
87
+ //#region src/shared/path-name.ts
88
+ /**
89
+ * The two path-name readings both halves need, in one place.
90
+ *
91
+ * A path arrives here from the Host (which uses platform separators) or from a
92
+ * browser document (which only ever writes `/`), so every splitter has to accept
93
+ * both. They used to be written out at each call site — once for the address
94
+ * grammar, once for the suffix table, once for a file name in a message — and the
95
+ * copies had already drifted apart on backslashes. One home makes them agree.
96
+ * @module dsh-figmark/shared/path-name
97
+ */
98
+ /**
99
+ * The last segment of a path, whatever separators it uses.
100
+ * @param path - a POSIX- or Windows-separated path.
101
+ * @returns the path's file name.
102
+ */
103
+ function baseNameOf(path) {
104
+ return path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
105
+ }
106
+ /**
107
+ * Read a path's lower-case suffix without the dot.
108
+ * @param path - file path or name.
109
+ * @returns the suffix, or an empty string when the name has none.
110
+ */
111
+ function suffixOf(path) {
112
+ const name = baseNameOf(path);
113
+ const dot = name.lastIndexOf(".");
114
+ return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
115
+ }
87
116
  //#endregion
88
117
  //#region src/shared/figure-kind.ts
89
118
  /**
@@ -115,24 +144,8 @@ const FIGURE_EXTENSIONS = [
115
144
  "html",
116
145
  "htm"
117
146
  ];
118
- /**
119
- * Read a path's lower-case suffix without the dot.
120
- * @param path - file path or name.
121
- * @returns the suffix, or an empty string when there is none.
122
- */
123
- function suffixOf(path) {
124
- const name = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1);
125
- const dot = name.lastIndexOf(".");
126
- return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
127
- }
128
- /**
129
- * Whether a path names a document this plugin renders.
130
- * @param path - file path.
131
- * @returns true when the suffix is one this plugin annotates.
132
- */
133
- function isFigurePath(path) {
134
- return FIGURE_EXTENSIONS.includes(suffixOf(path));
135
- }
147
+ /** Media type of a document that has pages rather than one surface. */
148
+ const PAGED_MEDIA_TYPE = "application/pdf";
136
149
  /**
137
150
  * Media type of one document path.
138
151
  * @param path - file path.
@@ -140,7 +153,7 @@ function isFigurePath(path) {
140
153
  */
141
154
  function figureMediaType(path) {
142
155
  switch (suffixOf(path)) {
143
- case "pdf": return "application/pdf";
156
+ case "pdf": return PAGED_MEDIA_TYPE;
144
157
  case "svg": return "image/svg+xml";
145
158
  case "png": return "image/png";
146
159
  case "jpg":
@@ -154,155 +167,265 @@ function figureMediaType(path) {
154
167
  default: return;
155
168
  }
156
169
  }
157
- //#endregion
158
- //#region src/host/sidecar.ts
159
170
  /**
160
- * Annotation files beside one figure.
171
+ * Whether a path names a document this plugin renders.
172
+ * @param path - file path.
173
+ * @returns true when the suffix is one this plugin annotates.
174
+ */
175
+ function isFigurePath(path) {
176
+ return FIGURE_EXTENSIONS.includes(suffixOf(path));
177
+ }
178
+ /**
179
+ * Whether a figure's suffix calls for the paged-document treatment.
161
180
  *
162
- * An annotation never modifies the figure: it is written to sibling files
163
- * (`<name>.annot.json` for the marks and `<name>.annotated.png` — or
164
- * `<name>.annotated.pdf` for a document that has pages — for the flattened
165
- * review artifact). One figure is one document, however many pages it has, so a
166
- * paged document's marks share a single sidecar and name their page inside it.
181
+ * One predicate for the whole plugin: the Host picks the review artifact's file
182
+ * extension from it, the store refuses a review whose kind contradicts it, and the
183
+ * browser half picks which JSON field carries the artifact from it. Reading the
184
+ * media type instead would work today and break the day a second suffix maps to
185
+ * `application/pdf`.
186
+ * @param path - file path.
187
+ * @returns true when the figure is a paged document.
188
+ */
189
+ function isPagedFigurePath(path) {
190
+ return figureMediaType(path) === PAGED_MEDIA_TYPE;
191
+ }
192
+ //#endregion
193
+ //#region src/host/figure-target.ts
194
+ /**
195
+ * Resolving one preview address to a figure that actually exists.
167
196
  *
168
- * The name keeps the figure's own extension (`fig1.svg` → `fig1.svg.annot.json`):
169
- * `fig1.svg` and `fig1.png` in one directory are two different documents, and marks
170
- * drawn on one of them mean nothing on the other, so they must never share a
171
- * sidecar. Every name is derived from the figure path, so the plugin can never be
172
- * asked to write an arbitrary location.
173
- * @module dsh-figmark/host/sidecar
197
+ * This is the only place a request-supplied string becomes a filesystem path, so
198
+ * it is where the plugin's write surface is defined: the address must parse as a
199
+ * file address, must resolve to a suffix this plugin renders, and must name an
200
+ * existing regular file. Anything else is refused with a message the browser can
201
+ * put in front of the reader — and nothing downstream ever accepts a path from the
202
+ * caller again, only the {@link FigureTarget} this module produced.
203
+ * @module dsh-figmark/host/figure-target
174
204
  */
175
- /** Suffix appended to a figure's name for the marks file. */
176
- const ANNOTATION_SUFFIX = ".annot.json";
177
- /** Suffix appended to a figure's name for the flattened review artifact. */
178
- const ANNOTATED_SUFFIX = ".annotated";
179
205
  /**
180
- * Resolve one figure's sidecar paths.
181
- * @param figurePath - absolute path of the annotated figure.
182
- * @returns the sidecar paths; both live in the figure's own directory.
206
+ * Resolve one request address to an existing figure file.
207
+ * @param address - `dsh-resource://file/…` address from the preview tab.
208
+ * @param sessionCwd - Session lookup used for a relative path.
209
+ * @returns the target, or an error message describing why it cannot be annotated.
183
210
  */
184
- function sidecarPaths(figurePath) {
185
- const name = basename(figurePath);
186
- const directory = dirname(figurePath);
187
- const reviewExtension = reviewFileExtension(figurePath);
211
+ async function resolveTarget(address, sessionCwd) {
212
+ const parsed = parseFileAddress(address);
213
+ if (parsed === void 0) return {
214
+ ok: false,
215
+ message: `not a file address: ${address}`
216
+ };
217
+ const sessionId = parsed.sessionId;
218
+ const cwd = sessionId === void 0 ? void 0 : await sessionCwd(sessionId);
219
+ let absolute;
220
+ if (isAbsolute(parsed.path)) absolute = parsed.path;
221
+ else {
222
+ if (cwd === void 0) return {
223
+ ok: false,
224
+ message: `cannot resolve this Session's directory for a relative path: ${address}`
225
+ };
226
+ absolute = resolve(cwd, parsed.path);
227
+ }
228
+ if (!isFigurePath(absolute)) return {
229
+ ok: false,
230
+ message: `not an annotatable figure: ${absolute}`
231
+ };
232
+ const mediaType = figureMediaType(absolute);
233
+ /* v8 ignore next -- isFigurePath already restricted the suffixes figureMediaType covers. */
234
+ if (mediaType === void 0) return {
235
+ ok: false,
236
+ message: `no media type for ${absolute}`
237
+ };
238
+ try {
239
+ if ((await stat(absolute)).isFile()) return {
240
+ ok: true,
241
+ target: {
242
+ address,
243
+ path: absolute,
244
+ mediaType,
245
+ workspace: cwd ?? null
246
+ }
247
+ };
248
+ } catch {}
188
249
  return {
189
- annotation: join(directory, `${name}${ANNOTATION_SUFFIX}`),
190
- review: join(directory, `${name}${ANNOTATED_SUFFIX}${reviewExtension}`)
250
+ ok: false,
251
+ message: `no such figure: ${absolute}`
191
252
  };
192
253
  }
254
+ /** Route prefix owned by this plugin. */
255
+ const ROUTE_PREFIX = "/dsh-figmark";
256
+ //#endregion
257
+ //#region src/shared/annotation.ts
193
258
  /**
194
- * Suffix of the flattened review artifact for one figure.
259
+ * Longest note one mark may carry, in characters.
195
260
  *
196
- * A paged document is reviewed as a document — the page marks travel as native
197
- * PDF annotations in a copy of the original — while a single-surface figure is
198
- * reviewed as the picture it is.
199
- * @param figurePath - absolute path of the annotated figure.
200
- * @returns the file extension, dot included.
261
+ * Lives in `shared` because both halves need the same number for different
262
+ * reasons: the browser stops the reader from typing past it, so the field cannot
263
+ * grow into a sidecar the Host will refuse; the Host refuses one anyway, so a
264
+ * client that forgot cannot write a file the next open cannot read. A single
265
+ * number in one place is what keeps those two from drifting apart.
201
266
  */
202
- function reviewFileExtension(figurePath) {
203
- return extname(figurePath).toLowerCase() === ".pdf" ? ".pdf" : ".png";
204
- }
267
+ const MAX_DETAIL_CHARS = 4e3;
268
+ /** Longest overall note a document may carry, in characters. */
269
+ const MAX_SUMMARY_CHARS = 4e3;
270
+ /** Thinnest stroke a mark can be drawn at, as a multiple of the default width. */
271
+ const WEIGHT_MIN = .5;
205
272
  /**
206
- * The marks file this plugin derived before the figure's extension became part of
207
- * the name (`fig1.svg` → `fig1.annot.json`). Read-only: a file an earlier version
208
- * wrote stays readable, so an existing annotation never disappears on upgrade.
209
- * @param figurePath - absolute path of the annotated figure.
210
- * @returns the legacy marks file path beside the figure.
273
+ * Hold a stroke weight inside the range the painter can draw.
274
+ *
275
+ * @param value - the weight to hold.
276
+ * @returns the value, pulled to the nearest end of the range.
211
277
  */
212
- function legacyAnnotationPath(figurePath) {
213
- return join(dirname(figurePath), `${basename(figurePath, extname(figurePath))}${ANNOTATION_SUFFIX}`);
278
+ function clampWeight(value) {
279
+ return Math.min(8, Math.max(WEIGHT_MIN, value));
214
280
  }
281
+ //#endregion
282
+ //#region src/host/route-http.ts
215
283
  /**
216
- * Marks files to read for one figure, most authoritative first: the current name,
217
- * then the legacy one. A figure whose name carries no extension derives both from
218
- * the same name, so the list is deduplicated.
219
- * @param figurePath - absolute path of the annotated figure.
220
- * @returns the candidate marks file paths.
284
+ * Largest accepted request body.
285
+ *
286
+ * Twice the review cap: the artifact travels base64-encoded inside a JSON
287
+ * envelope, and base64 costs four characters for every three bytes. Anything past
288
+ * this is refused before it is buffered, which is what keeps a hostile body from
289
+ * being an allocation.
221
290
  */
222
- function annotationCandidates(figurePath) {
223
- const current = sidecarPaths(figurePath).annotation;
224
- const legacy = legacyAnnotationPath(figurePath);
225
- return current === legacy ? [current] : [current, legacy];
226
- }
291
+ const MAX_BODY_BYTES = 24 * 1024 * 1024 * 2;
227
292
  /**
228
- * Name pattern of the one-page-per-file sidecars this plugin wrote before a paged
229
- * document became a single annotation.
230
- *
231
- * Both spellings are matched — the extension-bearing name
232
- * (`fig1.pdf.p2.annot.json`) and the base-name one an even earlier version wrote
233
- * (`fig1.p2.annot.json`) — and the page number is the pattern's first group.
293
+ * Whether a request carries the plugin guard and a same-origin posture.
234
294
  *
235
- * @param figurePath - absolute path of the annotated figure.
236
- * @returns an anchored, case-insensitive pattern over one directory entry's name.
295
+ * A browser that does not send `Sec-Fetch-Site` at all cannot be origin-checked,
296
+ * and the header alone is still a preflight requirement for any cross-origin
297
+ * attempt — so an absent value passes and a present one must be same-origin.
298
+ * @param req - incoming request.
299
+ * @returns true when the request may proceed.
237
300
  */
238
- function legacyPagePattern(figurePath) {
239
- const extension = extname(figurePath);
240
- const alternatives = [.../* @__PURE__ */ new Set([basename(figurePath), basename(figurePath, extension)])].map(escapeForRegExp).join("|");
241
- return new RegExp(`^(?:${alternatives})\\.p(\\d+)${escapeForRegExp(ANNOTATION_SUFFIX)}$`, "iu");
301
+ function guarded(req) {
302
+ if (req.headers["x-dsh-figmark"] === void 0) return false;
303
+ const site = req.headers["sec-fetch-site"];
304
+ if (typeof site !== "string") return true;
305
+ return site === "same-origin" || site === "none";
242
306
  }
243
- /** Escape one literal so it matches itself inside a regular expression. */
244
- function escapeForRegExp(value) {
245
- return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
307
+ /**
308
+ * Write one JSON response.
309
+ * @param res - response to own.
310
+ * @param status - HTTP status code.
311
+ * @param body - JSON-serializable payload.
312
+ */
313
+ function sendJson(res, status, body) {
314
+ const text = JSON.stringify(body);
315
+ res.writeHead(status, {
316
+ "content-type": "application/json; charset=utf-8",
317
+ "cache-control": "no-store"
318
+ });
319
+ res.end(text);
246
320
  }
247
321
  /**
248
- * Whether a stored document is the annotation of this very figure.
249
- *
250
- * A sidecar sits beside its figure and both names derive from the figure path, so
251
- * the file name — extension included, and case-insensitive because one file may be
252
- * spelled `FIG1.SVG` — is the whole identity. The check matters most for the legacy
253
- * name, which two figures sharing a base name also share.
254
- *
255
- * @param document - stored document to test.
256
- * @param figurePath - absolute path of the figure being opened.
257
- * @returns true when the document names this figure.
322
+ * Write one refusal.
323
+ * @param res - response to own.
324
+ * @param status - HTTP status code.
325
+ * @param error - what went wrong, in the Host's own words.
258
326
  */
259
- function annotationTargetsFigure(document, figurePath) {
260
- return basename(document.figure.path).toLowerCase() === basename(figurePath).toLowerCase();
327
+ function sendFailure(res, status, error) {
328
+ sendJson(res, status, {
329
+ ok: false,
330
+ error
331
+ });
332
+ }
333
+ /**
334
+ * Read a request body with a byte cap.
335
+ * @param req - incoming request.
336
+ * @returns the body text.
337
+ * @throws {Error} when the body exceeds the cap.
338
+ */
339
+ async function readBody(req) {
340
+ const chunks = [];
341
+ let total = 0;
342
+ for await (const chunk of req) {
343
+ const buffer = chunk;
344
+ total += buffer.length;
345
+ if (total > MAX_BODY_BYTES) throw new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`);
346
+ chunks.push(buffer);
347
+ }
348
+ return Buffer.concat(chunks).toString("utf8");
261
349
  }
262
350
  //#endregion
263
- //#region src/shared/annotation.ts
264
- /** Thinnest stroke a mark can be drawn at, as a multiple of the default width. */
265
- const WEIGHT_MIN = .5;
351
+ //#region src/host/review-payload.ts
352
+ /** First bytes of every PDF file, used to tell one payload from another. */
353
+ const PDF_HEADER = "%PDF-";
266
354
  /**
267
- * Hold a stroke weight inside the range the painter can draw.
355
+ * Read the one flattened review artifact a save may carry.
268
356
  *
269
- * @param value - the weight to hold.
270
- * @returns the value, pulled to the nearest end of the range.
357
+ * The kind is taken from the payload, and the store refuses a pair that
358
+ * contradicts the figure's own suffix — so a caller cannot name PNG bytes as the
359
+ * review of a PDF.
360
+ *
361
+ * @param body - parsed request body.
362
+ * @returns the artifact, or undefined when the save carried none.
363
+ * @throws {Error} when both payloads are present, or the PDF bytes are not a PDF.
271
364
  */
272
- function clampWeight(value) {
273
- return Math.min(8, Math.max(WEIGHT_MIN, value));
365
+ function readReview(body) {
366
+ const image = typeof body["reviewImage"] === "string" ? body["reviewImage"] : "";
367
+ const pdf = typeof body["annotatedPdf"] === "string" ? body["annotatedPdf"] : "";
368
+ if (image !== "" && pdf !== "") throw new Error("a save carries one review artifact, not both");
369
+ if (pdf !== "") {
370
+ const bytes = new Uint8Array(Buffer.from(pdf, "base64"));
371
+ if (!Buffer.from(bytes.subarray(0, 5)).toString("latin1").startsWith(PDF_HEADER)) throw new Error("the annotated document is not a PDF");
372
+ return {
373
+ kind: "pdf",
374
+ bytes
375
+ };
376
+ }
377
+ if (image !== "") return {
378
+ kind: "image",
379
+ bytes: new Uint8Array(Buffer.from(image, "base64"))
380
+ };
274
381
  }
275
382
  //#endregion
276
- //#region src/host/store.ts
383
+ //#region src/host/store-schema.ts
277
384
  /**
278
- * Reading and writing one figure's annotation sidecar.
385
+ * The wire schema of one annotation document.
279
386
  *
280
- * The marks file is JSON owned by this plugin; the review artifact is the same
281
- * marks applied to the figure (a PNG for a single-surface figure, a copy of the
282
- * document carrying native PDF annotations for one that has pages). Both are
283
- * written through a temporary file and a rename, so a reader never observes a
284
- * half-written document.
285
- * @module dsh-figmark/host/store
387
+ * Everything that crosses the HTTP boundary is JSON somebody else wrote, so it is
388
+ * validated field by field before any of it reaches disk. This module owns that
389
+ * one job: turning unknown JSON into {@link AnnotationDocument} or refusing it
390
+ * with a message that names the field. It deliberately knows nothing about files
391
+ * — the read and write modules call it, never the other way round.
392
+ *
393
+ * The two figure shapes are held apart here rather than tolerated downstream: a
394
+ * document is either one surface (width and height) or a paged one (pageCount),
395
+ * and a mark may name a page only when its figure has pages. A reader that got
396
+ * both would have to guess which coordinate space the marks live in.
397
+ * @module dsh-figmark/host/store-schema
286
398
  */
287
- /** Maximum accepted sidecar size; a document larger than this is not one we wrote. */
288
- const MAX_ANNOTATION_BYTES = 4 * 1024 * 1024;
289
- /** Maximum accepted flattened review artifact. */
290
- const MAX_REVIEW_BYTES = 24 * 1024 * 1024;
291
399
  /** Longest element selector one anchor may carry. */
292
400
  const MAX_SELECTOR_CHARS = 400;
401
+ /**
402
+ * A note as it is stored: trimmed, and refused when it is longer than a paragraph.
403
+ *
404
+ * The cap is enforced rather than truncated. A writer that silently cut a pasted
405
+ * paragraph would hand the reader back a different sentence than the one they
406
+ * wrote, with no way to tell which half survived; refusing says so and keeps the
407
+ * screen's copy intact. Absent and empty are the same thing — an empty note is no
408
+ * note, and writing one would make the file say something the reader did not.
409
+ * @param value - the field as it arrived.
410
+ * @param what - which field, for the refusal message.
411
+ * @returns the note, or undefined when there is none.
412
+ * @throws {Error} when the note is longer than a paragraph.
413
+ */
414
+ function readNote(value, what, max) {
415
+ if (typeof value !== "string") return void 0;
416
+ if (value.length > max) throw new Error(`the annotation ${what} is longer than ${String(max)} characters`);
417
+ const trimmed = value.trim();
418
+ return trimmed === "" ? void 0 : trimmed;
419
+ }
293
420
  /** Kinds a mark may declare. */
294
421
  const MARK_KINDS = [
295
422
  "arrow",
296
423
  "rect",
297
424
  "ellipse",
298
425
  "pen",
299
- "point"
426
+ "point",
427
+ "element"
300
428
  ];
301
- /** Content hash of one file. */
302
- async function fileDigest(path) {
303
- const bytes = await readFile(path);
304
- return createHash("sha256").update(bytes).digest("hex");
305
- }
306
429
  /**
307
430
  * Whether a value is a finite figure coordinate pair.
308
431
  * @param value - candidate value from the wire.
@@ -340,16 +463,22 @@ function readAnchor(value) {
340
463
  };
341
464
  }
342
465
  /**
343
- * Read one optional rotation from the wire.
466
+ * Read one optional stroke weight from the wire.
344
467
  * @param value - the field as it arrived.
345
- * @returns the angle in degrees, or undefined when absent.
346
- * @throws {Error} when the field is present but is not a usable angle.
468
+ * @returns the weight, or undefined when absent.
469
+ * @throws {Error} when the field is present but is not a usable multiple.
347
470
  */
348
471
  function readWeight(value) {
349
472
  if (value === void 0) return void 0;
350
473
  if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("annotation mark weight must be a finite multiple of the default width");
351
474
  return clampWeight(value);
352
475
  }
476
+ /**
477
+ * Read one optional rotation from the wire.
478
+ * @param value - the field as it arrived.
479
+ * @returns the angle in degrees, or undefined when absent.
480
+ * @throws {Error} when the field is present but is not a finite number.
481
+ */
353
482
  function readRotation(value) {
354
483
  if (value === void 0) return void 0;
355
484
  if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("annotation mark rotation must be a finite number of degrees");
@@ -369,6 +498,19 @@ function readPositiveInteger(raw, field) {
369
498
  return value;
370
499
  }
371
500
  /**
501
+ * Read one optional positive size from the wire.
502
+ * @param raw - the figure object as it arrived.
503
+ * @param field - field name, used in the failure message.
504
+ * @returns the size, or undefined when the field is absent.
505
+ * @throws {Error} when the field is present but is not a positive number.
506
+ */
507
+ function readSize(raw, field) {
508
+ const value = raw[field];
509
+ if (value === void 0) return void 0;
510
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`annotation figure ${field} must be a positive number`);
511
+ return value;
512
+ }
513
+ /**
372
514
  * Validate one mark from the wire.
373
515
  *
374
516
  * A mark names the page it was drawn on exactly when its figure is a paged
@@ -396,12 +538,13 @@ function readMark(value, index, figure) {
396
538
  const anchor = readAnchor(raw["anchor"]);
397
539
  const rotation = readRotation(raw["rotation"]);
398
540
  const weight = readWeight(raw["weight"]);
541
+ const detail = readNote(raw["detail"], "mark note", MAX_DETAIL_CHARS);
399
542
  return {
400
543
  id: raw["id"],
401
544
  kind,
402
545
  color: raw["color"],
403
546
  points,
404
- ...typeof raw["detail"] === "string" ? { detail: raw["detail"] } : {},
547
+ ...detail === void 0 ? {} : { detail },
405
548
  ...page === void 0 ? {} : { page },
406
549
  ...rotation === void 0 ? {} : { rotation },
407
550
  ...weight === void 0 ? {} : { weight },
@@ -409,66 +552,187 @@ function readMark(value, index, figure) {
409
552
  };
410
553
  }
411
554
  /**
412
- * Validate a whole annotation document from the wire.
413
- * @param value - parsed request body.
414
- * @returns the validated document.
415
- * @throws {Error} when the body is not a document this plugin wrote.
555
+ * Validate a whole annotation document from the wire.
556
+ * @param value - parsed request body.
557
+ * @returns the validated document.
558
+ * @throws {Error} when the body is not a document this plugin wrote.
559
+ */
560
+ function readAnnotationDocument(value) {
561
+ if (typeof value !== "object" || value === null) throw new Error("annotation document must be an object");
562
+ const raw = value;
563
+ if (raw["version"] !== 1) throw new Error(`annotation document version ${JSON.stringify(raw["version"])} is not 1`);
564
+ const figure = raw["figure"];
565
+ if (typeof figure !== "object" || figure === null) throw new Error("annotation document needs a figure");
566
+ const figureRaw = figure;
567
+ for (const field of [
568
+ "address",
569
+ "path",
570
+ "mediaType",
571
+ "sha256"
572
+ ]) if (typeof figureRaw[field] !== "string" || figureRaw[field] === "") throw new Error(`annotation figure needs ${field}`);
573
+ const width = readSize(figureRaw, "width");
574
+ const height = readSize(figureRaw, "height");
575
+ if (width === void 0 !== (height === void 0)) throw new Error("annotation figure needs both its width and its height, or neither");
576
+ const pageCount = readPositiveInteger(figureRaw, "pageCount");
577
+ if (pageCount !== void 0 && width !== void 0) throw new Error("annotation figure is a paged document, so it cannot carry width and height");
578
+ const marks = raw["marks"];
579
+ if (!Array.isArray(marks)) throw new Error("annotation document needs a marks array");
580
+ const updatedAt = typeof raw["updatedAt"] === "string" ? raw["updatedAt"] : (/* @__PURE__ */ new Date()).toISOString();
581
+ const summary = readNote(raw["summary"], "document note", MAX_SUMMARY_CHARS);
582
+ return {
583
+ version: 1,
584
+ figure: {
585
+ address: figureRaw["address"],
586
+ path: figureRaw["path"],
587
+ mediaType: figureRaw["mediaType"],
588
+ sha256: figureRaw["sha256"],
589
+ ...width !== void 0 && height !== void 0 ? {
590
+ width,
591
+ height
592
+ } : {},
593
+ ...pageCount === void 0 ? {} : { pageCount }
594
+ },
595
+ createdAt: typeof raw["createdAt"] === "string" ? raw["createdAt"] : updatedAt,
596
+ updatedAt,
597
+ marks: marks.map((mark, index) => readMark(mark, index, {
598
+ ...width === void 0 ? {} : { width },
599
+ ...pageCount === void 0 ? {} : { pageCount }
600
+ })),
601
+ ...summary === void 0 ? {} : { summary }
602
+ };
603
+ }
604
+ //#endregion
605
+ //#region src/host/sidecar.ts
606
+ /**
607
+ * Annotation files beside one figure.
608
+ *
609
+ * An annotation never modifies the figure: it is written to sibling files
610
+ * (`<name>.annot.json` for the marks and `<name>.annotated.png` — or
611
+ * `<name>.annotated.pdf` for a document that has pages — for the flattened
612
+ * review artifact). One figure is one document, however many pages it has, so a
613
+ * paged document's marks share a single sidecar and name their page inside it.
614
+ *
615
+ * The name keeps the figure's own extension (`fig1.svg` → `fig1.svg.annot.json`):
616
+ * `fig1.svg` and `fig1.png` in one directory are two different documents, and marks
617
+ * drawn on one of them mean nothing on the other, so they must never share a
618
+ * sidecar. Every name is derived from the figure path, so the plugin can never be
619
+ * asked to write an arbitrary location.
620
+ * @module dsh-figmark/host/sidecar
621
+ */
622
+ /** Suffix appended to a figure's name for the marks file. */
623
+ const ANNOTATION_SUFFIX = ".annot.json";
624
+ /** Suffix appended to a figure's name for the flattened review artifact. */
625
+ const ANNOTATED_SUFFIX = ".annotated";
626
+ /**
627
+ * Resolve one figure's sidecar paths.
628
+ * @param figurePath - absolute path of the annotated figure.
629
+ * @returns the sidecar paths; both live in the figure's own directory.
630
+ */
631
+ function sidecarPaths(figurePath) {
632
+ const name = basename(figurePath);
633
+ const directory = dirname(figurePath);
634
+ const reviewExtension = reviewFileExtension(figurePath);
635
+ return {
636
+ annotation: join(directory, `${name}${ANNOTATION_SUFFIX}`),
637
+ review: join(directory, `${name}${ANNOTATED_SUFFIX}${reviewExtension}`)
638
+ };
639
+ }
640
+ /**
641
+ * Suffix of the flattened review artifact for one figure.
642
+ *
643
+ * A paged document is reviewed as a document — the page marks travel as native
644
+ * PDF annotations in a copy of the original — while a single-surface figure is
645
+ * reviewed as the picture it is.
646
+ * @param figurePath - absolute path of the annotated figure.
647
+ * @returns the file extension, dot included.
648
+ */
649
+ function reviewFileExtension(figurePath) {
650
+ return isPagedFigurePath(figurePath) ? ".pdf" : ".png";
651
+ }
652
+ /**
653
+ * The marks file this plugin derived before the figure's extension became part of
654
+ * the name (`fig1.svg` → `fig1.annot.json`). Read-only: a file an earlier version
655
+ * wrote stays readable, so an existing annotation never disappears on upgrade.
656
+ * @param figurePath - absolute path of the annotated figure.
657
+ * @returns the legacy marks file path beside the figure.
658
+ */
659
+ function legacyAnnotationPath(figurePath) {
660
+ return join(dirname(figurePath), `${basename(figurePath, extname(figurePath))}${ANNOTATION_SUFFIX}`);
661
+ }
662
+ /**
663
+ * Marks files to read for one figure, most authoritative first: the current name,
664
+ * then the legacy one. A figure whose name carries no extension derives both from
665
+ * the same name, so the list is deduplicated.
666
+ * @param figurePath - absolute path of the annotated figure.
667
+ * @returns the candidate marks file paths.
668
+ */
669
+ function annotationCandidates(figurePath) {
670
+ const current = sidecarPaths(figurePath).annotation;
671
+ const legacy = legacyAnnotationPath(figurePath);
672
+ return current === legacy ? [current] : [current, legacy];
673
+ }
674
+ /**
675
+ * Name pattern of the one-page-per-file sidecars this plugin wrote before a paged
676
+ * document became a single annotation.
677
+ *
678
+ * Both spellings are matched — the extension-bearing name
679
+ * (`fig1.pdf.p2.annot.json`) and the base-name one an even earlier version wrote
680
+ * (`fig1.p2.annot.json`) — and the page number is the pattern's first group.
681
+ *
682
+ * @param figurePath - absolute path of the annotated figure.
683
+ * @returns an anchored, case-insensitive pattern over one directory entry's name.
684
+ */
685
+ function legacyPagePattern(figurePath) {
686
+ const extension = extname(figurePath);
687
+ const alternatives = [.../* @__PURE__ */ new Set([basename(figurePath), basename(figurePath, extension)])].map(escapeForRegExp).join("|");
688
+ return new RegExp(`^(?:${alternatives})\\.p(\\d+)${escapeForRegExp(ANNOTATION_SUFFIX)}$`, "iu");
689
+ }
690
+ /** Escape one literal so it matches itself inside a regular expression. */
691
+ function escapeForRegExp(value) {
692
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
693
+ }
694
+ /**
695
+ * Whether a stored document is the annotation of this very figure.
696
+ *
697
+ * A sidecar sits beside its figure and both names derive from the figure path, so
698
+ * the file name — extension included, and case-insensitive because one file may be
699
+ * spelled `FIG1.SVG` — is the whole identity. The check matters most for the legacy
700
+ * name, which two figures sharing a base name also share.
701
+ *
702
+ * @param document - stored document to test.
703
+ * @param figurePath - absolute path of the figure being opened.
704
+ * @returns true when the document names this figure.
416
705
  */
417
- function readAnnotationDocument(value) {
418
- if (typeof value !== "object" || value === null) throw new Error("annotation document must be an object");
419
- const raw = value;
420
- if (raw["version"] !== 1) throw new Error(`annotation document version ${JSON.stringify(raw["version"])} is not 1`);
421
- const figure = raw["figure"];
422
- if (typeof figure !== "object" || figure === null) throw new Error("annotation document needs a figure");
423
- const figureRaw = figure;
424
- for (const field of [
425
- "address",
426
- "path",
427
- "mediaType",
428
- "sha256"
429
- ]) if (typeof figureRaw[field] !== "string" || figureRaw[field] === "") throw new Error(`annotation figure needs ${field}`);
430
- const width = readSize(figureRaw, "width");
431
- const height = readSize(figureRaw, "height");
432
- if (width === void 0 !== (height === void 0)) throw new Error("annotation figure needs both its width and its height, or neither");
433
- const pageCount = readPositiveInteger(figureRaw, "pageCount");
434
- if (pageCount !== void 0 && width !== void 0) throw new Error("annotation figure is a paged document, so it cannot carry width and height");
435
- const marks = raw["marks"];
436
- if (!Array.isArray(marks)) throw new Error("annotation document needs a marks array");
437
- const updatedAt = typeof raw["updatedAt"] === "string" ? raw["updatedAt"] : (/* @__PURE__ */ new Date()).toISOString();
438
- return {
439
- version: 1,
440
- figure: {
441
- address: figureRaw["address"],
442
- path: figureRaw["path"],
443
- mediaType: figureRaw["mediaType"],
444
- sha256: figureRaw["sha256"],
445
- ...width !== void 0 && height !== void 0 ? {
446
- width,
447
- height
448
- } : {},
449
- ...pageCount === void 0 ? {} : { pageCount }
450
- },
451
- createdAt: typeof raw["createdAt"] === "string" ? raw["createdAt"] : updatedAt,
452
- updatedAt,
453
- marks: marks.map((mark, index) => readMark(mark, index, {
454
- ...width === void 0 ? {} : { width },
455
- ...pageCount === void 0 ? {} : { pageCount }
456
- })),
457
- ...typeof raw["summary"] === "string" && raw["summary"] !== "" ? { summary: raw["summary"] } : {}
458
- };
706
+ function annotationTargetsFigure(document, figurePath) {
707
+ return basename(document.figure.path).toLowerCase() === basename(figurePath).toLowerCase();
459
708
  }
709
+ //#endregion
710
+ //#region src/host/store-read.ts
460
711
  /**
461
- * Read one optional positive size from the wire.
462
- * @param raw - the figure object as it arrived.
463
- * @param field - field name, used in the failure message.
464
- * @returns the size, or undefined when the field is absent.
465
- * @throws {Error} when the field is present but is not a positive number.
712
+ * Reading one figure's annotation sidecar, including the shapes older versions wrote.
713
+ *
714
+ * Three stored shapes have to read back as one document, because a figure
715
+ * annotated by an earlier version must not lose its marks on upgrade: the current
716
+ * sidecar name, the legacy base-name sidecar, and the per-page files a paged
717
+ * document was once split into. All of them are read-only here — a save always
718
+ * writes the current name — so the upgrade is invisible to the reader.
719
+ *
720
+ * Nothing in this module decides where a sidecar lives: the candidate names come
721
+ * from the sidecar module, which derives every one of them from the figure path.
722
+ * @module dsh-figmark/host/store-read
466
723
  */
467
- function readSize(raw, field) {
468
- const value = raw[field];
469
- if (value === void 0) return void 0;
470
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`annotation figure ${field} must be a positive number`);
471
- return value;
724
+ /**
725
+ * Content hash of one file.
726
+ *
727
+ * Read whole rather than streamed: the caller compares it against the hash the
728
+ * browser recorded when the marks were drawn, and a figure large enough for the
729
+ * difference to matter is not one this plugin renders in a sidebar.
730
+ * @param path - absolute path of the file.
731
+ * @returns the SHA-256 digest as lower-case hex.
732
+ */
733
+ async function fileDigest(path) {
734
+ const bytes = await readFile(path);
735
+ return createHash("sha256").update(bytes).digest("hex");
472
736
  }
473
737
  /**
474
738
  * Read one figure's saved annotation.
@@ -478,15 +742,27 @@ function readSize(raw, field) {
478
742
  * that share a base name would otherwise read each other's marks.
479
743
  *
480
744
  * @param figurePath - absolute path of the figure.
481
- * @returns the document, or null when none was stored or it is unreadable.
745
+ * @returns the document and any sidecar that was present but unusable.
482
746
  */
483
747
  async function readAnnotation(figurePath) {
748
+ const unreadable = [];
484
749
  for (const candidate of annotationCandidates(figurePath)) {
485
- const document = await readSidecar(candidate);
486
- if (document !== null && annotationTargetsFigure(document, figurePath)) return document;
750
+ const read = await readSidecar(candidate);
751
+ if (read === "unreadable") unreadable.push(candidate);
752
+ else if (read !== null && annotationTargetsFigure(read, figurePath)) return {
753
+ annotation: read,
754
+ unreadable
755
+ };
487
756
  }
488
- if (figureMediaType(figurePath) !== "application/pdf") return null;
489
- return await readLegacyPages(figurePath);
757
+ if (!isPagedFigurePath(figurePath)) return {
758
+ annotation: null,
759
+ unreadable
760
+ };
761
+ const merged = await readLegacyPages(figurePath);
762
+ return {
763
+ annotation: merged.annotation,
764
+ unreadable: [...unreadable, ...merged.unreadable]
765
+ };
490
766
  }
491
767
  /**
492
768
  * Restate the per-page sidecars an earlier version wrote as one document.
@@ -501,7 +777,7 @@ async function readAnnotation(figurePath) {
501
777
  * time it saves.
502
778
  *
503
779
  * @param figurePath - absolute path of the figure.
504
- * @returns the merged document, or null when no readable page file exists.
780
+ * @returns the merged document, when any page file was readable.
505
781
  */
506
782
  async function readLegacyPages(figurePath) {
507
783
  const directory = dirname(figurePath);
@@ -510,51 +786,64 @@ async function readLegacyPages(figurePath) {
510
786
  try {
511
787
  entries = await readdir(directory);
512
788
  } catch {
513
- return null;
789
+ return {
790
+ annotation: null,
791
+ unreadable: []
792
+ };
514
793
  }
515
794
  const pages = entries.map((name) => ({
516
795
  name,
517
796
  page: Number(pattern.exec(name)?.[1])
518
797
  })).filter((entry) => Number.isInteger(entry.page) && entry.page > 0).sort((left, right) => left.page - right.page);
519
798
  const read = [];
799
+ const unreadable = [];
520
800
  for (const entry of pages) {
521
- const document = await readSidecar(join(directory, entry.name));
522
- if (document !== null && annotationTargetsFigure(document, figurePath)) read.push({
801
+ const path = join(directory, entry.name);
802
+ const document = await readSidecar(path);
803
+ if (document === "unreadable") unreadable.push(path);
804
+ else if (document !== null && annotationTargetsFigure(document, figurePath)) read.push({
523
805
  page: entry.page,
524
806
  document
525
807
  });
526
808
  }
527
809
  const first = read[0];
528
- if (first === void 0) return null;
810
+ if (first === void 0) return {
811
+ annotation: null,
812
+ unreadable
813
+ };
529
814
  let summary;
530
815
  for (const entry of read) if (entry.document.summary !== void 0) summary = entry.document.summary;
531
816
  return {
532
- version: 1,
533
- figure: {
534
- address: first.document.figure.address,
535
- path: first.document.figure.path,
536
- mediaType: first.document.figure.mediaType,
537
- sha256: first.document.figure.sha256,
538
- pageCount: read.reduce((highest, entry) => Math.max(highest, entry.page), first.page)
817
+ annotation: {
818
+ version: 1,
819
+ figure: {
820
+ address: first.document.figure.address,
821
+ path: first.document.figure.path,
822
+ mediaType: first.document.figure.mediaType,
823
+ sha256: first.document.figure.sha256,
824
+ pageCount: read.reduce((highest, entry) => Math.max(highest, entry.page), first.page)
825
+ },
826
+ createdAt: read.reduce((earliest, entry) => entry.document.createdAt < earliest ? entry.document.createdAt : earliest, first.document.createdAt),
827
+ updatedAt: read.reduce((latest, entry) => entry.document.updatedAt > latest ? entry.document.updatedAt : latest, first.document.updatedAt),
828
+ marks: read.flatMap((entry) => entry.document.marks.map((mark) => ({
829
+ ...mark,
830
+ page: entry.page
831
+ }))),
832
+ ...summary === void 0 ? {} : { summary }
539
833
  },
540
- createdAt: read.reduce((earliest, entry) => entry.document.createdAt < earliest ? entry.document.createdAt : earliest, first.document.createdAt),
541
- updatedAt: read.reduce((latest, entry) => entry.document.updatedAt > latest ? entry.document.updatedAt : latest, first.document.updatedAt),
542
- marks: read.flatMap((entry) => entry.document.marks.map((mark) => ({
543
- ...mark,
544
- page: entry.page
545
- }))),
546
- ...summary === void 0 ? {} : { summary }
834
+ unreadable
547
835
  };
548
836
  }
549
837
  /**
550
838
  * Parse one marks file into this plugin's document shape.
551
839
  * @param sidecarPath - absolute path of the marks file.
552
- * @returns the document, or null when the file is missing, oversized, or unreadable.
840
+ * @returns the document, null when there is no readable file, or `'unreadable'`
841
+ * when the file is there and is not something this plugin will read.
553
842
  */
554
843
  async function readSidecar(sidecarPath) {
555
844
  let text;
556
845
  try {
557
- if ((await stat(sidecarPath)).size > MAX_ANNOTATION_BYTES) return null;
846
+ if ((await stat(sidecarPath)).size > 4194304) return "unreadable";
558
847
  text = await readFile(sidecarPath, "utf8");
559
848
  } catch {
560
849
  return null;
@@ -611,6 +900,45 @@ function bridgeV2Document(raw) {
611
900
  summary: raw["summary"]
612
901
  };
613
902
  }
903
+ //#endregion
904
+ //#region src/host/store-write.ts
905
+ /**
906
+ * Writing one figure's annotation sidecar, and removing it again.
907
+ *
908
+ * The two files are written through a temporary name and a rename so a reader
909
+ * never observes a half-written document: a preview opening while an autosave
910
+ * runs reads either the previous document or the new one, never a truncated one.
911
+ *
912
+ * Both file names are derived here from the figure path, never passed in, and the
913
+ * review artifact's kind is checked against the figure's own kind before anything
914
+ * is touched — a PNG review of a PDF would be a file no reader could open.
915
+ * @module dsh-figmark/host/store-write
916
+ */
917
+ /**
918
+ * How many scratch files this process has named.
919
+ *
920
+ * The clock is not enough on its own: two saves started in the same millisecond
921
+ * share a pid and a millisecond, took the same scratch name, and one of them renamed
922
+ * the other's half-written bytes into place — or found nothing to rename and failed a
923
+ * save that had every right to succeed. A counter inside the process cannot repeat,
924
+ * and the pid and clock still keep two *processes* apart.
925
+ */
926
+ let scratchCount = 0;
927
+ /**
928
+ * Replace one file atomically.
929
+ *
930
+ * The temporary name carries the pid, a counter and the clock so two saves that
931
+ * overlap cannot pick the same scratch file; the rename is the moment the new
932
+ * content becomes observable.
933
+ * @param path - absolute path of the file to replace.
934
+ * @param content - text or bytes to write.
935
+ */
936
+ async function writeAtomically(path, content) {
937
+ scratchCount += 1;
938
+ const temporary = `${path}.tmp-${process.pid}-${scratchCount}-${Date.now()}`;
939
+ await writeFile(temporary, content, typeof content === "string" ? "utf8" : void 0);
940
+ await rename(temporary, path);
941
+ }
614
942
  /**
615
943
  * Write one figure's annotation, and optionally its flattened review artifact.
616
944
  * @param figurePath - absolute path of the annotated figure.
@@ -621,18 +949,14 @@ function bridgeV2Document(raw) {
621
949
  */
622
950
  async function writeAnnotation(figurePath, document, review) {
623
951
  const paths = sidecarPaths(figurePath);
624
- if (review !== void 0 && review.kind !== (reviewFileExtension(figurePath) === ".pdf" ? "pdf" : "image")) throw new Error(`a ${reviewFileExtension(figurePath)} review artifact cannot be written from ${review.kind} bytes`);
952
+ if (review !== void 0 && review.kind !== (isPagedFigurePath(figurePath) ? "pdf" : "image")) throw new Error(`a ${reviewFileExtension(figurePath)} review artifact cannot be written from ${review.kind} bytes`);
625
953
  await mkdir(dirname(paths.annotation), { recursive: true });
626
- const temporary = `${paths.annotation}.tmp-${process.pid}-${Date.now()}`;
627
- await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, "utf8");
628
- await rename(temporary, paths.annotation);
954
+ await writeAtomically(paths.annotation, `${JSON.stringify(document, null, 2)}\n`);
629
955
  if (review === void 0) return {
630
956
  paths,
631
957
  wroteReview: false
632
958
  };
633
- const reviewTemporary = `${paths.review}.tmp-${process.pid}-${Date.now()}`;
634
- await writeFile(reviewTemporary, review.bytes);
635
- await rename(reviewTemporary, paths.review);
959
+ await writeAtomically(paths.review, review.bytes);
636
960
  return {
637
961
  paths,
638
962
  wroteReview: true
@@ -654,7 +978,7 @@ async function writeAnnotation(figurePath, document, review) {
654
978
  async function removeAnnotation(figurePath) {
655
979
  const paths = sidecarPaths(figurePath);
656
980
  let removed = false;
657
- for (const path of [paths.annotation, paths.review]) try {
981
+ for (const path of await allSidecarNames(figurePath)) try {
658
982
  await rm(path, { force: false });
659
983
  removed = true;
660
984
  } catch (error) {
@@ -665,146 +989,113 @@ async function removeAnnotation(figurePath) {
665
989
  removed
666
990
  };
667
991
  }
668
- //#endregion
669
- //#region src/host/routes.ts
670
992
  /**
671
- * HTTP routes of the annotator.
993
+ * Every file this plugin may have written beside one figure, oldest spelling first.
672
994
  *
673
- * The routes live outside the `/api` channel, so they carry their own guard: a
674
- * required request header (which a cross-origin form or image request cannot
675
- * set without a preflight this plugin never answers) plus an origin check on
676
- * browsers that send `Sec-Fetch-Site`. Writes are limited to the two sidecar
677
- * files derived from a figure that already exists.
678
- * @module dsh-figmark/host/routes
995
+ * The read path accepts more names than the write path produces: the legacy
996
+ * base-name sidecar (`fig1.annot.json` beside `fig1.svg`), and for a paged document
997
+ * the one-file-per-page sidecars an earlier version wrote. Removing only the two
998
+ * current names left those behind — and because the reader merges them back in, a
999
+ * cleared figure reopened with its marks on it, which is the bug in finding 1.
1000
+ * Whatever the reader will read has to be what a clear removes.
1001
+ * @param figurePath - absolute path of the figure whose sidecars are removed.
1002
+ * @returns the paths to unlink.
679
1003
  */
680
- /** Largest accepted request body. */
681
- const MAX_BODY_BYTES = MAX_REVIEW_BYTES * 2;
682
- /** First bytes of every PDF file, used to tell one payload from another. */
683
- const PDF_HEADER = "%PDF-";
1004
+ async function allSidecarNames(figurePath) {
1005
+ const paths = sidecarPaths(figurePath);
1006
+ const names = [
1007
+ paths.annotation,
1008
+ paths.review,
1009
+ legacyAnnotationPath(figurePath)
1010
+ ];
1011
+ if (isPagedFigurePath(figurePath)) {
1012
+ const pattern = legacyPagePattern(figurePath);
1013
+ let entries;
1014
+ try {
1015
+ entries = await readdir(dirname(figurePath));
1016
+ } catch {
1017
+ entries = [];
1018
+ }
1019
+ for (const entry of entries) if (pattern.test(entry)) names.push(join(dirname(figurePath), entry));
1020
+ }
1021
+ return [...new Set(names)];
1022
+ }
1023
+ //#endregion
1024
+ //#region src/host/routes.ts
684
1025
  /**
685
- * Resolve one request address to an existing figure file.
686
- * @param address - `dsh-resource://file/…` address from the preview tab.
687
- * @param deps - Session lookup used for a relative path.
688
- * @returns the target, or an error message describing why it cannot be annotated.
1026
+ * Resolve the figure one request names, as an answer rather than an exception.
1027
+ * @param address - the address the request carried, empty when it carried none.
1028
+ * @param deps - Session lookup.
1029
+ * @returns the target, or the 404 refusal to send.
689
1030
  */
690
- async function resolveTarget(address, deps) {
691
- const parsed = parseFileAddress(address);
692
- if (parsed === void 0) return {
1031
+ async function targetOf(address, deps) {
1032
+ const resolved = await resolveTarget(address, deps.sessionCwd);
1033
+ return resolved.ok ? {
1034
+ ok: true,
1035
+ body: resolved.target
1036
+ } : {
693
1037
  ok: false,
694
- message: `not a file address: ${address}`
1038
+ status: 404,
1039
+ error: resolved.message
695
1040
  };
696
- let absolute;
697
- if (isAbsolute(parsed.path)) absolute = parsed.path;
698
- else {
699
- const sessionId = parsed.sessionId;
700
- const cwd = sessionId === void 0 ? void 0 : await deps.sessionCwd(sessionId);
701
- if (cwd === void 0) return {
702
- ok: false,
703
- message: `cannot resolve this Session's directory for a relative path: ${address}`
704
- };
705
- absolute = resolve(cwd, parsed.path);
706
- }
707
- if (!isFigurePath(absolute)) return {
708
- ok: false,
709
- message: `not an annotatable figure: ${absolute}`
1041
+ }
1042
+ /**
1043
+ * Read the figure's current hash and its saved annotation.
1044
+ * @param target - the resolved figure.
1045
+ * @returns the read body.
1046
+ */
1047
+ async function readAnswer(target) {
1048
+ const saved = await readAnnotation(target.path);
1049
+ const sha256 = await fileDigest(target.path);
1050
+ return {
1051
+ ok: true,
1052
+ figure: {
1053
+ ...target,
1054
+ sha256
1055
+ },
1056
+ annotation: saved.annotation,
1057
+ ...saved.unreadable.length === 0 ? {} : { unreadable: saved.unreadable }
710
1058
  };
711
- const mediaType = figureMediaType(absolute);
712
- /* v8 ignore next -- isFigurePath already restricted the suffixes figureMediaType covers. */
713
- if (mediaType === void 0) return {
1059
+ }
1060
+ /**
1061
+ * Write the annotation one request carried.
1062
+ * @param target - the resolved figure.
1063
+ * @param raw - the parsed request body.
1064
+ * @returns the save body, or the refusal to send.
1065
+ */
1066
+ async function saveAnswer(target, raw) {
1067
+ const document = readAnnotationDocument(raw["annotation"]);
1068
+ const review = readReview(raw);
1069
+ if (review !== void 0 && review.bytes.byteLength > 25165824) return {
714
1070
  ok: false,
715
- message: `no media type for ${absolute}`
1071
+ status: 413,
1072
+ error: "the review artifact is too large"
716
1073
  };
717
- try {
718
- if (!(await stat(absolute)).isFile()) return {
719
- ok: false,
720
- message: `not a regular file: ${absolute}`
721
- };
722
- } catch {
723
- return {
724
- ok: false,
725
- message: `no such figure: ${absolute}`
726
- };
727
- }
1074
+ const saved = await writeAnnotation(target.path, document, review);
728
1075
  return {
729
1076
  ok: true,
730
- target: {
731
- address,
732
- path: absolute,
733
- mediaType
1077
+ body: {
1078
+ ok: true,
1079
+ annotationPath: saved.paths.annotation,
1080
+ reviewPath: saved.wroteReview ? saved.paths.review : null
734
1081
  }
735
1082
  };
736
1083
  }
737
1084
  /**
738
- * Read the one flattened review artifact a save may carry.
739
- *
740
- * The kind is taken from the payload, and the store refuses a pair that
741
- * contradicts the figure's own suffix — so a caller cannot name PNG bytes as the
742
- * review of a PDF.
743
- *
744
- * @param body - parsed request body.
745
- * @returns the artifact, or undefined when the save carried none.
746
- * @throws {Error} when both payloads are present, or the PDF bytes are not a PDF.
1085
+ * Remove the sidecars of one figure.
1086
+ * @param target - the resolved figure.
1087
+ * @returns the removal body.
747
1088
  */
748
- function readReview(body) {
749
- const image = typeof body["reviewImage"] === "string" ? body["reviewImage"] : "";
750
- const pdf = typeof body["annotatedPdf"] === "string" ? body["annotatedPdf"] : "";
751
- if (image !== "" && pdf !== "") throw new Error("a save carries one review artifact, not both");
752
- if (pdf !== "") {
753
- const bytes = new Uint8Array(Buffer.from(pdf, "base64"));
754
- if (!Buffer.from(bytes.subarray(0, 5)).toString("latin1").startsWith(PDF_HEADER)) throw new Error("the annotated document is not a PDF");
755
- return {
756
- kind: "pdf",
757
- bytes
758
- };
759
- }
760
- if (image !== "") return {
761
- kind: "image",
762
- bytes: new Uint8Array(Buffer.from(image, "base64"))
1089
+ async function removeAnswer(target) {
1090
+ const removed = await removeAnnotation(target.path);
1091
+ return {
1092
+ ok: true,
1093
+ annotationPath: removed.paths.annotation,
1094
+ reviewPath: removed.paths.review,
1095
+ removed: removed.removed
763
1096
  };
764
1097
  }
765
1098
  /**
766
- * Whether a request carries the plugin guard and a same-origin posture.
767
- * @param req - incoming request.
768
- * @returns true when the request may proceed.
769
- */
770
- function guarded(req) {
771
- if (req.headers["x-dsh-figmark"] === void 0) return false;
772
- const site = req.headers["sec-fetch-site"];
773
- if (typeof site !== "string") return true;
774
- return site === "same-origin" || site === "none";
775
- }
776
- /**
777
- * Write one JSON response.
778
- * @param res - response to own.
779
- * @param status - HTTP status code.
780
- * @param body - JSON-serializable payload.
781
- */
782
- function sendJson(res, status, body) {
783
- const text = JSON.stringify(body);
784
- res.writeHead(status, {
785
- "content-type": "application/json; charset=utf-8",
786
- "cache-control": "no-store"
787
- });
788
- res.end(text);
789
- }
790
- /**
791
- * Read a request body with a byte cap.
792
- * @param req - incoming request.
793
- * @returns the body text.
794
- * @throws {Error} when the body exceeds the cap.
795
- */
796
- async function readBody(req) {
797
- const chunks = [];
798
- let total = 0;
799
- for await (const chunk of req) {
800
- const buffer = chunk;
801
- total += buffer.length;
802
- if (total > MAX_BODY_BYTES) throw new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`);
803
- chunks.push(buffer);
804
- }
805
- return Buffer.concat(chunks).toString("utf8");
806
- }
807
- /**
808
1099
  * Build the route handlers this plugin registers.
809
1100
  * @param deps - Session lookup.
810
1101
  * @returns one handler per route path.
@@ -812,92 +1103,59 @@ async function readBody(req) {
812
1103
  function createHandlers(deps) {
813
1104
  const annotation = async (req, res) => {
814
1105
  if (!guarded(req)) {
815
- sendJson(res, 403, {
816
- ok: false,
817
- error: "missing plugin guard header"
818
- });
1106
+ sendFailure(res, 403, "missing plugin guard header");
819
1107
  return;
820
1108
  }
821
- const url = new URL(req.url ?? "/", "http://localhost");
822
1109
  try {
823
- if (req.method === "GET") {
824
- const resolved = await resolveTarget(url.searchParams.get("address") ?? "", deps);
825
- if (!resolved.ok) {
826
- sendJson(res, 404, {
827
- ok: false,
828
- error: resolved.message
829
- });
830
- return;
831
- }
832
- const [sha256, saved] = await Promise.all([fileDigest(resolved.target.path), readAnnotation(resolved.target.path)]);
833
- sendJson(res, 200, {
834
- ok: true,
835
- figure: {
836
- ...resolved.target,
837
- sha256
838
- },
839
- annotation: saved
840
- });
841
- return;
842
- }
843
- if (req.method === "POST") {
844
- const body = JSON.parse(await readBody(req));
845
- const resolved = await resolveTarget(typeof body["address"] === "string" ? body["address"] : "", deps);
846
- if (!resolved.ok) {
847
- sendJson(res, 404, {
848
- ok: false,
849
- error: resolved.message
850
- });
851
- return;
852
- }
853
- const document = readAnnotationDocument(body["annotation"]);
854
- const review = readReview(body);
855
- if (review !== void 0 && review.bytes.byteLength > 25165824) {
856
- sendJson(res, 413, {
857
- ok: false,
858
- error: "the review artifact is too large"
859
- });
860
- return;
861
- }
862
- const saved = await writeAnnotation(resolved.target.path, document, review);
863
- sendJson(res, 200, {
864
- ok: true,
865
- annotationPath: saved.paths.annotation,
866
- reviewPath: saved.wroteReview ? saved.paths.review : null
867
- });
868
- return;
869
- }
870
- if (req.method === "DELETE") {
871
- const resolved = await resolveTarget(url.searchParams.get("address") ?? "", deps);
872
- if (!resolved.ok) {
873
- sendJson(res, 404, {
874
- ok: false,
875
- error: resolved.message
876
- });
877
- return;
878
- }
879
- const removed = await removeAnnotation(resolved.target.path);
880
- sendJson(res, 200, {
881
- ok: true,
882
- annotationPath: removed.paths.annotation,
883
- reviewPath: removed.paths.review,
884
- removed: removed.removed
885
- });
1110
+ const answer = await route(req, new URL(req.url ?? "/", "http://localhost"), deps);
1111
+ if (answer.ok) {
1112
+ sendJson(res, 200, answer.body);
886
1113
  return;
887
1114
  }
888
- sendJson(res, 405, {
889
- ok: false,
890
- error: `method ${req.method ?? ""} is not allowed`
891
- });
1115
+ sendFailure(res, answer.status, answer.error);
892
1116
  } catch (error) {
893
- sendJson(res, 400, {
894
- ok: false,
895
- error: error instanceof Error ? error.message : String(error)
896
- });
1117
+ sendFailure(res, 400, error instanceof Error ? error.message : String(error));
897
1118
  }
898
1119
  };
899
1120
  return { annotation };
900
1121
  }
1122
+ /**
1123
+ * Dispatch one guarded request to the operation its method names.
1124
+ * @param req - incoming request.
1125
+ * @param url - the request's URL.
1126
+ * @param deps - Session lookup.
1127
+ * @returns the answer to send.
1128
+ */
1129
+ async function route(req, url, deps) {
1130
+ const address = url.searchParams.get("address") ?? "";
1131
+ if (req.method === "GET") {
1132
+ const target = await targetOf(address, deps);
1133
+ if (!target.ok) return target;
1134
+ return {
1135
+ ok: true,
1136
+ body: await readAnswer(target.body)
1137
+ };
1138
+ }
1139
+ if (req.method === "POST") {
1140
+ const body = JSON.parse(await readBody(req));
1141
+ const target = await targetOf(typeof body["address"] === "string" ? body["address"] : "", deps);
1142
+ if (!target.ok) return target;
1143
+ return await saveAnswer(target.body, body);
1144
+ }
1145
+ if (req.method === "DELETE") {
1146
+ const target = await targetOf(address, deps);
1147
+ if (!target.ok) return target;
1148
+ return {
1149
+ ok: true,
1150
+ body: await removeAnswer(target.body)
1151
+ };
1152
+ }
1153
+ return {
1154
+ ok: false,
1155
+ status: 405,
1156
+ error: `method ${req.method ?? ""} is not allowed`
1157
+ };
1158
+ }
901
1159
  //#endregion
902
1160
  //#region src/host/session-cwd.ts
903
1161
  /**