visualproof 0.0.0-stage → 0.2.0

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.
Files changed (146) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
  9. package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
  10. package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
  11. package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
  12. package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
  13. package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
  14. package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
  15. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
  16. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  17. package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  18. package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  19. package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
  20. package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
  21. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  22. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  23. package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  24. package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  25. package/dist/extractor/Extract.lean +1180 -0
  26. package/dist/extractor/Usage.lean +900 -0
  27. package/dist/mcp/visualproof-mcp.mjs +476 -0
  28. package/dist/server/access.js +95 -0
  29. package/dist/server/agent.js +3270 -0
  30. package/dist/server/agentdocs.js +35 -0
  31. package/dist/server/agentlibrary.js +202 -0
  32. package/dist/server/agentnotes.js +701 -0
  33. package/dist/server/agentviews.js +703 -0
  34. package/dist/server/askfollow.js +286 -0
  35. package/dist/server/boardspots.js +159 -0
  36. package/dist/server/dirs.js +175 -0
  37. package/dist/server/doctor.js +186 -0
  38. package/dist/server/events.js +198 -0
  39. package/dist/server/export.js +420 -0
  40. package/dist/server/extract.js +204 -0
  41. package/dist/server/fetchurl.js +321 -0
  42. package/dist/server/figures.js +158 -0
  43. package/dist/server/history.js +780 -0
  44. package/dist/server/historyapi.js +304 -0
  45. package/dist/server/hover.js +595 -0
  46. package/dist/server/identity.js +114 -0
  47. package/dist/server/invites.js +241 -0
  48. package/dist/server/layoutthread.js +51 -0
  49. package/dist/server/library.js +600 -0
  50. package/dist/server/libraryroute.js +211 -0
  51. package/dist/server/live.js +1101 -0
  52. package/dist/server/lostfound.js +199 -0
  53. package/dist/server/main.js +1406 -0
  54. package/dist/server/mcptools.js +496 -0
  55. package/dist/server/pastdoc.js +73 -0
  56. package/dist/server/pipeline.js +159 -0
  57. package/dist/server/poppler.js +251 -0
  58. package/dist/server/presence.js +252 -0
  59. package/dist/server/project.js +222 -0
  60. package/dist/server/prompt.js +53 -0
  61. package/dist/server/readonly.js +152 -0
  62. package/dist/server/readonlygate.js +123 -0
  63. package/dist/server/renames.js +268 -0
  64. package/dist/server/review.js +334 -0
  65. package/dist/server/reviewapi.js +188 -0
  66. package/dist/server/snapshot.js +50 -0
  67. package/dist/server/sourcestore.js +473 -0
  68. package/dist/server/status.js +360 -0
  69. package/dist/server/talk.js +476 -0
  70. package/dist/server/tokens.js +221 -0
  71. package/dist/server/usage.js +501 -0
  72. package/dist/server/versions.js +82 -0
  73. package/dist/server/view.js +164 -0
  74. package/dist/server/views.js +1167 -0
  75. package/dist/shared/askfollow.js +46 -0
  76. package/dist/shared/board.js +332 -0
  77. package/dist/shared/build.js +2 -0
  78. package/dist/shared/collab.js +241 -0
  79. package/dist/shared/comment.js +285 -0
  80. package/dist/shared/context.js +157 -0
  81. package/dist/shared/doc/authors.js +98 -0
  82. package/dist/shared/doc/build.js +358 -0
  83. package/dist/shared/doc/latex.js +455 -0
  84. package/dist/shared/doc/markdown.js +159 -0
  85. package/dist/shared/doc/model.js +37 -0
  86. package/dist/shared/doc/order.js +124 -0
  87. package/dist/shared/doc/outline.js +45 -0
  88. package/dist/shared/doc/proof.js +272 -0
  89. package/dist/shared/doc/sections.js +93 -0
  90. package/dist/shared/doc/settings.js +75 -0
  91. package/dist/shared/edgecodec.js +102 -0
  92. package/dist/shared/editor.js +64 -0
  93. package/dist/shared/excerpt.js +87 -0
  94. package/dist/shared/graph.js +16 -0
  95. package/dist/shared/items.js +92 -0
  96. package/dist/shared/keys.js +203 -0
  97. package/dist/shared/layout.js +926 -0
  98. package/dist/shared/lexer.js +130 -0
  99. package/dist/shared/library.js +711 -0
  100. package/dist/shared/lostfound.js +150 -0
  101. package/dist/shared/moves.js +244 -0
  102. package/dist/shared/notecard.js +295 -0
  103. package/dist/shared/noteplace.js +505 -0
  104. package/dist/shared/palette.js +46 -0
  105. package/dist/shared/pastgraph.js +103 -0
  106. package/dist/shared/places.js +80 -0
  107. package/dist/shared/projectnotes.js +244 -0
  108. package/dist/shared/query.js +818 -0
  109. package/dist/shared/review.js +642 -0
  110. package/dist/shared/reviewqueue.js +333 -0
  111. package/dist/shared/ring.js +366 -0
  112. package/dist/shared/scope.js +255 -0
  113. package/dist/shared/scopeedit.js +81 -0
  114. package/dist/shared/search.js +191 -0
  115. package/dist/shared/separate.js +178 -0
  116. package/dist/shared/sharesnap.js +281 -0
  117. package/dist/shared/sticky.js +501 -0
  118. package/dist/shared/timeline.js +73 -0
  119. package/dist/shared/titles.js +300 -0
  120. package/dist/shared/viewgraph.js +157 -0
  121. package/dist/shared/views.js +859 -0
  122. package/dist/web/bundle/app.js +52671 -0
  123. package/dist/web/bundle/edges.js +407 -0
  124. package/dist/web/bundle/layout.js +1010 -0
  125. package/dist/web/bundle/pdfworker.js +48322 -0
  126. package/dist/web/index.html +257 -0
  127. package/dist/web/src/motion.js +430 -0
  128. package/dist/web/src/past.js +224 -0
  129. package/dist/web/src/scene.js +804 -0
  130. package/dist/web/style.css +2548 -0
  131. package/docs/agent-api/README.md +91 -0
  132. package/docs/agent-api/advice.md +108 -0
  133. package/docs/agent-api/export.md +120 -0
  134. package/docs/agent-api/following.md +140 -0
  135. package/docs/agent-api/history.md +142 -0
  136. package/docs/agent-api/library.md +65 -0
  137. package/docs/agent-api/mcp.md +104 -0
  138. package/docs/agent-api/notes.md +93 -0
  139. package/docs/agent-api/reading.md +166 -0
  140. package/docs/agent-api/requests.md +94 -0
  141. package/docs/agent-api/talking.md +100 -0
  142. package/docs/agent-api/tcb.md +135 -0
  143. package/docs/agent-api/views.md +125 -0
  144. package/docs/agent-api/words.md +56 -0
  145. package/package.json +39 -4
  146. package/test/toolchains.json +5 -0
