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,501 @@
|
|
|
1
|
+
// Notes on a board (ViewSpec decision 23, CanvasSpec "Notes", the owner's decisions of 2026-10-09):
|
|
2
|
+
// short texts people and agents stick on a board. Since 2026-10-11 a note is the project's unless it
|
|
3
|
+
// is made a view's own (AttachmentSpec decisions 13–19): the project's notes are
|
|
4
|
+
// shared/projectnotes.ts's, and where either kind is on a board is shared/noteplace.ts's. What this
|
|
5
|
+
// file holds is a view note — ViewSpec decision 23's note, kept with its view on the server and
|
|
6
|
+
// changed by the view's operations (shared/views.ts), so that everyone on the board sees it at once —
|
|
7
|
+
// with its checks, and the rules both kinds share of how a note shows; it is drawn by the pages
|
|
8
|
+
// (web/src/sticky.ts).
|
|
9
|
+
//
|
|
10
|
+
// Where a view note is: attached to the most specific cluster at the spot where it was put, at an
|
|
11
|
+
// offset in that cluster's local coordinates, so that it moves with the cluster; on the canvas
|
|
12
|
+
// itself, in world coordinates, where no cluster is there. A note keeps the offsets of the spot in
|
|
13
|
+
// every cluster that held it, and in the world, so that when its cluster is gone (a new graph, a
|
|
14
|
+
// scope that no longer holds it) it goes to the nearest one that is still there, where the spot was
|
|
15
|
+
// in it, or to the canvas. Under the other clustering than the one it was put under it is on the
|
|
16
|
+
// ring around the board (AttachmentSpec decision 16), never in a cluster that happens to have the
|
|
17
|
+
// same key. Either kind may be attached to a declaration instead (the owner's decision of
|
|
18
|
+
// 2026-10-12, NoteDecl): that is its place under every clustering, and the place it has besides is
|
|
19
|
+
// where it falls back to when the declaration is gone. What is here runs the same on the server, in
|
|
20
|
+
// the pages and in the tests.
|
|
21
|
+
import { validPersonId } from "./review.js";
|
|
22
|
+
/** the most a note carries: a note is a few words and what they point at, not a folder */
|
|
23
|
+
export const MAX_CARRIES = 8;
|
|
24
|
+
/** the farthest a card's centre is kept from its icon along either axis, in pixels of the screen:
|
|
25
|
+
* further, it would be off a smaller screen than the one it was put on, whichever way it flips */
|
|
26
|
+
export const CARD_REACH = 600;
|
|
27
|
+
/** where a note attached to a declaration sits by default, in the board's units: up and to the right
|
|
28
|
+
* of its point, about three declarations away — at the zoom a click on a declaration flies to (1.1),
|
|
29
|
+
* 37 px on the screen, its leader showing */
|
|
30
|
+
export const DECL_AT = [24, -24];
|
|
31
|
+
/** the nearest an attached note sits to its declaration's point, centre to centre, in the board's
|
|
32
|
+
* units: a little more than a point across, so that it always has a side */
|
|
33
|
+
export const DECL_NEAR = 12;
|
|
34
|
+
/** the reach, in the board's units — about six declarations away: the radius of the circle round a
|
|
35
|
+
* declaration in which a note attached to it, dragged, stays attached, where that is between
|
|
36
|
+
* ZONE_MIN_PX and ZONE_MAX_PX on the screen (shared/notecard.ts, zonePx); and the furthest a drop
|
|
37
|
+
* pushes a note out from the point to clear it, so that a note let go on its point zoomed out stays
|
|
38
|
+
* near it on the board */
|
|
39
|
+
export const DECL_REACH = 64;
|
|
40
|
+
/** The circle in which a dragged note stays attached to its declaration (the owner's decision of
|
|
41
|
+
* 2026-10-12), by its radius on the screen, in pixels: never smaller than ZONE_MIN_PX, so that zoomed
|
|
42
|
+
* out it shows well round the icon (24 px across), a short move does not leave it, and it holds an
|
|
43
|
+
* icon pushed out to clear the point (shared/notecard.ts, DECL_CLEAR_PX, 40 px); never larger than
|
|
44
|
+
* ZONE_MAX_PX, so that zoomed in a short move takes a note off its declaration too, and it still holds
|
|
45
|
+
* DECL_NEAR at the most zoom (144 px at 12). */
|
|
46
|
+
export const ZONE_MIN_PX = 48;
|
|
47
|
+
export const ZONE_MAX_PX = 160;
|
|
48
|
+
/** the least and the most zoom the canvas goes to (web/src/canvas.ts) */
|
|
49
|
+
export const ZOOM_LEAST = 0.02;
|
|
50
|
+
export const ZOOM_MOST = 12;
|
|
51
|
+
/** the farthest an attached note sits from its declaration's point, in the board's units: as far as
|
|
52
|
+
* the circle in which it stays attached reaches at the least zoom, so that wherever its icon is let
|
|
53
|
+
* go inside the circle, at any zoom, it is kept there */
|
|
54
|
+
export const DECL_FAR = ZONE_MIN_PX / ZOOM_LEAST;
|
|
55
|
+
export const NOTE_OPS = ["note", "note-edit", "note-move", "note-open", "note-card", "note-decl", "note-delete"];
|
|
56
|
+
/** the longest a note is, in characters */
|
|
57
|
+
export const MAX_NOTE_TEXT = 2000;
|
|
58
|
+
/** the most notes a view holds: a board, not a document */
|
|
59
|
+
export const MAX_NOTES = 300;
|
|
60
|
+
/** A cluster's radius on the screen below which a note in it shows collapsed, whatever its own
|
|
61
|
+
* state (CanvasSpec, "Notes"): an expanded note is some 240 px wide, and a cluster smaller than
|
|
62
|
+
* that on the screen would be covered by it, its labels and its neighbours with it. */
|
|
63
|
+
export const AUTO_COLLAPSE_PX = 120;
|
|
64
|
+
const CLUSTER_MODES = ["namespace", "module"];
|
|
65
|
+
const finite = (v, most = 1e7) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) < most;
|
|
66
|
+
export function validNoteId(x) {
|
|
67
|
+
return typeof x === "string" && /^[A-Za-z0-9_-]{1,32}$/.test(x);
|
|
68
|
+
}
|
|
69
|
+
/** Why a note's text cannot be taken, or null. */
|
|
70
|
+
export function invalidNoteText(x) {
|
|
71
|
+
if (typeof x !== "string" || !x.trim())
|
|
72
|
+
return "A note needs text.";
|
|
73
|
+
if (x.length > MAX_NOTE_TEXT)
|
|
74
|
+
return `A note is at most ${MAX_NOTE_TEXT.toLocaleString("en")} characters.`;
|
|
75
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(x))
|
|
76
|
+
return "A note's text has control characters.";
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/** Why what a note carries cannot be taken, or null: absent, or a list of at most MAX_CARRIES files
|
|
80
|
+
* of the library and clips of their pages (NoteCarry), none twice. A region is a box of a page, so
|
|
81
|
+
* it comes with the page. Whether the library has the files is the server's to say (`carryProblem`). */
|
|
82
|
+
export function invalidCarries(x) {
|
|
83
|
+
if (x === undefined)
|
|
84
|
+
return null;
|
|
85
|
+
if (!Array.isArray(x))
|
|
86
|
+
return "carries";
|
|
87
|
+
if (x.length > MAX_CARRIES)
|
|
88
|
+
return `A note carries at most ${MAX_CARRIES} files and clips.`;
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
for (const c of x) {
|
|
91
|
+
if (!c || typeof c !== "object" || typeof c.file !== "string" || !/^[0-9a-f]{64}$/.test(c.file))
|
|
92
|
+
return "carries.file";
|
|
93
|
+
if (c.page !== undefined && !(Number.isInteger(c.page) && c.page >= 1 && c.page < 1e6))
|
|
94
|
+
return "carries.page";
|
|
95
|
+
if (c.region !== undefined) {
|
|
96
|
+
const r = c.region;
|
|
97
|
+
if (!Array.isArray(r) || r.length !== 4 || !r.every((v) => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1) || !(r[2] > r[0] && r[3] > r[1]))
|
|
98
|
+
return "carries.region";
|
|
99
|
+
if (c.page === undefined)
|
|
100
|
+
return "carries.region: a clip is a region of a page, and comes with the page";
|
|
101
|
+
}
|
|
102
|
+
const key = carryKey(c);
|
|
103
|
+
if (seen.has(key))
|
|
104
|
+
return "A note carries a file or a clip once.";
|
|
105
|
+
seen.add(key);
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
/** What a note carries with `c` attached (a note's Attach…): the list, `c` last; or why not — it
|
|
110
|
+
* carries that already, or as much as a note carries. */
|
|
111
|
+
export function withCarry(had, c) {
|
|
112
|
+
const list = had ?? [];
|
|
113
|
+
if (list.some((x) => carryKey(x) === carryKey(c)))
|
|
114
|
+
return "The note carries it already.";
|
|
115
|
+
if (list.length >= MAX_CARRIES)
|
|
116
|
+
return `A note carries at most ${MAX_CARRIES} files and clips: detach one first.`;
|
|
117
|
+
return [...list, c];
|
|
118
|
+
}
|
|
119
|
+
/** The text of a note made of a clip (Clip to a note, in the reference window): what the field says
|
|
120
|
+
* where the reader wrote in it; left as it came (`label`, the link's label by default), the text
|
|
121
|
+
* selected, quoted in full with its page, where text was selected; else that label. At most
|
|
122
|
+
* MAX_NOTE_TEXT, cut with an ellipsis. */
|
|
123
|
+
export function clipNoteText(field, label, quote, page) {
|
|
124
|
+
const typed = field.replace(/\s+/g, " ").trim();
|
|
125
|
+
const text = typed && typed !== label ? typed : quote ? `“${quote}” — p. ${page}` : typed || label;
|
|
126
|
+
return text.length > MAX_NOTE_TEXT ? text.slice(0, MAX_NOTE_TEXT - 1) + "…" : text;
|
|
127
|
+
}
|
|
128
|
+
/** A carry as one key: its file, page and region, what tells two carries of a note apart. */
|
|
129
|
+
export const carryKey = (c) => `${c.file}:${c.page ?? ""}:${c.region?.join(",") ?? ""}`;
|
|
130
|
+
/** Whether two notes carry the same, in the same order (nothing and an empty list alike). */
|
|
131
|
+
export function sameCarries(a, b) {
|
|
132
|
+
const x = a ?? [], y = b ?? [];
|
|
133
|
+
return x.length === y.length && x.every((c, k) => carryKey(c) === carryKey(y[k]));
|
|
134
|
+
}
|
|
135
|
+
/** What a note is to carry that it did not carry before (`had`): what the library checks. A carry it
|
|
136
|
+
* has already stays, whatever became of its file since — archived, or this server never had it. */
|
|
137
|
+
export function newCarries(next, had) {
|
|
138
|
+
const old = new Set((had ?? []).map(carryKey));
|
|
139
|
+
return (next ?? []).filter((c) => !old.has(carryKey(c)));
|
|
140
|
+
}
|
|
141
|
+
/** Why a note cannot take carry `c`, the library's record of its file being `file` (null where the
|
|
142
|
+
* library has none), or null: the file is in the library and not archived; an image is carried
|
|
143
|
+
* whole; a page of a PDF is one it has. */
|
|
144
|
+
export function carryProblem(c, file) {
|
|
145
|
+
if (!file)
|
|
146
|
+
return `The library has no file ${c.file.slice(0, 12)}…: add it to the library first.`;
|
|
147
|
+
if (file.archived)
|
|
148
|
+
return `${file.title} is archived: restore it from the library's archive to attach it.`;
|
|
149
|
+
if (file.type !== "pdf")
|
|
150
|
+
return c.page !== undefined || c.region ? `${file.title} is an image: a note carries it whole, with no page or region.` : null;
|
|
151
|
+
if (c.page !== undefined && file.pages && c.page > file.pages)
|
|
152
|
+
return `${file.title} has ${file.pages} page${file.pages === 1 ? "" : "s"}: page ${c.page} is not one of them.`;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
/** What a note carries as a record keeps it: only the fields a carry has, none where it is empty. */
|
|
156
|
+
export function cleanCarries(x) {
|
|
157
|
+
if (invalidCarries(x) || !Array.isArray(x) || !x.length)
|
|
158
|
+
return {};
|
|
159
|
+
return { carries: x.map((c) => ({ file: c.file, ...(c.page !== undefined ? { page: c.page } : {}), ...(c.region ? { region: [...c.region] } : {}) })) };
|
|
160
|
+
}
|
|
161
|
+
/** Why a card's offset cannot be taken, or null: a pair of numbers within CARD_REACH, or null (the
|
|
162
|
+
* default) where `none` allows it. */
|
|
163
|
+
export function invalidCard(x, none = true) {
|
|
164
|
+
if (x === null && none)
|
|
165
|
+
return null;
|
|
166
|
+
return Array.isArray(x) && x.length === 2 && x.every((v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= CARD_REACH) ? null : "card";
|
|
167
|
+
}
|
|
168
|
+
/** A card's offset as a record keeps it: whole pixels, none where it cannot be read. */
|
|
169
|
+
export function cleanCard(x) {
|
|
170
|
+
return x !== undefined && !invalidCard(x, false) ? { card: [Math.round(x[0]), Math.round(x[1])] } : {};
|
|
171
|
+
}
|
|
172
|
+
/** Why the declaration a note is attached to cannot be taken, or null: a full name — some text on
|
|
173
|
+
* one line, at most 2,000 characters — and an offset within DECL_FAR (`reach`: the store reads any
|
|
174
|
+
* offset, and brings it within); or null (none) where `none` allows it. Whether the graph has that
|
|
175
|
+
* declaration is not asked: a note attached on a board of a past build may name one the latest
|
|
176
|
+
* build lacks. */
|
|
177
|
+
export function invalidDecl(x, none = true, reach = true) {
|
|
178
|
+
if (x === null && none)
|
|
179
|
+
return null;
|
|
180
|
+
const d = x;
|
|
181
|
+
if (!d || typeof d !== "object" || Array.isArray(d))
|
|
182
|
+
return "decl";
|
|
183
|
+
if (typeof d.name !== "string" || !d.name.trim() || d.name.length > 2000 || /[\u0000-\u001f\u007f]/.test(d.name))
|
|
184
|
+
return "decl.name";
|
|
185
|
+
const at = d.at;
|
|
186
|
+
if (!Array.isArray(at) || at.length !== 2 || !at.every((v) => typeof v === "number" && Number.isFinite(v)))
|
|
187
|
+
return "decl.at";
|
|
188
|
+
// within its reach, a tenth of a unit rounded either way
|
|
189
|
+
if (reach && Math.hypot(at[0], at[1]) > DECL_FAR + 0.1)
|
|
190
|
+
return "decl.at";
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
/** An offset of an attached note from its declaration's point (NoteDecl.at, the board's units) as it
|
|
194
|
+
* is kept: along its own direction, at least `near` (DECL_NEAR, or more where a drop asks it: never
|
|
195
|
+
* beyond DECL_REACH) and at most DECL_FAR from the point, to a tenth of a unit; DECL_AT where it has
|
|
196
|
+
* no direction. */
|
|
197
|
+
export function keptDecl(d, near = DECL_NEAR) {
|
|
198
|
+
const len = Math.hypot(d[0], d[1]);
|
|
199
|
+
if (!(len >= 0.1))
|
|
200
|
+
return [DECL_AT[0], DECL_AT[1]];
|
|
201
|
+
const least = Math.min(DECL_REACH, Math.max(DECL_NEAR, near));
|
|
202
|
+
const f = len < least ? least / len : len > DECL_FAR ? DECL_FAR / len : 1;
|
|
203
|
+
return [Math.round(d[0] * f * 10) / 10, Math.round(d[1] * f * 10) / 10];
|
|
204
|
+
}
|
|
205
|
+
/** The declaration a note is attached to as a record keeps it: its name, and its offset kept as
|
|
206
|
+
* keptDecl keeps it, brought within DECL_FAR, so that an attachment kept with an offset beyond it
|
|
207
|
+
* stays attached; none where it cannot be read. */
|
|
208
|
+
export function cleanDecl(x) {
|
|
209
|
+
if (x === undefined || invalidDecl(x, false, false))
|
|
210
|
+
return {};
|
|
211
|
+
const d = x;
|
|
212
|
+
return { decl: { name: d.name, at: keptDecl(d.at) } };
|
|
213
|
+
}
|
|
214
|
+
/** Note `n` attached to `decl` (null: to none); whether that changed it. */
|
|
215
|
+
export function setDecl(n, decl) {
|
|
216
|
+
const d = decl ? cleanDecl(decl).decl : undefined;
|
|
217
|
+
if (d ? !!n.decl && n.decl.name === d.name && n.decl.at[0] === d.at[0] && n.decl.at[1] === d.at[1] : !n.decl)
|
|
218
|
+
return false;
|
|
219
|
+
if (d)
|
|
220
|
+
n.decl = d;
|
|
221
|
+
else
|
|
222
|
+
delete n.decl;
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
/** Note `n`'s card made to hang at `card` (null: as by default); whether that changed it. */
|
|
226
|
+
export function setCard(n, card) {
|
|
227
|
+
const c = card ? cleanCard(card).card : undefined;
|
|
228
|
+
if (c ? !!n.card && n.card[0] === c[0] && n.card[1] === c[1] : !n.card)
|
|
229
|
+
return false;
|
|
230
|
+
if (c)
|
|
231
|
+
n.card = c;
|
|
232
|
+
else
|
|
233
|
+
delete n.card;
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
/** Why a place cannot be taken, or null. */
|
|
237
|
+
export function invalidPlace(x) {
|
|
238
|
+
const p = x;
|
|
239
|
+
if (!p || typeof p !== "object")
|
|
240
|
+
return "place";
|
|
241
|
+
if (!CLUSTER_MODES.includes(p.mode))
|
|
242
|
+
return "place.mode";
|
|
243
|
+
if (!Array.isArray(p.in) || p.in.length > 64)
|
|
244
|
+
return "place.in";
|
|
245
|
+
for (const e of p.in)
|
|
246
|
+
if (!Array.isArray(e) || e.length !== 3 || typeof e[0] !== "string" || !e[0] || e[0].length > 2000 || !finite(e[1]) || !finite(e[2]))
|
|
247
|
+
return "place.in";
|
|
248
|
+
if (!Array.isArray(p.world) || p.world.length !== 2 || !p.world.every((v) => finite(v)))
|
|
249
|
+
return "place.world";
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
/** Why a note operation is refused, or null. */
|
|
253
|
+
export function invalidNoteOp(o) {
|
|
254
|
+
if (o.op === "note") {
|
|
255
|
+
const n = o.note;
|
|
256
|
+
if (!n || typeof n !== "object")
|
|
257
|
+
return "note";
|
|
258
|
+
if (!validNoteId(n.id))
|
|
259
|
+
return "note.id";
|
|
260
|
+
const t = invalidNoteText(n.text);
|
|
261
|
+
if (t)
|
|
262
|
+
return t;
|
|
263
|
+
if (typeof n.open !== "boolean")
|
|
264
|
+
return "note.open";
|
|
265
|
+
if (n.card !== undefined && invalidCard(n.card, false))
|
|
266
|
+
return "note.card";
|
|
267
|
+
if (n.decl !== undefined && invalidDecl(n.decl, false))
|
|
268
|
+
return "note.decl";
|
|
269
|
+
return invalidPlace(n.place) ?? invalidCarries(n.carries);
|
|
270
|
+
}
|
|
271
|
+
if (!validNoteId(o.id))
|
|
272
|
+
return "id";
|
|
273
|
+
if (o.op === "note-edit")
|
|
274
|
+
return invalidNoteText(o.text) ?? invalidCarries(o.carries);
|
|
275
|
+
if (o.op === "note-move")
|
|
276
|
+
return invalidPlace(o.place);
|
|
277
|
+
if (o.op === "note-open")
|
|
278
|
+
return typeof o.open === "boolean" ? null : "open";
|
|
279
|
+
if (o.op === "note-card")
|
|
280
|
+
return invalidCard(o.card);
|
|
281
|
+
if (o.op === "note-decl")
|
|
282
|
+
return invalidDecl(o.decl);
|
|
283
|
+
return o.op === "note-delete" ? null : "op";
|
|
284
|
+
}
|
|
285
|
+
const round = (v) => Math.round(v * 10) / 10;
|
|
286
|
+
/** Apply one note operation to a view's notes in place; true when anything changed. A note
|
|
287
|
+
* beyond MAX_NOTES, or one whose id is taken, is not added. */
|
|
288
|
+
export function applyNoteOp(notes, o) {
|
|
289
|
+
if (o.op === "note") {
|
|
290
|
+
if (notes.length >= MAX_NOTES || notes.some((n) => n.id === o.note.id))
|
|
291
|
+
return false;
|
|
292
|
+
const n = structuredClone(o.note);
|
|
293
|
+
if (n.decl)
|
|
294
|
+
n.decl = cleanDecl(n.decl).decl;
|
|
295
|
+
if (!n.decl)
|
|
296
|
+
delete n.decl;
|
|
297
|
+
notes.push(n);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
const k = notes.findIndex((n) => n.id === o.id);
|
|
301
|
+
if (k < 0)
|
|
302
|
+
return false;
|
|
303
|
+
const n = notes[k];
|
|
304
|
+
switch (o.op) {
|
|
305
|
+
case "note-edit":
|
|
306
|
+
if (n.text === o.text && (o.carries === undefined || sameCarries(n.carries, o.carries)))
|
|
307
|
+
return false;
|
|
308
|
+
n.text = o.text;
|
|
309
|
+
if (o.carries !== undefined)
|
|
310
|
+
setCarries(n, o.carries);
|
|
311
|
+
if (o.at)
|
|
312
|
+
n.edited = { by: o.by ?? null, ...(o.agent ? { agent: true } : {}), ...(o.person ? { person: o.person } : {}), at: o.at };
|
|
313
|
+
return true;
|
|
314
|
+
case "note-move":
|
|
315
|
+
n.place = structuredClone(o.place);
|
|
316
|
+
return true;
|
|
317
|
+
case "note-open":
|
|
318
|
+
if (n.open === o.open)
|
|
319
|
+
return false;
|
|
320
|
+
n.open = o.open;
|
|
321
|
+
return true;
|
|
322
|
+
case "note-card":
|
|
323
|
+
return setCard(n, o.card);
|
|
324
|
+
case "note-decl":
|
|
325
|
+
return setDecl(n, o.decl);
|
|
326
|
+
case "note-delete":
|
|
327
|
+
notes.splice(k, 1);
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/** What note `n` carries made `carries`: a list of its own, none where it is empty. A new array
|
|
332
|
+
* each time, so that what was kept of the note before (the timeline's `before`) keeps the old one. */
|
|
333
|
+
export function setCarries(n, carries) {
|
|
334
|
+
const c = cleanCarries(carries).carries;
|
|
335
|
+
if (c)
|
|
336
|
+
n.carries = c;
|
|
337
|
+
else
|
|
338
|
+
delete n.carries;
|
|
339
|
+
}
|
|
340
|
+
/** Mark `x` (a new note, or an edit) as done by `who` at `at`: the name, the agent's mark, and the
|
|
341
|
+
* personal link they came by — never with a note without a name (a private reader's). */
|
|
342
|
+
export function stampWho(x, who, at) {
|
|
343
|
+
x.by = who?.by ?? null;
|
|
344
|
+
if (who?.agent)
|
|
345
|
+
x.agent = true;
|
|
346
|
+
else
|
|
347
|
+
delete x.agent;
|
|
348
|
+
if (x.by && who?.person && !who.agent)
|
|
349
|
+
x.person = who.person;
|
|
350
|
+
else
|
|
351
|
+
delete x.person;
|
|
352
|
+
x.at = at;
|
|
353
|
+
}
|
|
354
|
+
/** Stamp who does a note operation, and when, as the server records who dropped a pin: the
|
|
355
|
+
* author of a new note, the editor of an edit. */
|
|
356
|
+
export function stampNoteOp(o, who, at) {
|
|
357
|
+
if (o.op === "note") {
|
|
358
|
+
stampWho(o.note, who, at);
|
|
359
|
+
delete o.note.edited;
|
|
360
|
+
}
|
|
361
|
+
else if (o.op === "note-edit")
|
|
362
|
+
stampWho(o, who, at);
|
|
363
|
+
}
|
|
364
|
+
/** A view's notes as views.json holds them, checked: one that cannot be read is left out. */
|
|
365
|
+
export function cleanNotes(raw) {
|
|
366
|
+
if (!Array.isArray(raw))
|
|
367
|
+
return [];
|
|
368
|
+
const out = [];
|
|
369
|
+
for (const n of raw) {
|
|
370
|
+
if (!n || typeof n !== "object" || !validNoteId(n.id) || invalidNoteText(n.text) || invalidPlace(n.place) || typeof n.open !== "boolean")
|
|
371
|
+
continue;
|
|
372
|
+
if (out.some((x) => x.id === n.id) || out.length >= MAX_NOTES)
|
|
373
|
+
continue;
|
|
374
|
+
const ed = n.edited;
|
|
375
|
+
out.push({
|
|
376
|
+
id: n.id,
|
|
377
|
+
text: n.text,
|
|
378
|
+
place: n.place,
|
|
379
|
+
open: n.open,
|
|
380
|
+
by: typeof n.by === "string" ? n.by : null,
|
|
381
|
+
...(n.agent === true ? { agent: true } : {}),
|
|
382
|
+
...(validPersonId(n.person) ? { person: n.person } : {}),
|
|
383
|
+
at: typeof n.at === "string" ? n.at : new Date(0).toISOString(),
|
|
384
|
+
...cleanEdited(ed),
|
|
385
|
+
...cleanCarries(n.carries),
|
|
386
|
+
...cleanCard(n.card),
|
|
387
|
+
...cleanDecl(n.decl),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
/** A note's last edit as a file holds it, checked: none where it cannot be read. */
|
|
393
|
+
export function cleanEdited(ed) {
|
|
394
|
+
const e = ed;
|
|
395
|
+
return e && typeof e === "object" && typeof e.at === "string"
|
|
396
|
+
? { edited: { by: typeof e.by === "string" ? e.by : null, ...(e.agent ? { agent: true } : {}), ...(validPersonId(e.person) ? { person: e.person } : {}), at: e.at } }
|
|
397
|
+
: {};
|
|
398
|
+
}
|
|
399
|
+
/** The place of a spot of a board: the most specific open cluster whose circle holds it, and every
|
|
400
|
+
* cluster around that one, each with the spot's offset from its centre, and the spot itself. */
|
|
401
|
+
export function placeAt(mode, world, clusters) {
|
|
402
|
+
const [wx, wy] = world;
|
|
403
|
+
let best = -1;
|
|
404
|
+
clusters.forEach((c, k) => {
|
|
405
|
+
if (!c.open || (c.x - wx) ** 2 + (c.y - wy) ** 2 > c.r * c.r)
|
|
406
|
+
return;
|
|
407
|
+
if (best < 0 || c.depth > clusters[best].depth)
|
|
408
|
+
best = k;
|
|
409
|
+
});
|
|
410
|
+
const chain = [];
|
|
411
|
+
for (let k = best; k >= 0; k = clusters[k].parent)
|
|
412
|
+
chain.push([clusters[k].key, round(wx - clusters[k].x), round(wy - clusters[k].y)]);
|
|
413
|
+
return { mode, in: chain, world: [round(wx), round(wy)] };
|
|
414
|
+
}
|
|
415
|
+
/** A board's clusters as placing a note reads them, at rest: where its layout puts each, or its
|
|
416
|
+
* pin (in its parent's coordinates) where it has one, summed down the tree; folds close what they
|
|
417
|
+
* hold. The pages read their scene instead, which is where things are drawn. */
|
|
418
|
+
export function boardClusters(L, pins, folds) {
|
|
419
|
+
const folded = new Set(folds);
|
|
420
|
+
const out = [];
|
|
421
|
+
const at = (c) => {
|
|
422
|
+
if (out[c])
|
|
423
|
+
return out[c];
|
|
424
|
+
const cl = L.clusters[c];
|
|
425
|
+
const [lx, ly] = pins.clusters[cl.key] ?? [cl.lx, cl.ly];
|
|
426
|
+
const up = cl.parent >= 0 ? at(cl.parent) : null;
|
|
427
|
+
return (out[c] = { key: cl.key, parent: cl.parent, depth: cl.depth, x: (up?.x ?? 0) + lx, y: (up?.y ?? 0) + ly, r: cl.r, open: !folded.has(cl.key) && (up?.open ?? true) });
|
|
428
|
+
};
|
|
429
|
+
L.clusters.forEach((_, c) => at(c));
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
/** Where declaration `i` (named `name`) is at rest on that board, in world coordinates. */
|
|
433
|
+
export function nodeSpot(L, pins, clusters, i, name) {
|
|
434
|
+
const [lx, ly] = pins.nodes[name] ?? [L.lx[i], L.ly[i]];
|
|
435
|
+
const c = L.cluster[i];
|
|
436
|
+
return c >= 0 ? [clusters[c].x + lx, clusters[c].y + ly] : [lx, ly];
|
|
437
|
+
}
|
|
438
|
+
/** The place of a spot in cluster `c` and every cluster around it, whatever is folded: a note an
|
|
439
|
+
* agent puts beside a declaration is in the declaration's own cluster. */
|
|
440
|
+
export function placeIn(mode, world, clusters, c) {
|
|
441
|
+
const chain = [];
|
|
442
|
+
for (let k = c; k >= 0; k = clusters[k].parent)
|
|
443
|
+
chain.push([clusters[k].key, round(world[0] - clusters[k].x), round(world[1] - clusters[k].y)]);
|
|
444
|
+
return { mode, in: chain, world: [round(world[0]), round(world[1])] };
|
|
445
|
+
}
|
|
446
|
+
/** Where a view note is on a layout clustered by `mode`: in cluster `c` at its offset (dx, dy) from
|
|
447
|
+
* the centre — the first cluster of its place the layout has, by key (`index`) — or on the canvas
|
|
448
|
+
* (`c` -1) at its world spot. Null under the other clustering than the one it was put under: it is
|
|
449
|
+
* on the ring there (shared/noteplace.ts), never matched by key, since a namespace and a folder
|
|
450
|
+
* can have the same key and be nothing alike (AttachmentSpec decision 18). */
|
|
451
|
+
export function resolvePlace(place, mode, index) {
|
|
452
|
+
if (place.mode !== mode)
|
|
453
|
+
return null;
|
|
454
|
+
for (const [key, dx, dy] of place.in) {
|
|
455
|
+
const c = index(key);
|
|
456
|
+
if (c >= 0)
|
|
457
|
+
return { c, dx, dy };
|
|
458
|
+
}
|
|
459
|
+
return { c: -1, dx: place.world[0], dy: place.world[1] };
|
|
460
|
+
}
|
|
461
|
+
/** How a note shows: expanded only when it is open and its cluster is large enough on the screen to
|
|
462
|
+
* hold it (`clusterPx`, its radius in pixels; null for a note on the canvas, which is never
|
|
463
|
+
* collapsed for the zoom). Zoomed out, every note is collapsed; zoomed in again, it is as its
|
|
464
|
+
* state says. */
|
|
465
|
+
export function noteLook(open, clusterPx) {
|
|
466
|
+
return open && (clusterPx === null || clusterPx >= AUTO_COLLAPSE_PX) ? "open" : "shut";
|
|
467
|
+
}
|
|
468
|
+
/** The folded clusters that hide notes, and how many each hides: a note in a cluster that is
|
|
469
|
+
* folded, or inside one that is, is hidden, and counted on the outermost folded cluster around it
|
|
470
|
+
* (`shownAs`: that cluster, or -1 where the note's cluster is drawn open). */
|
|
471
|
+
export function noteMarkers(clustersOf, shownAs) {
|
|
472
|
+
const out = new Map();
|
|
473
|
+
for (const c of clustersOf) {
|
|
474
|
+
if (c < 0)
|
|
475
|
+
continue;
|
|
476
|
+
const r = shownAs(c);
|
|
477
|
+
if (r >= 0)
|
|
478
|
+
out.set(r, (out.get(r) ?? 0) + 1);
|
|
479
|
+
}
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
/** A note's first words, for its collapsed look: the Markdown marks left out. */
|
|
483
|
+
export function noteExcerpt(text, most = 18) {
|
|
484
|
+
const plain = text.replace(/```[\s\S]*?```/g, " ").replace(/[`*_]/g, "").replace(/\s+/g, " ").trim();
|
|
485
|
+
return plain.length > most ? plain.slice(0, most - 1).trimEnd() + "…" : plain;
|
|
486
|
+
}
|
|
487
|
+
/** The declarations' names a note's text mentions, as the page links them: what it writes in
|
|
488
|
+
* backticks, and dotted names written bare (web/src/talk.ts, renderMarkdown). Activity and the
|
|
489
|
+
* agents' timeline say a note is about each. */
|
|
490
|
+
export function noteNames(text) {
|
|
491
|
+
const out = new Set();
|
|
492
|
+
for (const m of text.matchAll(/`([^`\n]+)`/g))
|
|
493
|
+
out.add(m[1].trim());
|
|
494
|
+
for (const m of text.replace(/`[^`\n]*`/g, " ").matchAll(/(?<![\w.'])[A-Za-z_\u00c0-\u024f][\w'!?\u00c0-\u024f]*(?:\.[\w'!?\u00c0-\u024f]+)+/g))
|
|
495
|
+
out.add(m[0]);
|
|
496
|
+
return [...out];
|
|
497
|
+
}
|
|
498
|
+
/** A new note's id. */
|
|
499
|
+
export function newNoteId() {
|
|
500
|
+
return "n" + Math.random().toString(36).slice(2, 10);
|
|
501
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The timeline (CollabSpec decision 5): what happened on a server. The server stores builds, TCB
|
|
2
|
+
// changes, re-layouts, targets taken off and prefixes trusted or no longer trusted; opinions,
|
|
3
|
+
// targets and key marks are read from the review's logs by the page itself, so the logs bring their
|
|
4
|
+
// own history. Since M6 (AgentSpec.md) it also stores the layouts agents made — each request one
|
|
5
|
+
// batch, with what it replaced, so it can be undone — and their undoing. What an agent did carries
|
|
6
|
+
// `agent`: since an agent at level full may do what a person does (AgentSpec decision 9), that is
|
|
7
|
+
// also a target it took off and a prefix it trusted or no longer trusted. Since M7 (ViewSpec.md),
|
|
8
|
+
// the views saved for everyone, renamed, deleted and laid out afresh; since 2026-10-09, the notes
|
|
9
|
+
// on a view written, edited and deleted, by people and agents alike (ViewSpec decision 23), and a
|
|
10
|
+
// view's scope changed (ExportSpec decision 15: who left a trace on a view authors its document). A note
|
|
11
|
+
// event of the kind agents left about a declaration before is not read. What someone invited by a
|
|
12
|
+
// personal link did carries `person`, their link's id (CollabSpec decision 9), so that the page
|
|
13
|
+
// shows it under the name they go by now, with its mark.
|
|
14
|
+
import { SITE_BODY, SITE_STMT } from "./graph.js";
|
|
15
|
+
import { byName } from "./review.js";
|
|
16
|
+
/** at most this many names are listed in one event; the counts stay exact */
|
|
17
|
+
export const NAMES_LISTED = 100;
|
|
18
|
+
/** The graph as the TCB needs it: names, content hashes, statement and body edges. */
|
|
19
|
+
export class TcbIndex {
|
|
20
|
+
names;
|
|
21
|
+
hashes;
|
|
22
|
+
byName = new Map();
|
|
23
|
+
start;
|
|
24
|
+
dst;
|
|
25
|
+
/** `edges`: (source, target, site) triples, as the server serves them. */
|
|
26
|
+
constructor(nodes, edges) {
|
|
27
|
+
const n = nodes.length;
|
|
28
|
+
this.names = nodes.map((v) => v.name);
|
|
29
|
+
this.hashes = nodes.map((v) => v.hash ?? "");
|
|
30
|
+
this.names.forEach((name, i) => this.byName.set(name, i));
|
|
31
|
+
const keep = SITE_STMT | SITE_BODY;
|
|
32
|
+
this.start = new Int32Array(n + 1);
|
|
33
|
+
for (let e = 0; e + 2 < edges.length; e += 3)
|
|
34
|
+
if (edges[e + 2] & keep)
|
|
35
|
+
this.start[edges[e] + 1]++;
|
|
36
|
+
for (let i = 0; i < n; i++)
|
|
37
|
+
this.start[i + 1] += this.start[i];
|
|
38
|
+
this.dst = new Int32Array(this.start[n]);
|
|
39
|
+
const at = this.start.slice(0, n);
|
|
40
|
+
for (let e = 0; e + 2 < edges.length; e += 3)
|
|
41
|
+
if (edges[e + 2] & keep)
|
|
42
|
+
this.dst[at[edges[e]]++] = edges[e + 1];
|
|
43
|
+
}
|
|
44
|
+
/** A declaration's TCB, itself included: name → content hash ("" where there is none), as the
|
|
45
|
+
* timeline compares a target's from one graph to the next (server/events.ts). */
|
|
46
|
+
tcb(root) {
|
|
47
|
+
const seen = new Uint8Array(this.names.length);
|
|
48
|
+
const stack = [root];
|
|
49
|
+
seen[root] = 1;
|
|
50
|
+
// keyed by names: no prototype, so that `toString` is a name like any (shared/review.ts)
|
|
51
|
+
const out = byName();
|
|
52
|
+
while (stack.length) {
|
|
53
|
+
const u = stack.pop();
|
|
54
|
+
out[this.names[u]] = this.hashes[u];
|
|
55
|
+
for (let e = this.start[u]; e < this.start[u + 1]; e++) {
|
|
56
|
+
const v = this.dst[e];
|
|
57
|
+
if (!seen[v]) {
|
|
58
|
+
seen[v] = 1;
|
|
59
|
+
stack.push(v);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** What changed between two TCBs (`TcbIndex.tcb`): the names added, removed, and whose content hash
|
|
67
|
+
* changed, each sorted. Own keys only: `toString` is a name like any. */
|
|
68
|
+
export function tcbDiff(then, now) {
|
|
69
|
+
const added = Object.keys(now).filter((k) => !Object.hasOwn(then, k)).sort();
|
|
70
|
+
const removed = Object.keys(then).filter((k) => !Object.hasOwn(now, k)).sort();
|
|
71
|
+
const changed = Object.keys(now).filter((k) => Object.hasOwn(then, k) && then[k] !== now[k]).sort();
|
|
72
|
+
return { added, removed, changed };
|
|
73
|
+
}
|