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/README.md +3 -3
- package/lib/client.js +7247 -3468
- package/lib/client.pdf.js +48316 -47567
- package/lib/index.js +676 -418
- package/market/index.json +2 -2
- package/package.json +2 -2
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* @module
|
|
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
|
|
181
|
-
* @param
|
|
182
|
-
* @
|
|
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
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
*
|
|
259
|
+
* Longest note one mark may carry, in characters.
|
|
195
260
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* @
|
|
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
|
|
213
|
-
return
|
|
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
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* the
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
236
|
-
*
|
|
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
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
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
|
-
/**
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
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
|
|
260
|
-
|
|
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/
|
|
264
|
-
/**
|
|
265
|
-
const
|
|
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
|
-
*
|
|
355
|
+
* Read the one flattened review artifact a save may carry.
|
|
268
356
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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
|
|
273
|
-
|
|
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
|
-
*
|
|
385
|
+
* The wire schema of one annotation document.
|
|
279
386
|
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
|
466
|
+
* Read one optional stroke weight from the wire.
|
|
344
467
|
* @param value - the field as it arrived.
|
|
345
|
-
* @returns the
|
|
346
|
-
* @throws {Error} when the field is present but is not a usable
|
|
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
|
-
...
|
|
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
|
|
418
|
-
|
|
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
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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
|
|
486
|
-
if (
|
|
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 (
|
|
489
|
-
|
|
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,
|
|
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
|
|
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
|
|
522
|
-
|
|
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
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
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,
|
|
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 >
|
|
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 !== (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
993
|
+
* Every file this plugin may have written beside one figure, oldest spelling first.
|
|
672
994
|
*
|
|
673
|
-
* The
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
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
|
-
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
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
|
|
686
|
-
* @param address -
|
|
687
|
-
* @param deps - Session lookup
|
|
688
|
-
* @returns the target, or
|
|
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
|
|
691
|
-
const
|
|
692
|
-
|
|
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
|
-
|
|
1038
|
+
status: 404,
|
|
1039
|
+
error: resolved.message
|
|
695
1040
|
};
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
1071
|
+
status: 413,
|
|
1072
|
+
error: "the review artifact is too large"
|
|
716
1073
|
};
|
|
717
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
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
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
889
|
-
ok: false,
|
|
890
|
-
error: `method ${req.method ?? ""} is not allowed`
|
|
891
|
-
});
|
|
1115
|
+
sendFailure(res, answer.status, answer.error);
|
|
892
1116
|
} catch (error) {
|
|
893
|
-
|
|
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
|
/**
|