@@ -0,0 +1,600 @@
1
+ // The library on the server (AttachmentSpec decisions 1–6, 20): the bytes of its files, kept in the
2
+ // site's folder by their SHA-256 (`<site>/files/<sha256>`), and what people and agents do with them, which
3
+ // the review's logs record (shared/library.ts). Its record and the links into it name it by its hash. A server that reads a link to a file it does not have says so, and takes
4
+ // only bytes of that hash to fill the gap.
5
+ //
6
+ // Every operation is here once, for both doors (AgentSpec, "One way in per operation, two doors"):
7
+ // the page's routes (server/libraryroute.ts) and the agents' endpoints (server/agentlibrary.ts) call
8
+ // the same methods with who asks. Whoever can give an opinion — a person with a name, an agent at
9
+ // level advise — adds files and makes links; an agent edits, archives and removes what others made
10
+ // at level full only (decision 20). A file is never deleted from the library, only archived
11
+ // (HistorySpec decision 1); its bytes are purged by the owner alone, from the command line
12
+ // (`visualproof purge-file`).
13
+ //
14
+ // A link to a cluster follows the cluster when a build moves all its declarations to one other
15
+ // cluster — a folder or a namespace renamed (decision 6): the server finds it as it finds renames,
16
+ // and records it as a `link-edit` in its own log, beside the moves it found (`carryClusterLinks`).
17
+ import { createHash, randomBytes } from "node:crypto";
18
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { clusterKeys } from "../shared/layout.js";
21
+ import { DEFAULT_LIBRARY_LIMIT, defaultLabel, fileOf, LIMITS, linkOf, MAX_FILE, MIME, nameStem, parseSubject, roundRegion, sizeWords, sniffType, subjectText, validQuote, validRegion, validSha } from "../shared/library.js";
22
+ import { arxivId, arxivTitle, fetchUrl } from "./fetchurl.js";
23
+ import { clampWidth, PdfReader, searchWords, writeAtomic } from "./poppler.js";
24
+ import { SERVER_WRITER } from "./review.js";
25
+ /** The bytes of the library's files, in `<site>/files/<sha256>`, and what poppler read of them,
26
+ * in `<cache>/files-cache/<sha256>/`. */
27
+ export class Library {
28
+ dir;
29
+ pdf;
30
+ /** the most the library holds, all its files' bytes together (a server option) */
31
+ limit;
32
+ /** how a fetch by URL is made: the defaults, unless a test fetches from a server of its own */
33
+ fetchOptions = {};
34
+ /** the bytes held, worked out once and kept up to date */
35
+ usedBytes = null;
36
+ /** the files in the site directory, what was read of them in the project's cache (server/dirs.ts) */
37
+ constructor(siteDir, cacheDir, limit = DEFAULT_LIBRARY_LIMIT) {
38
+ this.dir = join(siteDir, "files");
39
+ this.pdf = new PdfReader(join(cacheDir, "files-cache"));
40
+ this.limit = limit;
41
+ }
42
+ path(sha) {
43
+ return join(this.dir, sha);
44
+ }
45
+ has(sha) {
46
+ return validSha(sha) && existsSync(this.path(sha));
47
+ }
48
+ /** How many bytes the library's files take. */
49
+ used() {
50
+ if (this.usedBytes === null) {
51
+ let n = 0;
52
+ try {
53
+ for (const f of readdirSync(this.dir))
54
+ if (validSha(f))
55
+ n += statSync(join(this.dir, f)).size;
56
+ }
57
+ catch {
58
+ // no folder yet: nothing held
59
+ }
60
+ this.usedBytes = n;
61
+ }
62
+ return this.usedBytes;
63
+ }
64
+ /** Keep `bytes`: stored once by their hash, whoever adds them again. Refused where they are no
65
+ * PDF or image, larger than a file may be, not of the hash expected (`expect`: bytes added for a
66
+ * record whose file this server lacks), or beyond the library's limit. */
67
+ put(bytes, expect) {
68
+ if (!bytes.length)
69
+ return { status: 400, error: "The file is empty.", hint: "Send the file's bytes." };
70
+ if (bytes.length > MAX_FILE)
71
+ return { status: 413, error: `The file is ${sizeWords(bytes.length)}: a file of the library is at most ${sizeWords(MAX_FILE)}.`, hint: "Add a smaller file." };
72
+ const type = sniffType(bytes);
73
+ if (!type)
74
+ return { status: 415, error: "This is no PDF, PNG, JPEG, WebP or GIF: the library takes those alone (never SVG, which can carry scripts).", hint: "The type is read from the file's bytes, not its name." };
75
+ const sha = createHash("sha256").update(bytes).digest("hex");
76
+ if (expect && sha !== expect)
77
+ return { status: 409, error: `This is another file (${sha.slice(0, 12)}…), not the one the link names (${expect.slice(0, 12)}…).`, hint: "Only a file with exactly that content is taken in its place: the same version of the paper, byte for byte." };
78
+ if (this.has(sha))
79
+ return { sha, type, fresh: false };
80
+ if (this.used() + bytes.length > this.limit)
81
+ return { status: 507, error: `The library holds ${sizeWords(this.used())} of its ${sizeWords(this.limit)}: this file does not fit.`, hint: "The owner can raise the limit (--library-limit) or purge files added by mistake (visualproof purge-file)." };
82
+ mkdirSync(this.dir, { recursive: true });
83
+ writeAtomic(this.path(sha), bytes);
84
+ this.usedBytes = this.used() + bytes.length;
85
+ return { sha, type, fresh: true };
86
+ }
87
+ read(sha) {
88
+ return this.has(sha) ? readFileSync(this.path(sha)) : null;
89
+ }
90
+ /** Purge a file's bytes for good, and what was read of them: the owner's, from the command line
91
+ * (decision 3). Its record and its links stay, saying the server lacks the file. */
92
+ purge(sha) {
93
+ if (!this.has(sha))
94
+ return false;
95
+ rmSync(this.path(sha), { force: true });
96
+ this.pdf.forget(sha);
97
+ this.usedBytes = null;
98
+ return true;
99
+ }
100
+ }
101
+ const fail = (status, error, hint, more = {}) => ({ status, body: { error, hint, ...more } });
102
+ const ok = (body) => ({ status: 200, body });
103
+ /** A file's record as the API shows it: with whether this server has its bytes. */
104
+ export function shownFile(f, have) {
105
+ return { ...f, have, mime: MIME[f.type] };
106
+ }
107
+ /** The cluster keys of a graph, by clustering: what `ns(P)` and `file(P)` may name. */
108
+ const keysOf = new WeakMap();
109
+ function clusterKeysOf(g) {
110
+ let k = keysOf.get(g);
111
+ if (!k)
112
+ keysOf.set(g, (k = { namespace: clusterKeys("namespace", g.nodes), module: clusterKeys("module", g.nodes) }));
113
+ return k;
114
+ }
115
+ /** What `body.region`, `body.quote` and `body.page` say of a place, checked: the place, or why not. */
116
+ function readPlace(b, type, pages) {
117
+ const place = {};
118
+ if (b.page !== undefined && b.page !== null) {
119
+ if (type !== "pdf")
120
+ return "An image has no pages: leave page out.";
121
+ if (!Number.isInteger(b.page) || b.page < 1 || (pages && b.page > pages))
122
+ return `page is a page of the file, 1 to ${pages ?? "its last"}.`;
123
+ place.page = b.page;
124
+ }
125
+ if (b.region !== undefined && b.region !== null) {
126
+ if (!validRegion(b.region))
127
+ return "region is [left, top, right, bottom], fractions of the page from its top left (0 to 1), left < right and top < bottom.";
128
+ place.region = roundRegion(b.region);
129
+ }
130
+ if (b.quote !== undefined && b.quote !== null) {
131
+ const q = typeof b.quote === "string" ? { text: b.quote } : b.quote;
132
+ if (!validQuote(q))
133
+ return `quote is the text quoted (at most ${LIMITS.quote} characters), or {text, before?, after?} with a little of the text around it.`;
134
+ place.quote = q;
135
+ }
136
+ if (type === "pdf" && place.page === undefined)
137
+ return "A link into a PDF is at a page: give page (from 1), with a region or a quote on it if you like.";
138
+ return place;
139
+ }
140
+ /** The library's operations, for the page and for agents alike. */
141
+ export class LibraryApi {
142
+ w;
143
+ constructor(w) {
144
+ this.w = w;
145
+ }
146
+ get data() {
147
+ return this.w.review.data;
148
+ }
149
+ /** Append an event under who asks, as everything the review records is. */
150
+ record(op, who) {
151
+ const r = { ...op, by: who.name, ...(who.agent ? { agent: true } : {}), ...(who.person ? { person: who.person } : {}) };
152
+ return this.w.review.append({ name: who.name, agent: who.agent, person: who.person ?? null, server: who.server }, r, this.w.project.toolchain);
153
+ }
154
+ written(r) {
155
+ if ("status" in r)
156
+ return fail(r.status, r.error, r.status === 409 ? "Mend that line of the log by hand (or ask its writer to); VisualProof never rewrites a log." : "");
157
+ return null;
158
+ }
159
+ /** What changed in the review, for the page to apply at once (as POST /api/review answers it). */
160
+ static changeOf(r) {
161
+ return "change" in r && r.change ? { change: r.change } : {};
162
+ }
163
+ // ---------------------------------------------------------------- the library
164
+ /** The library: its files, the archived apart, each with whether this server has its bytes, and
165
+ * how full it is. */
166
+ list() {
167
+ const all = Object.values(this.data.files ?? {});
168
+ const shown = (f) => ({ ...shownFile(f, this.w.library.has(f.sha)), links: (this.data.links ?? []).filter((l) => l.file === f.sha).length });
169
+ return ok({
170
+ files: all.filter((f) => !f.archived).map(shown),
171
+ archived: all.filter((f) => f.archived).map(shown),
172
+ used: this.w.library.used(),
173
+ limit: this.w.library.limit,
174
+ maxFile: MAX_FILE,
175
+ });
176
+ }
177
+ file(sha) {
178
+ if (!validSha(sha))
179
+ return fail(400, "sha is a file's SHA-256, 64 hex digits.", "GET the library to list them.");
180
+ const f = fileOf(this.data, sha);
181
+ if (!f)
182
+ return fail(404, `The library has no file ${sha.slice(0, 12)}….`, "GET the library to list them.");
183
+ return ok({ file: shownFile(f, this.w.library.has(sha)), links: (this.data.links ?? []).filter((l) => l.file === sha) });
184
+ }
185
+ /** Add a file's bytes: kept once by their hash, and recorded where the library has no record of
186
+ * them yet — its title the one `meta.title` finds, else the PDF's own, else the file's name.
187
+ * `meta.title` is asked then alone, since it may fetch a page (an arXiv paper's abstract), and a
188
+ * title it cannot find leaves the rest. Bytes the library has already are its file as it is
189
+ * (restored, where it was archived); bytes for a record whose file this server lacks (`expect`)
190
+ * fill that gap and record nothing. A file recorded anew is carried by a note of the project, on
191
+ * the ring (`fileAdded`), unless `meta.note` is false: the page writes that note itself. */
192
+ async add(bytes, meta, who) {
193
+ if (meta.expect !== undefined && meta.expect !== null && !validSha(meta.expect))
194
+ return fail(400, "sha is a file's SHA-256, 64 hex digits.", "Name the file the link names.");
195
+ // bytes for a record are taken only where there is the record: nothing is kept for nothing
196
+ if (meta.expect && !fileOf(this.data, meta.expect))
197
+ return fail(404, "The library has no record of that file.", "Add it as a new file, without sha.");
198
+ const put = this.w.library.put(bytes, meta.expect ?? undefined);
199
+ if ("status" in put)
200
+ return fail(put.status, put.error, put.hint);
201
+ const had = fileOf(this.data, put.sha);
202
+ if (had) {
203
+ let restored = {};
204
+ if (had.archived) {
205
+ const r = this.record({ op: "file-restore", sha: put.sha }, who);
206
+ const no = this.written(r);
207
+ if (no)
208
+ return no;
209
+ restored = LibraryApi.changeOf(r);
210
+ }
211
+ const now = fileOf(this.data, put.sha);
212
+ return ok({ ok: true, file: shownFile(now, true), existed: true, restored: !!had.archived, ...(put.fresh ? { filled: true } : {}), ...restored });
213
+ }
214
+ let info = null;
215
+ if (put.type === "pdf") {
216
+ try {
217
+ info = await this.w.library.pdf.info(put.sha, this.w.library.path(put.sha));
218
+ }
219
+ catch {
220
+ // a PDF poppler cannot read: kept as it is; pdf.js may still draw it
221
+ }
222
+ }
223
+ const found = meta.title ? await meta.title().catch(() => null) : null;
224
+ const name = meta.name ? clip(meta.name.replace(/[\u0000-\u001f\u007f]/g, "").trim(), LIMITS.name) : null;
225
+ const title = clip(found ?? info?.title ?? (name ? nameStem(name) : null) ?? (meta.url ? new URL(meta.url).hostname : "Untitled"), LIMITS.title) || "Untitled";
226
+ const op = {
227
+ op: "file",
228
+ sha: put.sha,
229
+ title,
230
+ type: put.type,
231
+ size: bytes.length,
232
+ ...(info && info.pages > 0 ? { pages: Math.min(info.pages, LIMITS.pages) } : {}),
233
+ ...(name ? { name } : {}),
234
+ ...(meta.url ? { url: clip(meta.url, LIMITS.url) } : {}),
235
+ };
236
+ const r = this.record(op, who);
237
+ const no = this.written(r);
238
+ if (no)
239
+ return no;
240
+ const f = fileOf(this.data, put.sha);
241
+ this.w.fileAdded?.(f, who, meta.note !== false);
242
+ return ok({ ok: true, file: shownFile(f, true), existed: false, ...LibraryApi.changeOf(r) });
243
+ }
244
+ /** Add the file at an address: fetched once (an arXiv abstract's address gives its PDF), kept as
245
+ * any file is. A paper on arXiv, by its abstract's address or its PDF's, is titled as its abstract
246
+ * page says, which its PDF seldom does (arXiv serves it as "1910.09336v1.pdf"). */
247
+ async addUrl(url, who) {
248
+ if (typeof url !== "string" || !url.trim() || url.length > LIMITS.url)
249
+ return fail(400, "url is the file's address, http or https.", "e.g. {\"url\": \"https://arxiv.org/abs/2401.01234\"}.");
250
+ const opts = this.w.library.fetchOptions;
251
+ const got = await fetchUrl(url, opts);
252
+ if (!got.ok)
253
+ return fail(got.status, got.error, "Give the address of the PDF or the image itself; it is fetched once, by the server.");
254
+ // a paper is arXiv's where the address given is, or where a redirect led
255
+ const id = arxivId(url) ?? arxivId(got.url);
256
+ return this.add(got.bytes, { name: got.name, url: got.url, ...(id ? { title: () => arxivTitle(id, opts) } : {}) }, who);
257
+ }
258
+ /** May `who` change what `a` made? A person may; an agent its own at advise, anyone's at full. */
259
+ mayChange(a, who, what) {
260
+ if (!who.agent || who.full)
261
+ return null;
262
+ if (a.agent && a.by === who.name)
263
+ return null;
264
+ return fail(403, `${what} was made by ${a.by}: an agent changes what others made at level full only.`, "Ask the person who shared with you for a token at level full, or ask a person to do it.");
265
+ }
266
+ /** A file's title, edited. */
267
+ editFile(sha, title, who) {
268
+ const f = this.found(sha);
269
+ if ("status" in f)
270
+ return f;
271
+ const t = typeof title === "string" ? title.replace(/\s+/g, " ").trim() : "";
272
+ if (!t || t.length > LIMITS.title)
273
+ return fail(400, `title is 1 to ${LIMITS.title} characters.`, "Send {sha, title}.");
274
+ const no = this.mayChange(f, who, "This file");
275
+ if (no)
276
+ return no;
277
+ const r = this.record({ op: "file-edit", sha: f.sha, title: t }, who);
278
+ const bad = this.written(r);
279
+ if (bad)
280
+ return bad;
281
+ return ok({ ok: true, changed: !!("change" in r && r.change), file: shownFile(fileOf(this.data, f.sha), this.w.library.has(f.sha)), ...LibraryApi.changeOf(r) });
282
+ }
283
+ /** Archive a file (`on`), hiding it from the library while its links say so, or restore it. */
284
+ archive(sha, on, who) {
285
+ const f = this.found(sha);
286
+ if ("status" in f)
287
+ return f;
288
+ const no = this.mayChange(f, who, "This file");
289
+ if (no)
290
+ return no;
291
+ const r = this.record({ op: on === false ? "file-restore" : "file-archive", sha: f.sha }, who);
292
+ const bad = this.written(r);
293
+ if (bad)
294
+ return bad;
295
+ return ok({ ok: true, changed: !!("change" in r && r.change), file: shownFile(fileOf(this.data, f.sha), this.w.library.has(f.sha)), ...LibraryApi.changeOf(r) });
296
+ }
297
+ found(sha) {
298
+ if (!validSha(sha))
299
+ return fail(400, "sha is a file's SHA-256, 64 hex digits.", "GET the library to list them.");
300
+ return fileOf(this.data, sha) ?? fail(404, `The library has no file ${sha.slice(0, 12)}….`, "GET the library to list them.");
301
+ }
302
+ // ---------------------------------------------------------------- links
303
+ /** Whether a subject is one the graph on show has: a declaration (a name Lean generated from a
304
+ * command stands for the command's unit), or a cluster by namespace or by file. The subject as it
305
+ * is to be recorded, or the answer that refuses it. */
306
+ subject(raw) {
307
+ const s = parseSubject(raw);
308
+ if (!s)
309
+ return fail(400, "subject is decl(<full name>), ns(<namespace>) or file(<module prefix>).", "e.g. decl(fermatLastTheoremFour), or ns(Fermat42) for a cluster.");
310
+ const G = this.w.graph();
311
+ if (!G)
312
+ return fail(503, "The graph has not been read yet.", "The server is reading the project; try again in a few seconds.");
313
+ const { g } = G;
314
+ if (s.kind === "decl") {
315
+ const i = g.byName.get(s.name) ?? g.foldedInto.get(s.name);
316
+ if (i === undefined)
317
+ return fail(404, `There is no declaration ${s.name} in the graph.`, "Link a declaration of the graph, by its full name.");
318
+ return subjectText({ kind: "decl", name: g.nodes[i].name });
319
+ }
320
+ const mode = s.kind === "file" ? "module" : "namespace";
321
+ if (!clusterKeysOf(g)[mode].has(s.name))
322
+ return fail(404, `There is no cluster ${s.name} by ${s.kind === "file" ? "file" : "namespace"}.`, `A cluster is a namespace (ns) or a module prefix (file) of the graph, as its key names it.`);
323
+ return subjectText(s);
324
+ }
325
+ /** The links of a subject, or into a file; every link without either. */
326
+ links(q) {
327
+ let ls = this.data.links ?? [];
328
+ if (q.subject) {
329
+ const s = parseSubject(q.subject);
330
+ if (!s)
331
+ return fail(400, "subject is decl(<full name>), ns(<namespace>) or file(<module prefix>).", "");
332
+ ls = ls.filter((l) => l.subject === subjectText(s));
333
+ }
334
+ if (q.file)
335
+ ls = ls.filter((l) => l.file === q.file);
336
+ return ok({ links: ls.map((l) => this.shownLink(l)) });
337
+ }
338
+ shownLink(l) {
339
+ const f = fileOf(this.data, l.file);
340
+ return { ...l, fileTitle: f?.title ?? null, ...(f?.archived ? { archived: true } : {}), have: this.w.library.has(l.file) };
341
+ }
342
+ /** Make a link: a subject of the graph, a file of the library (not archived), a place in it and a
343
+ * label — by default the page and the quote's first words. */
344
+ link(b, who) {
345
+ const subject = this.subject(b.subject);
346
+ if (typeof subject !== "string")
347
+ return subject;
348
+ const f = this.found(b.file ?? b.sha);
349
+ if ("status" in f)
350
+ return f;
351
+ if (f.archived)
352
+ return fail(409, `${f.title} is archived.`, "Restore it from the library's archive first.");
353
+ const place = readPlace(b, f.type, f.pages);
354
+ if (typeof place === "string")
355
+ return fail(400, place, "");
356
+ const label = b.label === undefined || b.label === null || (typeof b.label === "string" && !b.label.trim()) ? defaultLabel(place, f.type) : typeof b.label === "string" ? b.label.replace(/\s+/g, " ").trim() : "";
357
+ if (!label || label.length > LIMITS.label)
358
+ return fail(400, `label is 1 to ${LIMITS.label} characters.`, "e.g. \"Theorem 3, p. 12\"; left out, the page and the quote's first words.");
359
+ const id = `l${randomBytes(6).toString("hex")}`;
360
+ const r = this.record({ op: "link", id, subject, file: f.sha, label, ...place }, who);
361
+ const bad = this.written(r);
362
+ if (bad)
363
+ return bad;
364
+ return ok({ ok: true, link: this.shownLink(linkOf(this.data, id)), ...LibraryApi.changeOf(r) });
365
+ }
366
+ /** Edit a link: its label, its place, its subject. */
367
+ editLink(b, who) {
368
+ const l = validId(b.id) ? linkOf(this.data, b.id) : null;
369
+ if (!l)
370
+ return fail(404, `There is no link ${String(b.id)}.`, "List the links of a subject or a file to find one.");
371
+ const no = this.mayChange(l, who, "This link");
372
+ if (no)
373
+ return no;
374
+ const f = fileOf(this.data, l.file);
375
+ const op = { op: "link-edit", id: l.id };
376
+ if (b.subject !== undefined) {
377
+ const s = this.subject(b.subject);
378
+ if (typeof s !== "string")
379
+ return s;
380
+ op.subject = s;
381
+ }
382
+ if (b.label !== undefined) {
383
+ const t = typeof b.label === "string" ? b.label.replace(/\s+/g, " ").trim() : "";
384
+ if (!t || t.length > LIMITS.label)
385
+ return fail(400, `label is 1 to ${LIMITS.label} characters.`, "");
386
+ op.label = t;
387
+ }
388
+ if (b.page !== undefined || b.region !== undefined || b.quote !== undefined) {
389
+ // the place as it will be, checked whole
390
+ const next = { page: l.page, region: l.region, quote: l.quote, ...Object.fromEntries(["page", "region", "quote"].filter((k) => b[k] !== undefined).map((k) => [k, b[k]])) };
391
+ const place = readPlace(next, f?.type ?? "pdf", f?.pages);
392
+ if (typeof place === "string")
393
+ return fail(400, place, "");
394
+ for (const k of ["page", "region", "quote"])
395
+ if (b[k] !== undefined)
396
+ op[k] = place[k] ?? null;
397
+ }
398
+ if (Object.keys(op).length === 2)
399
+ return fail(400, "Say what changes: label, page, region, quote or subject.", "");
400
+ const r = this.record(op, who);
401
+ const bad = this.written(r);
402
+ if (bad)
403
+ return bad;
404
+ return ok({ ok: true, changed: !!("change" in r && r.change), link: this.shownLink(linkOf(this.data, l.id)), ...LibraryApi.changeOf(r) });
405
+ }
406
+ /** Remove a link. */
407
+ unlink(id, who) {
408
+ const l = validId(id) ? linkOf(this.data, id) : null;
409
+ if (!l)
410
+ return fail(404, `There is no link ${String(id)}.`, "List the links of a subject or a file to find one.");
411
+ const no = this.mayChange(l, who, "This link");
412
+ if (no)
413
+ return no;
414
+ const r = this.record({ op: "unlink", id: l.id }, who);
415
+ const bad = this.written(r);
416
+ if (bad)
417
+ return bad;
418
+ return ok({ ok: true, removed: l.id, ...LibraryApi.changeOf(r) });
419
+ }
420
+ // ---------------------------------------------------------------- reading a file
421
+ /** A file's bytes, for the page's window and an agent that wants them. */
422
+ bytes(sha) {
423
+ const f = this.found(sha);
424
+ if ("status" in f)
425
+ return f;
426
+ const data = this.w.library.read(f.sha);
427
+ if (!data)
428
+ return this.missing(f);
429
+ return { status: 200, body: null, bytes: { type: MIME[f.type], data, immutable: true } };
430
+ }
431
+ missing(f) {
432
+ return fail(404, `This server does not have the file of ${f.title}.`, "Add it to the library: only a file of exactly that content (its SHA-256) is taken in its place.", { missing: true, sha: f.sha });
433
+ }
434
+ /** A PDF of the library, and its page `page`, checked: the record, the path and the info, or the
435
+ * answer that refuses. */
436
+ async pdfPage(sha, page) {
437
+ const f = this.found(sha);
438
+ if ("status" in f)
439
+ return f;
440
+ if (f.type !== "pdf")
441
+ return fail(400, `${f.title} is an image, not a PDF: it has no pages or text.`, "Fetch its bytes instead.");
442
+ if (!this.w.library.has(f.sha))
443
+ return this.missing(f);
444
+ const path = this.w.library.path(f.sha);
445
+ let info;
446
+ try {
447
+ info = await this.w.library.pdf.info(f.sha, path);
448
+ }
449
+ catch (e) {
450
+ return fail(422, `${f.title} cannot be read: ${e instanceof Error ? e.message : String(e)}`, "");
451
+ }
452
+ const p = page === undefined || page === null || page === "" ? 1 : Number(page);
453
+ if (!Number.isInteger(p) || p < 1 || p > info.pages)
454
+ return fail(400, `page is 1 to ${info.pages}.`, "");
455
+ return { f, path, info, page: p };
456
+ }
457
+ /** A page's text, with the place of every word (in the page's fractions) and where each line
458
+ * starts among them. */
459
+ async text(sha, page) {
460
+ const p = await this.pdfPage(sha, page);
461
+ if ("status" in p)
462
+ return p;
463
+ let all;
464
+ try {
465
+ all = await this.w.library.pdf.allWords(p.f.sha, p.path);
466
+ }
467
+ catch (e) {
468
+ return fail(422, `The text of ${p.f.title} cannot be read: ${e instanceof Error ? e.message : String(e)}`, "");
469
+ }
470
+ const w = all[p.page - 1] ?? { page: p.page, width: p.info.sizes[p.page - 1]?.w ?? 0, height: p.info.sizes[p.page - 1]?.h ?? 0, words: [], lines: [] };
471
+ const text = w.lines.map((start, k) => w.words.slice(start, w.lines[k + 1] ?? w.words.length).map((x) => x[0]).join(" ")).join("\n");
472
+ return ok({ file: p.f.sha, title: p.f.title, page: p.page, pages: p.info.pages, size: { width: w.width, height: w.height }, text, words: w.words, lines: w.lines, note: "Each word is [text, left, top, right, bottom], in fractions of the page from its top left: a region of a link is written so." });
473
+ }
474
+ /** Search a PDF's text: each match's page, the boxes of its words, and the text around it. */
475
+ async search(sha, q, most) {
476
+ const p = await this.pdfPage(sha, 1);
477
+ if ("status" in p)
478
+ return p;
479
+ if (typeof q !== "string" || q.trim().length < 2 || q.length > 500)
480
+ return fail(400, "q is the text to find, 2 to 500 characters.", "");
481
+ const n = Math.min(500, Math.max(1, Number(most) || 50));
482
+ try {
483
+ const all = await this.w.library.pdf.allWords(p.f.sha, p.path);
484
+ const r = searchWords(all, q, n);
485
+ return ok({ file: p.f.sha, title: p.f.title, q, matches: r.matches, more: r.more });
486
+ }
487
+ catch (e) {
488
+ return fail(422, `The text of ${p.f.title} cannot be read: ${e instanceof Error ? e.message : String(e)}`, "");
489
+ }
490
+ }
491
+ /** A page drawn as a PNG about `width` pixels wide. */
492
+ async page(sha, page, width) {
493
+ const p = await this.pdfPage(sha, page);
494
+ if ("status" in p)
495
+ return p;
496
+ try {
497
+ const data = await this.w.library.pdf.page(p.f.sha, p.path, p.page, clampWidth(Number(width) || 1200));
498
+ return { status: 200, body: null, bytes: { type: "image/png", data, immutable: true } };
499
+ }
500
+ catch (e) {
501
+ return fail(422, `Page ${p.page} of ${p.f.title} cannot be drawn: ${e instanceof Error ? e.message : String(e)}`, "");
502
+ }
503
+ }
504
+ /** A clip: a region of a page drawn as a PNG about `width` pixels wide (what a note carries,
505
+ * AttachmentSpec decision 12). */
506
+ async clip(sha, page, region, width) {
507
+ const p = await this.pdfPage(sha, page);
508
+ if ("status" in p)
509
+ return p;
510
+ const r = typeof region === "string" ? region.split(",").map(Number) : region;
511
+ if (!validRegion(r))
512
+ return fail(400, "region is left,top,right,bottom: fractions of the page from its top left (0 to 1).", "e.g. region=0.1,0.2,0.9,0.4.");
513
+ try {
514
+ const data = await this.w.library.pdf.clip(p.f.sha, p.path, p.page, p.info.sizes[p.page - 1] ?? { w: 612, h: 792 }, roundRegion(r), clampWidth(Number(width) || 800));
515
+ return { status: 200, body: null, bytes: { type: "image/png", data, immutable: true } };
516
+ }
517
+ catch (e) {
518
+ return fail(422, `That region of page ${p.page} cannot be drawn: ${e instanceof Error ? e.message : String(e)}`, "");
519
+ }
520
+ }
521
+ }
522
+ const validId = (s) => typeof s === "string" && /^l[0-9a-f]{12}$/.test(s);
523
+ const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
524
+ /** One LibraryApi per world: the page's routes and the agents' endpoints share it. */
525
+ const apis = new WeakMap();
526
+ export function libraryApiOf(key, make) {
527
+ let a = apis.get(key);
528
+ if (!a)
529
+ apis.set(key, (a = new LibraryApi(make())));
530
+ return a;
531
+ }
532
+ // ---------------------------------------------------------------- clusters a build renamed
533
+ /** The members of a cluster in a snapshot's graph, by namespace (`ns`: a name under it, or the
534
+ * structure it is) or by file (`file`: a module under it). */
535
+ function membersOf(g, kind, key) {
536
+ const out = [];
537
+ for (let i = 0; i < g.projectCount; i++) {
538
+ const n = g.nodes[i];
539
+ const at = kind === "ns" ? n.name : n.module;
540
+ if (at === key || at.startsWith(key + "."))
541
+ out.push(n);
542
+ }
543
+ return out;
544
+ }
545
+ /** Where a build moved a cluster as a whole (decision 6): the one cluster that holds every
546
+ * declaration of it the build still has, each under its new name (or the same) with the same part
547
+ * below the cluster — a folder or a namespace renamed. Null where the cluster is still there, was
548
+ * not there before, or its declarations went to more than one place, or nowhere. Declarations gone
549
+ * without a rename have no say. */
550
+ export function clusterMovedTo(before, after, renamed, kind, key) {
551
+ if (membersOf(after, kind, key).length)
552
+ return null;
553
+ const was = membersOf(before, kind, key);
554
+ if (!was.length)
555
+ return null;
556
+ const now = new Map();
557
+ for (let i = 0; i < after.projectCount; i++)
558
+ now.set(after.nodes[i].name, after.nodes[i].module);
559
+ let to = null;
560
+ for (const m of was) {
561
+ const name = renamed.get(m.name) ?? (now.has(m.name) ? m.name : null);
562
+ if (name === null)
563
+ continue;
564
+ const rest = (kind === "ns" ? m.name : m.module).slice(key.length);
565
+ const at = kind === "ns" ? name : now.get(name);
566
+ if (!at.endsWith(rest) || at.length <= rest.length)
567
+ return null;
568
+ const q = at.slice(0, at.length - rest.length);
569
+ if (to !== null && q !== to)
570
+ return null;
571
+ to = q;
572
+ }
573
+ return to === key ? null : to;
574
+ }
575
+ /** Carry the links on clusters a build renamed as a whole to their new names: a `link-edit` each in
576
+ * the server's own log, under VisualProof's name, saying why (`auto`) and at which snapshot. A
577
+ * cluster gone otherwise leaves its links where they are, for the lost-and-found. How many were
578
+ * carried. */
579
+ export function carryClusterLinks(review, toolchain, before, after, renamed, snap, say = () => { }) {
580
+ const links = review.data.links ?? [];
581
+ const moved = new Map();
582
+ let n = 0;
583
+ for (const l of [...links]) {
584
+ const s = parseSubject(l.subject);
585
+ if (!s || s.kind === "decl")
586
+ continue;
587
+ if (!moved.has(l.subject))
588
+ moved.set(l.subject, clusterMovedTo(before, after, renamed, s.kind, s.name));
589
+ const to = moved.get(l.subject);
590
+ if (!to)
591
+ continue;
592
+ const subject = subjectText({ kind: s.kind, name: to });
593
+ const r = review.append(SERVER_WRITER, { op: "link-edit", id: l.id, subject, auto: `${s.kind === "ns" ? "namespace" : "folder"} renamed`, snap, by: SERVER_WRITER.name }, toolchain);
594
+ if ("status" in r)
595
+ say(`history: the link ${l.id} on ${l.subject} could not be carried to ${subject}: ${r.error}`);
596
+ else if (r.change)
597
+ n++;
598
+ }
599
+ return n;
600
+ }