visualproof 0.0.0-stage → 0.1.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.
- package/LICENSE +202 -0
- package/README.md +99 -2
- package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
- package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
- package/dist/assets/latex/visualproof.sty +308 -0
- package/dist/extractor/Extract.lean +1180 -0
- package/dist/extractor/Usage.lean +900 -0
- package/dist/mcp/visualproof-mcp.mjs +476 -0
- package/dist/server/access.js +95 -0
- package/dist/server/agent.js +3235 -0
- package/dist/server/agentdocs.js +35 -0
- package/dist/server/agentlibrary.js +202 -0
- package/dist/server/agentnotes.js +701 -0
- package/dist/server/agentviews.js +703 -0
- package/dist/server/askfollow.js +286 -0
- package/dist/server/boardspots.js +159 -0
- package/dist/server/dirs.js +175 -0
- package/dist/server/doctor.js +186 -0
- package/dist/server/events.js +198 -0
- package/dist/server/export.js +420 -0
- package/dist/server/extract.js +204 -0
- package/dist/server/fetchurl.js +321 -0
- package/dist/server/figures.js +158 -0
- package/dist/server/history.js +741 -0
- package/dist/server/historyapi.js +304 -0
- package/dist/server/hover.js +595 -0
- package/dist/server/identity.js +114 -0
- package/dist/server/invites.js +241 -0
- package/dist/server/layoutthread.js +51 -0
- package/dist/server/library.js +600 -0
- package/dist/server/libraryroute.js +211 -0
- package/dist/server/live.js +1101 -0
- package/dist/server/lostfound.js +199 -0
- package/dist/server/main.js +1394 -0
- package/dist/server/mcptools.js +497 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +156 -0
- package/dist/server/poppler.js +251 -0
- package/dist/server/presence.js +252 -0
- package/dist/server/project.js +222 -0
- package/dist/server/prompt.js +53 -0
- package/dist/server/readonly.js +152 -0
- package/dist/server/readonlygate.js +123 -0
- package/dist/server/renames.js +268 -0
- package/dist/server/review.js +334 -0
- package/dist/server/reviewapi.js +188 -0
- package/dist/server/snapshot.js +49 -0
- package/dist/server/sourcestore.js +473 -0
- package/dist/server/status.js +360 -0
- package/dist/server/talk.js +476 -0
- package/dist/server/tokens.js +221 -0
- package/dist/server/usage.js +501 -0
- package/dist/server/versions.js +82 -0
- package/dist/server/view.js +157 -0
- package/dist/server/views.js +1167 -0
- package/dist/shared/askfollow.js +46 -0
- package/dist/shared/board.js +332 -0
- package/dist/shared/build.js +2 -0
- package/dist/shared/collab.js +148 -0
- package/dist/shared/comment.js +285 -0
- package/dist/shared/context.js +157 -0
- package/dist/shared/doc/authors.js +98 -0
- package/dist/shared/doc/build.js +358 -0
- package/dist/shared/doc/latex.js +455 -0
- package/dist/shared/doc/markdown.js +159 -0
- package/dist/shared/doc/model.js +37 -0
- package/dist/shared/doc/order.js +107 -0
- package/dist/shared/doc/proof.js +272 -0
- package/dist/shared/doc/sections.js +93 -0
- package/dist/shared/doc/settings.js +75 -0
- package/dist/shared/edgecodec.js +102 -0
- package/dist/shared/editor.js +64 -0
- package/dist/shared/excerpt.js +87 -0
- package/dist/shared/graph.js +16 -0
- package/dist/shared/items.js +92 -0
- package/dist/shared/keys.js +203 -0
- package/dist/shared/layout.js +926 -0
- package/dist/shared/lexer.js +130 -0
- package/dist/shared/library.js +711 -0
- package/dist/shared/lostfound.js +150 -0
- package/dist/shared/moves.js +243 -0
- package/dist/shared/notecard.js +295 -0
- package/dist/shared/noteplace.js +505 -0
- package/dist/shared/palette.js +46 -0
- package/dist/shared/pastgraph.js +95 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +810 -0
- package/dist/shared/review.js +642 -0
- package/dist/shared/reviewqueue.js +333 -0
- package/dist/shared/ring.js +366 -0
- package/dist/shared/scope.js +255 -0
- package/dist/shared/scopeedit.js +81 -0
- package/dist/shared/search.js +191 -0
- package/dist/shared/separate.js +178 -0
- package/dist/shared/sharesnap.js +280 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +298 -0
- package/dist/shared/viewgraph.js +139 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +48733 -0
- package/dist/web/bundle/edges.js +409 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +239 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +787 -0
- package/dist/web/style.css +2312 -0
- package/docs/agent-api/README.md +91 -0
- package/docs/agent-api/advice.md +108 -0
- package/docs/agent-api/export.md +120 -0
- package/docs/agent-api/following.md +140 -0
- package/docs/agent-api/history.md +142 -0
- package/docs/agent-api/library.md +65 -0
- package/docs/agent-api/mcp.md +104 -0
- package/docs/agent-api/notes.md +93 -0
- package/docs/agent-api/reading.md +166 -0
- package/docs/agent-api/requests.md +94 -0
- package/docs/agent-api/talking.md +100 -0
- package/docs/agent-api/tcb.md +135 -0
- package/docs/agent-api/views.md +124 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- package/test/toolchains.json +5 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
// The library and its links (AttachmentSpec.md, decisions 1–10, the owner's of 2026-10-10 and
|
|
2
|
+
// 2026-10-11): the papers and images that explain the formal code, and the links that join a
|
|
3
|
+
// declaration or a cluster to a place in one of them.
|
|
4
|
+
//
|
|
5
|
+
// A file's bytes are in the site's folder, named by their SHA-256 (server/library.ts), and never
|
|
6
|
+
// change (decision 2): a revised paper is another file. Its record — title, type, size, pages, who
|
|
7
|
+
// added it, the URL it came from — and every link are events of the review's logs (ReviewSpec
|
|
8
|
+
// decision 5), which name the file by its hash:
|
|
9
|
+
//
|
|
10
|
+
// {"op":"file","sha":"…","title":"…","type":"pdf","size":…,"pages":12,"url":"…",…}
|
|
11
|
+
// {"op":"file-edit","sha":"…","title":"…"} the title changed
|
|
12
|
+
// {"op":"file-archive","sha":"…"} hidden from the library (decision 3)
|
|
13
|
+
// {"op":"file-restore","sha":"…"} back in it
|
|
14
|
+
// {"op":"link","id":"…","subject":"decl(A.b)","file":"…","page":12,"region":[…],"quote":{…},"label":"…"}
|
|
15
|
+
// {"op":"link-edit","id":"…", …} its label, place or subject changed
|
|
16
|
+
// {"op":"unlink","id":"…"} removed
|
|
17
|
+
//
|
|
18
|
+
// each with who wrote it and when, attributed as opinions are (an agent's marked, someone invited
|
|
19
|
+
// by a personal link by the link's id). The replay keeps the records by hash and the links in the
|
|
20
|
+
// order they were made (`ReviewData.files`, `ReviewData.links`). A link's subject is named as a
|
|
21
|
+
// scope names it (decision 4): `decl(X)`, `ns(P)`, `file(P)`; a move (shared/moves.ts) carries the
|
|
22
|
+
// links on `decl(from)` to `decl(to)` as it carries opinions, and the server carries the links on a
|
|
23
|
+
// cluster a build renamed as a whole by a `link-edit` in its own log (server/library.ts).
|
|
24
|
+
//
|
|
25
|
+
// A place is a page of a PDF with a region of it, the quoted text with a little of the text around
|
|
26
|
+
// it, or both; or an image, whole or a region of it. A region is kept in the page's own fractions
|
|
27
|
+
// (0–1, from its top left), so that it does not depend on how large the page is drawn.
|
|
28
|
+
//
|
|
29
|
+
// Names are anything Lean allows, so every object keyed by them or by hashes here has no prototype,
|
|
30
|
+
// as in shared/review.ts.
|
|
31
|
+
import { splitName } from "./layout.js";
|
|
32
|
+
/** The most a file may be (decision 1). */
|
|
33
|
+
export const MAX_FILE = 50 * 1024 * 1024;
|
|
34
|
+
/** The most the library holds by default, all its files' bytes together: a server option. */
|
|
35
|
+
export const DEFAULT_LIBRARY_LIMIT = 2 * 1024 * 1024 * 1024;
|
|
36
|
+
export const FILE_TYPES = ["pdf", "png", "jpeg", "webp", "gif"];
|
|
37
|
+
export const MIME = { pdf: "application/pdf", png: "image/png", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
|
|
38
|
+
/** A file's type, read from its first bytes, never from its name: null for anything else (SVG, an
|
|
39
|
+
* HTML page a server answered instead of the PDF, a zip). A PDF may have a few bytes before its
|
|
40
|
+
* header, as readers accept. */
|
|
41
|
+
export function sniffType(b) {
|
|
42
|
+
const at = (k, sig) => sig.every((x, j) => b[k + j] === x);
|
|
43
|
+
if (b.length >= 8 && at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
44
|
+
return "png";
|
|
45
|
+
if (b.length >= 3 && at(0, [0xff, 0xd8, 0xff]))
|
|
46
|
+
return "jpeg";
|
|
47
|
+
if (b.length >= 6 && (at(0, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || at(0, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])))
|
|
48
|
+
return "gif";
|
|
49
|
+
if (b.length >= 12 && at(0, [0x52, 0x49, 0x46, 0x46]) && at(8, [0x57, 0x45, 0x42, 0x50]))
|
|
50
|
+
return "webp";
|
|
51
|
+
const pdf = [0x25, 0x50, 0x44, 0x46, 0x2d];
|
|
52
|
+
for (let k = 0; k + pdf.length <= Math.min(b.length, 1024); k++)
|
|
53
|
+
if (at(k, pdf))
|
|
54
|
+
return "pdf";
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/** A file's name in the library: the SHA-256 of its bytes, in lower-case hex. */
|
|
58
|
+
export function validSha(s) {
|
|
59
|
+
return typeof s === "string" && /^[0-9a-f]{64}$/.test(s);
|
|
60
|
+
}
|
|
61
|
+
/** A link's id, made by the server: short, and unique among a project's links. */
|
|
62
|
+
export function validLinkId(s) {
|
|
63
|
+
return typeof s === "string" && /^l[0-9a-f]{12}$/.test(s);
|
|
64
|
+
}
|
|
65
|
+
/** The limits of what a record and a link hold, in UTF-16 units. */
|
|
66
|
+
export const LIMITS = { title: 300, label: 200, name: 300, url: 2000, quote: 2000, context: 200, subject: 2010, pages: 100000 };
|
|
67
|
+
/** A subject's text, `decl(X)`, `ns(P)` or `file(P)`, read; null when it is not one. The name is
|
|
68
|
+
* everything between the first parenthesis and the last, so a name in «» may hold parentheses. */
|
|
69
|
+
export function parseSubject(s) {
|
|
70
|
+
if (typeof s !== "string" || s.length > LIMITS.subject)
|
|
71
|
+
return null;
|
|
72
|
+
const m = /^(decl|ns|file)\((.+)\)$/s.exec(s);
|
|
73
|
+
if (!m || !m[2].trim() || /[\n\r]/.test(m[2]))
|
|
74
|
+
return null;
|
|
75
|
+
return { kind: m[1], name: m[2] };
|
|
76
|
+
}
|
|
77
|
+
export function subjectText(s) {
|
|
78
|
+
return `${s.kind}(${s.name})`;
|
|
79
|
+
}
|
|
80
|
+
export const declSubject = (name) => `decl(${name})`;
|
|
81
|
+
/** The subject a cluster of a board is, by its key and the board's clustering. */
|
|
82
|
+
export function clusterSubject(key, mode) {
|
|
83
|
+
return `${mode === "module" ? "file" : "ns"}(${key})`;
|
|
84
|
+
}
|
|
85
|
+
/** A subject in a few words, as the page and the export say it: a declaration by its name, a
|
|
86
|
+
* cluster as "namespace P" or "files under P". */
|
|
87
|
+
export function subjectWords(s) {
|
|
88
|
+
const p = parseSubject(s);
|
|
89
|
+
if (!p)
|
|
90
|
+
return s;
|
|
91
|
+
return p.kind === "decl" ? p.name : p.kind === "ns" ? `namespace ${p.name}` : `files under ${p.name}`;
|
|
92
|
+
}
|
|
93
|
+
/** The clusters around a declaration under a way of clustering, innermost first, as subjects (the
|
|
94
|
+
* board's clusters: a namespace and each one around it, or a module and each prefix of it). A
|
|
95
|
+
* structure and its namespace are one thing on the board (shared/layout.ts), so a declaration's
|
|
96
|
+
* own name comes first by namespace: a link on `ns(Foo)` is one on the structure `Foo` too. */
|
|
97
|
+
export function clusterChain(name, module, mode) {
|
|
98
|
+
const parts = mode === "module" ? module.split(".") : splitName(name);
|
|
99
|
+
const out = [];
|
|
100
|
+
for (let k = parts.length; k >= 1; k--)
|
|
101
|
+
out.push(clusterSubject(parts.slice(0, k).join("."), mode));
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
// ---------------------------------------------------------------------------------------------
|
|
105
|
+
// The events
|
|
106
|
+
export const LIBRARY_OPS = ["file", "file-edit", "file-archive", "file-restore", "link", "link-edit", "unlink"];
|
|
107
|
+
export function isLibraryOp(op) {
|
|
108
|
+
return LIBRARY_OPS.includes(op);
|
|
109
|
+
}
|
|
110
|
+
const str = (v, max) => typeof v === "string" && v.length > 0 && v.length <= max && !!v.trim();
|
|
111
|
+
const optStr = (v, max) => v === undefined || str(v, max);
|
|
112
|
+
export function validRegion(r) {
|
|
113
|
+
if (!Array.isArray(r) || r.length !== 4 || !r.every((x) => typeof x === "number" && Number.isFinite(x) && x >= 0 && x <= 1))
|
|
114
|
+
return false;
|
|
115
|
+
return r[2] > r[0] && r[3] > r[1];
|
|
116
|
+
}
|
|
117
|
+
export function validQuote(q) {
|
|
118
|
+
if (!q || typeof q !== "object" || Array.isArray(q))
|
|
119
|
+
return false;
|
|
120
|
+
const o = q;
|
|
121
|
+
const ctx = (v) => v === undefined || (typeof v === "string" && v.length <= LIMITS.context);
|
|
122
|
+
return str(o.text, LIMITS.quote) && ctx(o.before) && ctx(o.after) && Object.keys(o).every((k) => k === "text" || k === "before" || k === "after");
|
|
123
|
+
}
|
|
124
|
+
const validPage = (p) => Number.isInteger(p) && p >= 1 && p <= LIMITS.pages;
|
|
125
|
+
/** Why a library event's own fields are not right, or null. Who wrote it is checked beside it
|
|
126
|
+
* (shared/review.ts, `invalid` and `readEvent`). */
|
|
127
|
+
export function invalidLibrary(o) {
|
|
128
|
+
switch (o.op) {
|
|
129
|
+
case "file":
|
|
130
|
+
if (!validSha(o.sha))
|
|
131
|
+
return "sha";
|
|
132
|
+
if (!str(o.title, LIMITS.title))
|
|
133
|
+
return "title";
|
|
134
|
+
if (!FILE_TYPES.includes(o.type))
|
|
135
|
+
return "type";
|
|
136
|
+
if (!Number.isInteger(o.size) || o.size <= 0 || o.size > MAX_FILE)
|
|
137
|
+
return "size";
|
|
138
|
+
if (o.pages !== undefined && !validPage(o.pages))
|
|
139
|
+
return "pages";
|
|
140
|
+
if (!optStr(o.name, LIMITS.name))
|
|
141
|
+
return "name";
|
|
142
|
+
if (!optStr(o.url, LIMITS.url))
|
|
143
|
+
return "url";
|
|
144
|
+
return null;
|
|
145
|
+
case "file-edit":
|
|
146
|
+
if (!validSha(o.sha))
|
|
147
|
+
return "sha";
|
|
148
|
+
return str(o.title, LIMITS.title) ? null : "title";
|
|
149
|
+
case "file-archive":
|
|
150
|
+
case "file-restore":
|
|
151
|
+
return validSha(o.sha) ? null : "sha";
|
|
152
|
+
case "link":
|
|
153
|
+
if (!validLinkId(o.id))
|
|
154
|
+
return "id";
|
|
155
|
+
if (!parseSubject(o.subject))
|
|
156
|
+
return "subject";
|
|
157
|
+
if (!validSha(o.file))
|
|
158
|
+
return "file";
|
|
159
|
+
if (!str(o.label, LIMITS.label))
|
|
160
|
+
return "label";
|
|
161
|
+
if (o.page !== undefined && !validPage(o.page))
|
|
162
|
+
return "page";
|
|
163
|
+
if (o.region !== undefined && !validRegion(o.region))
|
|
164
|
+
return "region";
|
|
165
|
+
if (o.quote !== undefined && !validQuote(o.quote))
|
|
166
|
+
return "quote";
|
|
167
|
+
if (o.quote !== undefined && o.page === undefined)
|
|
168
|
+
return "a quote is on a page";
|
|
169
|
+
return null;
|
|
170
|
+
case "link-edit":
|
|
171
|
+
if (!validLinkId(o.id))
|
|
172
|
+
return "id";
|
|
173
|
+
if (o.subject !== undefined && !parseSubject(o.subject))
|
|
174
|
+
return "subject";
|
|
175
|
+
if (o.label !== undefined && !str(o.label, LIMITS.label))
|
|
176
|
+
return "label";
|
|
177
|
+
if (o.page !== undefined && o.page !== null && !validPage(o.page))
|
|
178
|
+
return "page";
|
|
179
|
+
if (o.region !== undefined && o.region !== null && !validRegion(o.region))
|
|
180
|
+
return "region";
|
|
181
|
+
if (o.quote !== undefined && o.quote !== null && !validQuote(o.quote))
|
|
182
|
+
return "quote";
|
|
183
|
+
if (o.auto !== undefined && !str(o.auto, 200))
|
|
184
|
+
return "auto";
|
|
185
|
+
if (o.snap !== undefined && !(Number.isInteger(o.snap) && o.snap > 0))
|
|
186
|
+
return "snap";
|
|
187
|
+
if (["subject", "label", "page", "region", "quote"].every((k) => o[k] === undefined))
|
|
188
|
+
return "an edit changes something";
|
|
189
|
+
return null;
|
|
190
|
+
case "unlink":
|
|
191
|
+
return validLinkId(o.id) ? null : "id";
|
|
192
|
+
default:
|
|
193
|
+
return "op";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** A library event's own fields, as a line holds them: only those an event of its kind has, in an
|
|
197
|
+
* object of its own. Checked first (`invalidLibrary`). */
|
|
198
|
+
function fieldsOf(o) {
|
|
199
|
+
const pick = (keys) => {
|
|
200
|
+
const out = { op: o.op };
|
|
201
|
+
for (const k of keys)
|
|
202
|
+
if (o[k] !== undefined)
|
|
203
|
+
out[k] = k === "region" && Array.isArray(o[k]) ? [...o[k]] : k === "quote" && o[k] ? { ...o[k] } : o[k];
|
|
204
|
+
return out;
|
|
205
|
+
};
|
|
206
|
+
switch (o.op) {
|
|
207
|
+
case "file":
|
|
208
|
+
return pick(["sha", "title", "type", "size", "pages", "name", "url"]);
|
|
209
|
+
case "file-edit":
|
|
210
|
+
return pick(["sha", "title"]);
|
|
211
|
+
case "file-archive":
|
|
212
|
+
case "file-restore":
|
|
213
|
+
return pick(["sha"]);
|
|
214
|
+
case "link":
|
|
215
|
+
return pick(["id", "subject", "file", "label", "page", "region", "quote"]);
|
|
216
|
+
case "link-edit":
|
|
217
|
+
return pick(["id", "subject", "label", "page", "region", "quote", "auto", "snap"]);
|
|
218
|
+
case "unlink":
|
|
219
|
+
return pick(["id"]);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** The event that records a checked request: its fields, then who and when. */
|
|
223
|
+
export function libraryEventOf(r, by, now, agent, person) {
|
|
224
|
+
return { ...fieldsOf(r), at: now, by, ...(person ? { person } : {}), ...(agent ? { agent: true } : {}) };
|
|
225
|
+
}
|
|
226
|
+
/** A line of a log holding a library event, read: the event, or why it is not one. */
|
|
227
|
+
export function readLibraryEvent(o, at, by, agent, person) {
|
|
228
|
+
const why = invalidLibrary(o);
|
|
229
|
+
if (why)
|
|
230
|
+
return why;
|
|
231
|
+
return libraryEventOf(fieldsOf(o), by, at, agent, person);
|
|
232
|
+
}
|
|
233
|
+
export const LINK_PARTS = ["subject", "label", "place"];
|
|
234
|
+
const noProto = () => Object.create(null);
|
|
235
|
+
function authorOf(e) {
|
|
236
|
+
return { by: e.by, at: e.at, ...(e.agent ? { agent: true } : {}), ...(e.person ? { person: e.person } : {}) };
|
|
237
|
+
}
|
|
238
|
+
/** Whether two values of a part of a place are the same: a region or a quote by what it holds. */
|
|
239
|
+
const samePart = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
240
|
+
/** One library event replayed. The first record of a hash stands: two servers adding the same bytes
|
|
241
|
+
* add one file. */
|
|
242
|
+
export function applyLibrary(d, e) {
|
|
243
|
+
const files = (d.files ??= noProto());
|
|
244
|
+
const links = (d.links ??= []);
|
|
245
|
+
const file = (sha) => (Object.hasOwn(files, sha) ? files[sha] : null);
|
|
246
|
+
// what the timeline tells of it, only where it changed something: the same bytes added again, or
|
|
247
|
+
// an edit to what already is, is no news
|
|
248
|
+
const told = (step) => (d.libraryHistory ??= []).push({ ...step, ...authorOf(e) });
|
|
249
|
+
switch (e.op) {
|
|
250
|
+
case "file": {
|
|
251
|
+
if (file(e.sha))
|
|
252
|
+
return;
|
|
253
|
+
const { op: _op, at: _at, by: _by, agent: _agent, person: _person, ...rest } = e;
|
|
254
|
+
files[e.sha] = { ...rest, ...authorOf(e) };
|
|
255
|
+
told({ op: "file", sha: e.sha, title: e.title });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
case "file-edit": {
|
|
259
|
+
const f = file(e.sha);
|
|
260
|
+
if (!f)
|
|
261
|
+
return;
|
|
262
|
+
files[e.sha] = { ...f, title: e.title, edited: authorOf(e) };
|
|
263
|
+
if (f.title !== e.title)
|
|
264
|
+
told({ op: "file-edit", sha: e.sha, title: e.title, wasTitle: f.title });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
case "file-archive": {
|
|
268
|
+
const f = file(e.sha);
|
|
269
|
+
if (f && !f.archived) {
|
|
270
|
+
files[e.sha] = { ...f, archived: authorOf(e) };
|
|
271
|
+
told({ op: "file-archive", sha: e.sha, title: f.title });
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
case "file-restore": {
|
|
276
|
+
const f = file(e.sha);
|
|
277
|
+
if (f?.archived) {
|
|
278
|
+
const { archived: _a, ...rest } = f;
|
|
279
|
+
files[e.sha] = rest;
|
|
280
|
+
told({ op: "file-restore", sha: e.sha, title: f.title });
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
case "link": {
|
|
285
|
+
if (links.some((l) => l.id === e.id))
|
|
286
|
+
return;
|
|
287
|
+
const { op: _op, at: _at, by: _by, agent: _agent, person: _person, ...rest } = e;
|
|
288
|
+
links.push({ ...rest, ...authorOf(e) });
|
|
289
|
+
told({ op: "link", id: e.id, subject: e.subject, label: e.label, file: e.file });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
case "link-edit": {
|
|
293
|
+
const k = links.findIndex((l) => l.id === e.id);
|
|
294
|
+
if (k < 0)
|
|
295
|
+
return;
|
|
296
|
+
const was = links[k];
|
|
297
|
+
const l = { ...was, edited: authorOf(e) };
|
|
298
|
+
if (e.subject !== undefined)
|
|
299
|
+
l.subject = e.subject;
|
|
300
|
+
if (e.label !== undefined)
|
|
301
|
+
l.label = e.label;
|
|
302
|
+
for (const part of ["page", "region", "quote"]) {
|
|
303
|
+
if (e[part] === null)
|
|
304
|
+
delete l[part];
|
|
305
|
+
else if (e[part] !== undefined)
|
|
306
|
+
l[part] = e[part];
|
|
307
|
+
}
|
|
308
|
+
links[k] = l;
|
|
309
|
+
const changed = LINK_PARTS.filter((p) => (p === "place" ? ["page", "region", "quote"].some((x) => !samePart(was[x], l[x])) : was[p] !== l[p]));
|
|
310
|
+
if (changed.length)
|
|
311
|
+
told({
|
|
312
|
+
op: "link-edit",
|
|
313
|
+
id: l.id,
|
|
314
|
+
subject: l.subject,
|
|
315
|
+
label: l.label,
|
|
316
|
+
file: l.file,
|
|
317
|
+
changed,
|
|
318
|
+
...(was.subject !== l.subject ? { wasSubject: was.subject } : {}),
|
|
319
|
+
...(was.label !== l.label ? { wasLabel: was.label } : {}),
|
|
320
|
+
...(e.auto ? { auto: e.auto } : {}),
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
case "unlink": {
|
|
325
|
+
const k = links.findIndex((l) => l.id === e.id);
|
|
326
|
+
if (k < 0)
|
|
327
|
+
return;
|
|
328
|
+
const [l] = links.splice(k, 1);
|
|
329
|
+
told({ op: "unlink", id: l.id, subject: l.subject, label: l.label, file: l.file });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Whether recording `r` would change nothing: a file already in the library, archived or restored
|
|
335
|
+
* as it is, its title the same; a link removed that is not there. */
|
|
336
|
+
export function libraryNoChange(d, r) {
|
|
337
|
+
const f = d.files && Object.hasOwn(d.files, r.sha ?? "") ? d.files[r.sha] : null;
|
|
338
|
+
switch (r.op) {
|
|
339
|
+
case "file":
|
|
340
|
+
return !!f;
|
|
341
|
+
case "file-edit":
|
|
342
|
+
return !f || f.title === r.title;
|
|
343
|
+
case "file-archive":
|
|
344
|
+
return !f || !!f.archived;
|
|
345
|
+
case "file-restore":
|
|
346
|
+
return !f || !f.archived;
|
|
347
|
+
case "link":
|
|
348
|
+
return !!d.links?.some((l) => l.id === r.id);
|
|
349
|
+
case "link-edit": {
|
|
350
|
+
const l = d.links?.find((x) => x.id === r.id);
|
|
351
|
+
if (!l)
|
|
352
|
+
return true;
|
|
353
|
+
return ((r.subject === undefined || r.subject === l.subject) &&
|
|
354
|
+
(r.label === undefined || r.label === l.label) &&
|
|
355
|
+
["page", "region", "quote"].every((k) => r[k] === undefined || samePart(r[k], l[k])));
|
|
356
|
+
}
|
|
357
|
+
case "unlink":
|
|
358
|
+
return !d.links?.some((l) => l.id === r.id);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/** Someone invited by a personal link goes by `name` now: what their id added and linked shows
|
|
362
|
+
* under it (shared/review.ts, `named`). */
|
|
363
|
+
export function renamePerson(d, id, name) {
|
|
364
|
+
const fix = (a) => {
|
|
365
|
+
if (a?.person === id)
|
|
366
|
+
a.by = name;
|
|
367
|
+
};
|
|
368
|
+
for (const f of Object.values(d.files ?? {}))
|
|
369
|
+
[f, f.edited, f.archived].forEach(fix);
|
|
370
|
+
for (const l of d.links ?? [])
|
|
371
|
+
[l, l.edited].forEach(fix);
|
|
372
|
+
for (const h of d.libraryHistory ?? [])
|
|
373
|
+
fix(h);
|
|
374
|
+
}
|
|
375
|
+
/** The library's records, links and history as JSON carried them (a page's review, a snapshot):
|
|
376
|
+
* objects of their own, keyed without a prototype. */
|
|
377
|
+
export function libraryFromJson(raw) {
|
|
378
|
+
const out = {};
|
|
379
|
+
if (raw.files && typeof raw.files === "object" && !Array.isArray(raw.files)) {
|
|
380
|
+
out.files = noProto();
|
|
381
|
+
for (const [sha, f] of Object.entries(raw.files))
|
|
382
|
+
if (validSha(sha) && f && typeof f === "object")
|
|
383
|
+
out.files[sha] = { ...f };
|
|
384
|
+
}
|
|
385
|
+
if (Array.isArray(raw.links))
|
|
386
|
+
out.links = raw.links.filter((l) => !!l && typeof l === "object" && validLinkId(l.id)).map((l) => ({ ...l }));
|
|
387
|
+
if (Array.isArray(raw.libraryHistory))
|
|
388
|
+
out.libraryHistory = raw.libraryHistory.flatMap((h) => stepFromJson(h) ?? []);
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
/** A step of the library's history as JSON carried it, checked field by field and made again of
|
|
392
|
+
* those fields alone; null where it is not one. The timeline draws what it holds, so nothing in it
|
|
393
|
+
* is taken on trust. */
|
|
394
|
+
function stepFromJson(raw) {
|
|
395
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
396
|
+
return null;
|
|
397
|
+
const o = raw;
|
|
398
|
+
// who and when, as a line of a log says it (shared/review.ts, `readEvent` and `validPersonId`)
|
|
399
|
+
if (!str(o.at, 40) || !str(o.by, 120))
|
|
400
|
+
return null;
|
|
401
|
+
if (o.agent !== undefined && o.agent !== true)
|
|
402
|
+
return null;
|
|
403
|
+
if (o.person !== undefined && !(typeof o.person === "string" && /^[0-9a-f]{10}$/.test(o.person)))
|
|
404
|
+
return null;
|
|
405
|
+
const who = { by: o.by, at: o.at, ...(o.agent ? { agent: true } : {}), ...(o.person ? { person: o.person } : {}) };
|
|
406
|
+
switch (o.op) {
|
|
407
|
+
case "file":
|
|
408
|
+
case "file-archive":
|
|
409
|
+
case "file-restore":
|
|
410
|
+
case "file-edit": {
|
|
411
|
+
if (!validSha(o.sha) || !str(o.title, LIMITS.title))
|
|
412
|
+
return null;
|
|
413
|
+
const f = { sha: o.sha, title: o.title };
|
|
414
|
+
if (o.op !== "file-edit")
|
|
415
|
+
return { op: o.op, ...f, ...who };
|
|
416
|
+
return str(o.wasTitle, LIMITS.title) ? { op: "file-edit", ...f, wasTitle: o.wasTitle, ...who } : null;
|
|
417
|
+
}
|
|
418
|
+
case "link":
|
|
419
|
+
case "unlink":
|
|
420
|
+
case "link-edit": {
|
|
421
|
+
if (!validLinkId(o.id) || !parseSubject(o.subject) || !str(o.label, LIMITS.label) || !validSha(o.file))
|
|
422
|
+
return null;
|
|
423
|
+
const l = { id: o.id, subject: o.subject, label: o.label, file: o.file };
|
|
424
|
+
if (o.op !== "link-edit")
|
|
425
|
+
return { op: o.op, ...l, ...who };
|
|
426
|
+
const changed = Array.isArray(o.changed) ? LINK_PARTS.filter((p) => o.changed.includes(p)) : [];
|
|
427
|
+
if (!changed.length)
|
|
428
|
+
return null;
|
|
429
|
+
// what it was before, for each of those that changed and only those
|
|
430
|
+
if (changed.includes("subject") ? !parseSubject(o.wasSubject) : o.wasSubject !== undefined)
|
|
431
|
+
return null;
|
|
432
|
+
if (changed.includes("label") ? !str(o.wasLabel, LIMITS.label) : o.wasLabel !== undefined)
|
|
433
|
+
return null;
|
|
434
|
+
if (!optStr(o.auto, 200))
|
|
435
|
+
return null;
|
|
436
|
+
return {
|
|
437
|
+
op: "link-edit",
|
|
438
|
+
...l,
|
|
439
|
+
changed,
|
|
440
|
+
...(o.wasSubject !== undefined ? { wasSubject: o.wasSubject } : {}),
|
|
441
|
+
...(o.wasLabel !== undefined ? { wasLabel: o.wasLabel } : {}),
|
|
442
|
+
...(o.auto !== undefined ? { auto: o.auto } : {}),
|
|
443
|
+
...who,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
default:
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
// ---------------------------------------------------------------------------------------------
|
|
451
|
+
// Reading it
|
|
452
|
+
/** A file's name without the extension of a type the library takes: what its title is where the
|
|
453
|
+
* file says none of its own (server/library.ts). */
|
|
454
|
+
export const nameStem = (name) => name.replace(/\.(pdf|png|jpe?g|webp|gif)$/i, "");
|
|
455
|
+
/** The name a file was added under, as it came and without its extension, where its record keeps one
|
|
456
|
+
* (an upload's, or the name an address served it under). */
|
|
457
|
+
export function addedNames(f) {
|
|
458
|
+
return f.name ? [...new Set([f.name, nameStem(f.name)])] : [];
|
|
459
|
+
}
|
|
460
|
+
/** Every name file `sha` went by before its title now, as the review records it: the name it was
|
|
461
|
+
* added under (addedNames), the title it was added with and each title an edit took from it (the
|
|
462
|
+
* history's) — each once, its title now left out. What the text of a note made for it once was,
|
|
463
|
+
* that follows its title (AttachmentSpec decision 12). */
|
|
464
|
+
export function formerNames(d, sha) {
|
|
465
|
+
const f = fileOf(d, sha);
|
|
466
|
+
if (!f)
|
|
467
|
+
return [];
|
|
468
|
+
const out = new Set(addedNames(f));
|
|
469
|
+
for (const h of d.libraryHistory ?? []) {
|
|
470
|
+
if (h.op === "file" && h.sha === sha)
|
|
471
|
+
out.add(h.title);
|
|
472
|
+
else if (h.op === "file-edit" && h.sha === sha)
|
|
473
|
+
out.add(h.wasTitle);
|
|
474
|
+
}
|
|
475
|
+
out.delete(f.title);
|
|
476
|
+
return [...out];
|
|
477
|
+
}
|
|
478
|
+
export function filesOf(d) {
|
|
479
|
+
return Object.values(d.files ?? {});
|
|
480
|
+
}
|
|
481
|
+
export function fileOf(d, sha) {
|
|
482
|
+
return d.files && Object.hasOwn(d.files, sha) ? d.files[sha] : null;
|
|
483
|
+
}
|
|
484
|
+
/** The links of a subject, in the order they were made (decision 4). */
|
|
485
|
+
export function linksOf(d, subject) {
|
|
486
|
+
return (d.links ?? []).filter((l) => l.subject === subject);
|
|
487
|
+
}
|
|
488
|
+
/** The links into a file, in the order they were made. */
|
|
489
|
+
export function linksInto(d, sha) {
|
|
490
|
+
return (d.links ?? []).filter((l) => l.file === sha);
|
|
491
|
+
}
|
|
492
|
+
export function linkOf(d, id) {
|
|
493
|
+
return (d.links ?? []).find((l) => l.id === id) ?? null;
|
|
494
|
+
}
|
|
495
|
+
/** What the reference window opens beside a declaration's source (decision 8): its own links, else
|
|
496
|
+
* those of the innermost cluster around it that has any under the board's clustering (`whose`
|
|
497
|
+
* says which), else none. */
|
|
498
|
+
export function pairedLinks(d, name, module, mode) {
|
|
499
|
+
const own = linksOf(d, declSubject(name));
|
|
500
|
+
if (own.length)
|
|
501
|
+
return { links: own, whose: declSubject(name) };
|
|
502
|
+
for (const s of clusterChain(name, module, mode)) {
|
|
503
|
+
const ls = linksOf(d, s);
|
|
504
|
+
if (ls.length)
|
|
505
|
+
return { links: ls, whose: s };
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
/** Why a link is not made on a past build's board: a past build is not reviewed (ViewSpec decision
|
|
510
|
+
* 26), and a link is part of the review (decision 6). */
|
|
511
|
+
export const PAST_LINK = "This board shows a past build: links are made on the latest build, as the review is.";
|
|
512
|
+
/** What the reference window offers for a place the reader picked (decision 5): to link the subject
|
|
513
|
+
* being linked (a panel's Link to a paper…), else the one selected on the board; or why not — the
|
|
514
|
+
* board refuses links (`cannot`: a past build, a snapshot of the page), or the file is archived;
|
|
515
|
+
* or nothing to link to yet. */
|
|
516
|
+
export function linkOffer(o) {
|
|
517
|
+
if (o.cannot)
|
|
518
|
+
return { refused: o.cannot };
|
|
519
|
+
if (o.archived)
|
|
520
|
+
return { refused: "This file is archived: restore it from the Library to link to it." };
|
|
521
|
+
if (o.linking)
|
|
522
|
+
return { subject: o.linking, here: true };
|
|
523
|
+
if (o.board)
|
|
524
|
+
return { subject: o.board, here: false };
|
|
525
|
+
return { none: true };
|
|
526
|
+
}
|
|
527
|
+
/** Text with its runs of white space made one space, and where each character of it came from in
|
|
528
|
+
* the text it was made of: what a quote is found by, whatever the line breaks of its page. */
|
|
529
|
+
export function squeeze(text) {
|
|
530
|
+
let s = "";
|
|
531
|
+
const from = [];
|
|
532
|
+
let space = true;
|
|
533
|
+
for (let k = 0; k < text.length; k++) {
|
|
534
|
+
const c = text[k];
|
|
535
|
+
if (/\s/.test(c)) {
|
|
536
|
+
if (!space) {
|
|
537
|
+
s += " ";
|
|
538
|
+
from.push(k);
|
|
539
|
+
}
|
|
540
|
+
space = true;
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
s += c;
|
|
544
|
+
from.push(k);
|
|
545
|
+
space = false;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (s.endsWith(" ")) {
|
|
549
|
+
s = s.slice(0, -1);
|
|
550
|
+
from.pop();
|
|
551
|
+
}
|
|
552
|
+
return { s, from };
|
|
553
|
+
}
|
|
554
|
+
/** How much text around a quote is kept with it. */
|
|
555
|
+
export const QUOTE_CONTEXT = 48;
|
|
556
|
+
/** A quote of `text` from `start` to `end` (offsets into it), with a little of the text around it. */
|
|
557
|
+
export function quoteAt(text, start, end, context = QUOTE_CONTEXT) {
|
|
558
|
+
const q = squeeze(text.slice(start, end)).s.slice(0, LIMITS.quote);
|
|
559
|
+
if (!q)
|
|
560
|
+
return null;
|
|
561
|
+
const before = squeeze(text.slice(Math.max(0, start - context * 3), start)).s.slice(-context);
|
|
562
|
+
const after = squeeze(text.slice(end, end + context * 3)).s.slice(0, context);
|
|
563
|
+
return { text: q, ...(before ? { before } : {}), ...(after ? { after } : {}) };
|
|
564
|
+
}
|
|
565
|
+
/** Where a quote is in a page's text: the offsets of the best match into `text` — the one whose
|
|
566
|
+
* surroundings agree most with what was kept around it — or null when it is not there. Spaces are
|
|
567
|
+
* read loosely, and so is case where no exact match is. */
|
|
568
|
+
export function findQuote(text, q) {
|
|
569
|
+
const page = squeeze(text);
|
|
570
|
+
const want = squeeze(q.text).s;
|
|
571
|
+
if (!want)
|
|
572
|
+
return null;
|
|
573
|
+
const tries = [page.s, page.s.toLowerCase()];
|
|
574
|
+
for (const [k, hay] of tries.entries()) {
|
|
575
|
+
const needle = k ? want.toLowerCase() : want;
|
|
576
|
+
let best = null;
|
|
577
|
+
for (let at = hay.indexOf(needle); at >= 0; at = hay.indexOf(needle, at + 1)) {
|
|
578
|
+
const before = q.before ? squeeze(q.before).s : "";
|
|
579
|
+
const after = q.after ? squeeze(q.after).s : "";
|
|
580
|
+
const score = common(hay.slice(Math.max(0, at - before.length - 1), at).trimEnd(), before, true) + common(hay.slice(at + needle.length).trimStart(), after, false);
|
|
581
|
+
if (!best || score > best.score)
|
|
582
|
+
best = { at, score };
|
|
583
|
+
}
|
|
584
|
+
if (best) {
|
|
585
|
+
const end = best.at + needle.length - 1;
|
|
586
|
+
return { start: page.from[best.at], end: page.from[end] + 1 };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
/** How many characters two texts share at their ends (`fromEnd`) or starts. */
|
|
592
|
+
function common(a, b, fromEnd) {
|
|
593
|
+
let n = 0;
|
|
594
|
+
while (n < a.length && n < b.length && (fromEnd ? a[a.length - 1 - n] === b[b.length - 1 - n] : a[n] === b[n]))
|
|
595
|
+
n++;
|
|
596
|
+
return n;
|
|
597
|
+
}
|
|
598
|
+
/** A link's label by default (decision 4): the page and the quote's first words; for an image,
|
|
599
|
+
* what of it. */
|
|
600
|
+
export function defaultLabel(place, type) {
|
|
601
|
+
const words = place.quote ? place.quote.text.split(" ").slice(0, 6).join(" ") : "";
|
|
602
|
+
const quoted = words ? `“${words.length > 60 ? words.slice(0, 59) + "…" : words}${words.length < place.quote.text.length ? "…" : ""}”` : "";
|
|
603
|
+
if (type !== "pdf")
|
|
604
|
+
return place.region ? "A region of the image" : "The image";
|
|
605
|
+
const page = place.page ? `p. ${place.page}` : "";
|
|
606
|
+
return [page, quoted].filter(Boolean).join(" ") || "The paper";
|
|
607
|
+
}
|
|
608
|
+
/** A page's text as its words make it: a line's words joined by spaces, lines by line breaks
|
|
609
|
+
* (`lines[k]` is where line k starts among the words); and where each word starts in it. */
|
|
610
|
+
export function wordsText(words, lines) {
|
|
611
|
+
let text = "";
|
|
612
|
+
const starts = [];
|
|
613
|
+
const lineStart = new Set(lines);
|
|
614
|
+
words.forEach((w, k) => {
|
|
615
|
+
if (k > 0)
|
|
616
|
+
text += lineStart.has(k) ? "\n" : " ";
|
|
617
|
+
starts.push(text.length);
|
|
618
|
+
text += w[0];
|
|
619
|
+
});
|
|
620
|
+
return { text, starts };
|
|
621
|
+
}
|
|
622
|
+
/** The words inside a box drawn on a page — those whose middle is in it — as a quote: their text in
|
|
623
|
+
* reading order, with a little of the page's text around them; null where the box holds none. What
|
|
624
|
+
* a box's label and quote are by default (the owner's decision of 2026-10-11). */
|
|
625
|
+
export function quoteInRegion(words, lines, r) {
|
|
626
|
+
const inside = [];
|
|
627
|
+
words.forEach((w, k) => {
|
|
628
|
+
const x = (w[1] + w[3]) / 2, y = (w[2] + w[4]) / 2;
|
|
629
|
+
if (x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3])
|
|
630
|
+
inside.push(k);
|
|
631
|
+
});
|
|
632
|
+
if (!inside.length)
|
|
633
|
+
return null;
|
|
634
|
+
const { text, starts } = wordsText(words, lines);
|
|
635
|
+
const first = inside[0], last = inside[inside.length - 1];
|
|
636
|
+
const q = quoteAt(text, starts[first], starts[last] + words[last][0].length);
|
|
637
|
+
if (!q)
|
|
638
|
+
return null;
|
|
639
|
+
// a box across two columns holds words the text runs between: the quote is its own words alone,
|
|
640
|
+
// kept with the text around them
|
|
641
|
+
const own = inside.length === last - first + 1 ? q.text : inside.map((k) => words[k][0]).join(" ");
|
|
642
|
+
// a glyph a PDF's font maps to no character comes as a control character (a formula's symbols):
|
|
643
|
+
// it is no text to quote, and a note made of the quote could not hold it
|
|
644
|
+
const clean = (s) => (s ? squeeze(s.replace(CONTROL, " ")).s : "");
|
|
645
|
+
const out = clean(own).slice(0, LIMITS.quote);
|
|
646
|
+
if (!out)
|
|
647
|
+
return null;
|
|
648
|
+
const before = clean(q.before).slice(-QUOTE_CONTEXT), after = clean(q.after).slice(0, QUOTE_CONTEXT);
|
|
649
|
+
return { text: out, ...(before ? { before } : {}), ...(after ? { after } : {}) };
|
|
650
|
+
}
|
|
651
|
+
/** Control characters, which a word read from a PDF may carry for a glyph its font maps to nothing. */
|
|
652
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
653
|
+
/** Boxes of words merged line by line: one box for the words of each line, in their order, as a
|
|
654
|
+
* search's match and a quote are lit. Two boxes are on one line where they overlap by half the
|
|
655
|
+
* height of the lower one, and the second does not start left of the first. */
|
|
656
|
+
export function lineRects(boxes) {
|
|
657
|
+
const out = [];
|
|
658
|
+
for (const b of boxes) {
|
|
659
|
+
const last = out[out.length - 1];
|
|
660
|
+
const overlap = last ? Math.min(last[3], b[3]) - Math.max(last[1], b[1]) : 0;
|
|
661
|
+
if (last && overlap >= 0.5 * Math.min(last[3] - last[1], b[3] - b[1]) && b[0] >= last[0]) {
|
|
662
|
+
out[out.length - 1] = [last[0], Math.min(last[1], b[1]), Math.max(last[2], b[2]), Math.max(last[3], b[3])];
|
|
663
|
+
}
|
|
664
|
+
else
|
|
665
|
+
out.push([...b]);
|
|
666
|
+
}
|
|
667
|
+
return out;
|
|
668
|
+
}
|
|
669
|
+
/** Where a quote is on a page, as the boxes of the words it spans, line by line; null where the
|
|
670
|
+
* page's words do not hold it. */
|
|
671
|
+
export function quoteRects(words, lines, q) {
|
|
672
|
+
const { text, starts } = wordsText(words, lines);
|
|
673
|
+
const at = findQuote(text, q);
|
|
674
|
+
if (!at)
|
|
675
|
+
return null;
|
|
676
|
+
const boxes = [];
|
|
677
|
+
words.forEach((w, k) => {
|
|
678
|
+
if (starts[k] < at.end && starts[k] + w[0].length > at.start)
|
|
679
|
+
boxes.push([w[1], w[2], w[3], w[4]]);
|
|
680
|
+
});
|
|
681
|
+
return boxes.length ? lineRects(boxes) : null;
|
|
682
|
+
}
|
|
683
|
+
/** Where a link is lit on its page (the owner's decision of 2026-10-11): by its region where it has
|
|
684
|
+
* one; else by its quote's words, where the page's words are known (`words`: null where they cannot
|
|
685
|
+
* be read, undefined while they have not come) and hold it; else nowhere. `rects` the boxes to
|
|
686
|
+
* light, `box` the box around them. Nothing about the words stops a region from being lit. */
|
|
687
|
+
export function placeOnPage(place, words) {
|
|
688
|
+
if (place.region)
|
|
689
|
+
return { rects: [place.region], box: place.region };
|
|
690
|
+
let rects = null;
|
|
691
|
+
try {
|
|
692
|
+
rects = words && place.quote ? quoteRects(words.words, words.lines, place.quote) : null;
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
// words of a shape this page does not expect: the quote is not lit, and nothing else changes
|
|
696
|
+
}
|
|
697
|
+
if (!rects?.length)
|
|
698
|
+
return { rects: [], box: null };
|
|
699
|
+
return { rects, box: [Math.min(...rects.map((r) => r[0])), Math.min(...rects.map((r) => r[1])), Math.max(...rects.map((r) => r[2])), Math.max(...rects.map((r) => r[3]))] };
|
|
700
|
+
}
|
|
701
|
+
/** A region as the API and the page write it, each part to four decimals. */
|
|
702
|
+
export function roundRegion(r) {
|
|
703
|
+
return r.map((x) => Math.round(Math.min(1, Math.max(0, x)) * 1e4) / 1e4);
|
|
704
|
+
}
|
|
705
|
+
/** A size in words, in binary units as the limits are set: 1.2 MB, 340 KB, 2 GB. */
|
|
706
|
+
export function sizeWords(n) {
|
|
707
|
+
const G = 1024 ** 3, M = 1024 ** 2;
|
|
708
|
+
if (n >= G)
|
|
709
|
+
return `${+(n / G).toFixed(1)} GB`;
|
|
710
|
+
return n >= M ? `${(n / M).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`;
|
|
711
|
+
}
|