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.
- 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/assets/webfonts/LICENSE-Jost.txt +93 -0
- package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
- package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
- package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -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 +3270 -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 +780 -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 +1406 -0
- package/dist/server/mcptools.js +496 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +159 -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 +50 -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 +164 -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 +241 -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 +124 -0
- package/dist/shared/doc/outline.js +45 -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 +244 -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 +103 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +818 -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 +281 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +300 -0
- package/dist/shared/viewgraph.js +157 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +52671 -0
- package/dist/web/bundle/edges.js +407 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +257 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +804 -0
- package/dist/web/style.css +2548 -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 +125 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- package/test/toolchains.json +5 -0
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
// The views on a server (ViewSpec.md), kept in `<site>/views.json`: the book (shared/views.ts) of
|
|
2
|
+
// every kept view — the Project, the views people made, arranged or linked to targets —
|
|
3
|
+
// with its scope, clustering, and pins, folds and hidden levels per way of clustering; the views
|
|
4
|
+
// nobody keeps live here only while a page has them open. Each board's layout memory
|
|
5
|
+
// (shared/board.ts) is kept beside it, one file per view in `<site>/view-memory/`.
|
|
6
|
+
//
|
|
7
|
+
// Every change goes out live, per view and with who made it: `views` (the list, and what changed),
|
|
8
|
+
// `view-ops` (pins, folds, hidden levels, notes and the build shown as operations, as `places` did),
|
|
9
|
+
// `view-memory` (a board was laid out for a new set). The timeline records views saved for everyone,
|
|
10
|
+
// renamed, deleted, laid out afresh and kept at a past build (HistorySpec decision 19), and notes
|
|
11
|
+
// written, edited and deleted (shared/sticky.ts), each under who did it as the note records its
|
|
12
|
+
// author: the attribution pins have.
|
|
13
|
+
//
|
|
14
|
+
// A target's link is kept in the record of the view it is linked to (ViewSpec decision 29). A view
|
|
15
|
+
// nobody keeps is not written down, so a link a click made to it goes with it, at a start too; and
|
|
16
|
+
// since marking a target makes no view (the owner's decision of 2026-10-12), nothing is kept of the
|
|
17
|
+
// targets that have none. A `viewless` list an earlier version wrote is not read.
|
|
18
|
+
//
|
|
19
|
+
// The project's notes (AttachmentSpec decisions 13–19, shared/projectnotes.ts) are kept in the same
|
|
20
|
+
// file, beside the views: they go out as `note-ops`, as a view's notes go out in `view-ops`, and the
|
|
21
|
+
// timeline records them written, edited and deleted (their `view` ""), and carried by a build to
|
|
22
|
+
// the cluster their declarations moved to, as an edit by VisualProof; a note attached to a
|
|
23
|
+
// declaration follows it to another name (moveDeclaration). What a note of either kind
|
|
24
|
+
// carries (AttachmentSpec decision 12) is checked against the library as it is written: a file or a
|
|
25
|
+
// clip a note is to carry and did not before must be a file the library has, not archived.
|
|
26
|
+
//
|
|
27
|
+
// A view's items (the owner's decision of 2026-10-11, shared/items.ts) are kept in its record: the
|
|
28
|
+
// notes of the project added to it and taken out of it, changed by the `item` operation, which goes
|
|
29
|
+
// out in `view-ops` as pins do and is an event of the timeline as a scope change is (`items`). A
|
|
30
|
+
// note written in a view is added to it (`arrangeNotes`, `written`), with no event of its own beside
|
|
31
|
+
// the note's; a note deleted, or made a view's own, leaves every list.
|
|
32
|
+
//
|
|
33
|
+
// Who dropped a pin is kept per view beside its pins (shared/views.ts, PinAuthor) and never sent
|
|
34
|
+
// to the pages. What a private reader arranges is recorded without a name (CollabSpec decision 8):
|
|
35
|
+
// `handle` takes its author from identity.ts (`layoutAuthor`), and so does whatever arranges a board
|
|
36
|
+
// through it.
|
|
37
|
+
import { createHash } from "node:crypto";
|
|
38
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
import { CLUSTER_MODES, emptyLayouts } from "../shared/places.js";
|
|
41
|
+
import { cleanQuery, invalidQuery, printQuery, QueryError, parseQuery } from "../shared/query.js";
|
|
42
|
+
import { validPersonId } from "../shared/review.js";
|
|
43
|
+
import { cleanItems, itemIn, itemShown } from "../shared/items.js";
|
|
44
|
+
import { addedNames, filesOf, formerNames, validSha } from "../shared/library.js";
|
|
45
|
+
import { carryProblem, cleanNotes, invalidPlace, MAX_NOTE_TEXT, newCarries, newNoteId, sameCarries } from "../shared/sticky.js";
|
|
46
|
+
import { renameInNote, renameInQuery } from "../shared/moves.js";
|
|
47
|
+
import { carriedNotes } from "../shared/noteplace.js";
|
|
48
|
+
import { applyProjectNoteOp, cleanProjectNotes, invalidClusterPlace, invalidProjectNoteOps, MAX_PROJECT_NOTES } from "../shared/projectnotes.js";
|
|
49
|
+
import { cleanAuthors, invalidViewOps, PROJECT, summary, validVersion, ViewBook, viewTitle } from "../shared/views.js";
|
|
50
|
+
import { refusal } from "./agent.js";
|
|
51
|
+
import { layoutAuthor } from "./identity.js";
|
|
52
|
+
/** Whether an edit changed note `n` as it was before: its text, or what it carries (an attachment
|
|
53
|
+
* added or taken away is an edit of the note, as its text is). */
|
|
54
|
+
const edited = (n, o) => !!n && (n.text !== o.text || (o.carries !== undefined && !sameCarries(n.carries, o.carries)));
|
|
55
|
+
/** how long a view nobody keeps outlives the last page that had it open: a reload comes back */
|
|
56
|
+
const GRACE_MS = 8000;
|
|
57
|
+
/** how long a private page's holds outlive its last event stream (a reload is a new page) */
|
|
58
|
+
const HOLD_MS = 60_000;
|
|
59
|
+
/** the most views one private page holds */
|
|
60
|
+
const MAX_HOLDS = 100;
|
|
61
|
+
/** The most a board's layout memory may be when a page hands it over: room for a board of a large
|
|
62
|
+
* project's declarations many times over (Sphere-Packing-Lean's whole board, 7,851 declarations, is
|
|
63
|
+
* 1.8 MB; 50 MB is the owner's decision of 2026-10-14). */
|
|
64
|
+
export const MAX_MEMORY_BYTES = 50 << 20;
|
|
65
|
+
const isPins = (p) => {
|
|
66
|
+
const o = p;
|
|
67
|
+
const places = (x) => !!x && typeof x === "object" && !Array.isArray(x) && Object.values(x).every((a) => Array.isArray(a) && a.length === 2 && a.every((v) => typeof v === "number" && Number.isFinite(v)));
|
|
68
|
+
return !!o && typeof o === "object" && places(o.nodes) && places(o.clusters);
|
|
69
|
+
};
|
|
70
|
+
/** A kept view as views.json holds it, checked: the view, or why it cannot be used. A field a file
|
|
71
|
+
* written by an older version lacks takes its default. */
|
|
72
|
+
export function readView(raw) {
|
|
73
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
74
|
+
return "it is not a view's record";
|
|
75
|
+
const r = raw;
|
|
76
|
+
if (typeof r.id !== "string" || !r.id)
|
|
77
|
+
return "it has no id";
|
|
78
|
+
const bad = invalidQuery(r.scope);
|
|
79
|
+
if (bad)
|
|
80
|
+
return `its scope cannot be read: ${bad}`;
|
|
81
|
+
const clustering = r.clustering ?? "namespace";
|
|
82
|
+
if (!CLUSTER_MODES.includes(clustering))
|
|
83
|
+
return "its clustering is neither namespace nor module";
|
|
84
|
+
const pins = (r.pins ?? emptyLayouts());
|
|
85
|
+
if (!CLUSTER_MODES.every((m) => isPins(pins[m])))
|
|
86
|
+
return "its pins are not pins";
|
|
87
|
+
const folds = (r.folds ?? { namespace: [], module: [] });
|
|
88
|
+
if (!CLUSTER_MODES.every((m) => Array.isArray(folds[m]) && folds[m].every((k) => typeof k === "string")))
|
|
89
|
+
return "its folds are not lists of clusters";
|
|
90
|
+
const hidden = (r.hidden ?? { namespace: [], module: [] });
|
|
91
|
+
if (!CLUSTER_MODES.every((m) => Array.isArray(hidden[m]) && hidden[m].every((k) => typeof k === "string")))
|
|
92
|
+
return "its hidden levels are not lists of clusters";
|
|
93
|
+
if (r.name !== null && r.name !== undefined && typeof r.name !== "string")
|
|
94
|
+
return "its name is not text";
|
|
95
|
+
// a note that cannot be read is left out; the others are the view's
|
|
96
|
+
const notes = cleanNotes(r.notes);
|
|
97
|
+
// the notes of the project added to it and taken out of it; the Project keeps none, holding all
|
|
98
|
+
const items = r.id === PROJECT ? {} : cleanItems(r.items);
|
|
99
|
+
return {
|
|
100
|
+
id: r.id,
|
|
101
|
+
name: r.name ?? null,
|
|
102
|
+
scope: cleanQuery(r.scope),
|
|
103
|
+
clustering: clustering,
|
|
104
|
+
pins: pins,
|
|
105
|
+
folds: folds,
|
|
106
|
+
...(notes.length ? { notes } : {}),
|
|
107
|
+
...items,
|
|
108
|
+
hidden: hidden,
|
|
109
|
+
by: typeof r.by === "string" ? r.by : null,
|
|
110
|
+
at: typeof r.at === "string" ? r.at : new Date(0).toISOString(),
|
|
111
|
+
// who made it: an agent, or someone invited by a personal link (shared/views.ts, marksOf)
|
|
112
|
+
...(r.agent === true ? { agent: true } : typeof r.by === "string" && validPersonId(r.person) ? { person: r.person } : {}),
|
|
113
|
+
kept: true,
|
|
114
|
+
// the targets linked to it, in the order they were linked; a record written before a view could
|
|
115
|
+
// be several targets' (the owner's decision of 2026-10-11) names its one as `target`, and is read
|
|
116
|
+
// so, that the links people set are not lost
|
|
117
|
+
...linkedOf(r),
|
|
118
|
+
// the view's focus as set (ViewSpec decision 28): a name, or null for none
|
|
119
|
+
...(r.focus === null || (typeof r.focus === "string" && r.focus.length > 0 && r.focus.length <= 2000) ? { focus: r.focus } : {}),
|
|
120
|
+
// the build it is kept at (HistorySpec decision 19); a value that is no snapshot's id is dropped
|
|
121
|
+
...(validVersion(r.version) ? { version: r.version } : {}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** The targets linked to a view as its record on disk says them (ViewData.targets): names, each once. */
|
|
125
|
+
function linkedOf(r) {
|
|
126
|
+
const raw = Array.isArray(r.targets) ? r.targets : typeof r.target === "string" ? [r.target] : [];
|
|
127
|
+
const targets = [...new Set(raw.filter((t) => typeof t === "string" && t.length > 0 && t.length <= 2000))];
|
|
128
|
+
return targets.length ? { targets } : {};
|
|
129
|
+
}
|
|
130
|
+
/** An answer as the page's endpoints give one: `{error}`, and `who` when a name is needed. */
|
|
131
|
+
const no = (status, error, more = {}) => ({ status, body: { error, ...more } });
|
|
132
|
+
const ok = (body) => ({ status: 200, body });
|
|
133
|
+
const NEED_NAME = no(401, "Say who you are first.", { who: true });
|
|
134
|
+
/** A query from a request: its JSON form or its text; why not, when it is neither. */
|
|
135
|
+
export function readQuery(x) {
|
|
136
|
+
if (typeof x === "string") {
|
|
137
|
+
try {
|
|
138
|
+
return parseQuery(x);
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
return e instanceof QueryError ? `The scope cannot be read at ${e.at}: ${e.message}.` : String(e);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return cleanQuery(x) ?? `The scope cannot be used: ${invalidQuery(x)}. Send its text (e.g. tcb(A.b) except trusted) or its JSON form.`;
|
|
145
|
+
}
|
|
146
|
+
/** A layout's memory as a page sent it, checked; null when it is not one. */
|
|
147
|
+
function cleanMemory(x) {
|
|
148
|
+
const m = x;
|
|
149
|
+
if (!m || typeof m.set !== "string" || m.set.length > 200 || !m.mem || typeof m.mem !== "object")
|
|
150
|
+
return null;
|
|
151
|
+
const nodes = m.mem.nodes, clusters = m.mem.clusters;
|
|
152
|
+
if (!nodes || !clusters || typeof nodes !== "object" || typeof clusters !== "object")
|
|
153
|
+
return null;
|
|
154
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) < 1e8;
|
|
155
|
+
let n = 0;
|
|
156
|
+
for (const v of Object.values(nodes))
|
|
157
|
+
if (++n > 200000 || !Array.isArray(v) || v.length !== 3 || !num(v[0]) || !num(v[1]) || typeof v[2] !== "string")
|
|
158
|
+
return null;
|
|
159
|
+
for (const v of Object.values(clusters))
|
|
160
|
+
if (++n > 200000 || !Array.isArray(v) || v.length !== 3 || !v.every(num))
|
|
161
|
+
return null;
|
|
162
|
+
return { set: m.set, mem: { nodes, clusters } };
|
|
163
|
+
}
|
|
164
|
+
export class Views {
|
|
165
|
+
book;
|
|
166
|
+
file;
|
|
167
|
+
memDir;
|
|
168
|
+
layoutVersion;
|
|
169
|
+
send;
|
|
170
|
+
/** what records views made, renamed, deleted and laid out afresh; none for an export */
|
|
171
|
+
timeline;
|
|
172
|
+
/** the Project view laid out afresh: the server lays the graph out anew (server/live.ts) */
|
|
173
|
+
onProjectRelayout = () => { };
|
|
174
|
+
/** who is here now (server/presence.ts): a view nobody keeps lives while one of them has it open */
|
|
175
|
+
peers = () => [];
|
|
176
|
+
/** which page a page's secret stands for (server/live.ts): the `from` of the operations it sends,
|
|
177
|
+
* so that it skips its own when they come back; null when it is no open page's */
|
|
178
|
+
pageOf = () => null;
|
|
179
|
+
/** why a layout's memory does not fit the board it is handed over for, with the status to answer
|
|
180
|
+
* (server/agentviews.ts, `memoryProblem`); the live server reads the board on its graph */
|
|
181
|
+
checkMemory = () => null;
|
|
182
|
+
/** the kept views views.json holds that cannot be read now (UnreadableView) */
|
|
183
|
+
unreadable = [];
|
|
184
|
+
unreadableAuthors = {};
|
|
185
|
+
/** The views private pages hold (CollabSpec decision 8), by the session each page calls itself:
|
|
186
|
+
* such a page says nothing of what it has open, so a view nobody keeps lives while a page that
|
|
187
|
+
* read or opened it while private is open, as it does while it is in the others' tabs. Nobody is
|
|
188
|
+
* told of them. */
|
|
189
|
+
holds = new Map();
|
|
190
|
+
/** whether a page of this session has its event stream open (server/live.ts) */
|
|
191
|
+
sessionOpen = () => false;
|
|
192
|
+
/** when snapshot `id` was built (server/history.ts), or null where there is no such snapshot: a
|
|
193
|
+
* view is kept only at a build the history has (HistorySpec decision 19); none without a history */
|
|
194
|
+
snapshotAt = () => null;
|
|
195
|
+
/** what a view's title is told with (shared/views.ts, viewTitle): the graph on show's names, the
|
|
196
|
+
* history's builds (server/live.ts) */
|
|
197
|
+
titles = () => ({});
|
|
198
|
+
/** the latest build's snapshot, the graph on show's (server/history.ts); null without a history */
|
|
199
|
+
latest = () => null;
|
|
200
|
+
/** whether a name is a target of the review now (server/live.ts): what a view is focused on */
|
|
201
|
+
isTarget = () => true;
|
|
202
|
+
/** the library's record of a file (server/live.ts reads it from the review), against which what a
|
|
203
|
+
* note is to carry is checked; null where nothing checks it (a book read for an export) */
|
|
204
|
+
carriedFile = null;
|
|
205
|
+
/** The files of the library a project note was written for as they came, or that a note carried
|
|
206
|
+
* already then (AttachmentSpec decision 12, the owner's of 2026-10-11): each is considered once,
|
|
207
|
+
* so that a restart writes nothing again and a note deleted stays deleted. */
|
|
208
|
+
filesNoted = new Set();
|
|
209
|
+
/** Whether any note, the project's or a view's, carries file `sha`. */
|
|
210
|
+
carried(sha) {
|
|
211
|
+
const has = (n) => !!n.carries?.some((c) => c.file === sha);
|
|
212
|
+
return this.book.notes.some(has) || this.book.list().some((v) => (v.notes ?? []).some(has));
|
|
213
|
+
}
|
|
214
|
+
/** A file came into the library (its first record, by upload or by URL, from a person or an agent):
|
|
215
|
+
* every item shows on the Project (the owner's decision of 2026-10-11), so a note of the project
|
|
216
|
+
* carries it — collapsed, its text the file's title, written by whoever added the file, at the
|
|
217
|
+
* top level under both clusterings, with no place: on the Project's ring, in the order notes were
|
|
218
|
+
* written, and on another view's only where a link or an add brings it (decisions 16–17). `note` false where the page writes the note itself, at the spot
|
|
219
|
+
* a file was dropped or pasted on the canvas. Nothing where a note carries the file already, or
|
|
220
|
+
* it was considered before. Whether a note was written. */
|
|
221
|
+
fileAdded(f, who, note = true) {
|
|
222
|
+
// its title as notes follow it (followTitles), written down with what follows
|
|
223
|
+
if (this.fileTitles && !this.fileTitles.has(f.sha))
|
|
224
|
+
this.fileTitles.set(f.sha, f.title);
|
|
225
|
+
if (this.filesNoted.has(f.sha))
|
|
226
|
+
return false;
|
|
227
|
+
this.filesNoted.add(f.sha);
|
|
228
|
+
const write = note && !this.carried(f.sha) && this.book.notes.length < MAX_PROJECT_NOTES;
|
|
229
|
+
const n = { id: newNoteId(), text: noteTitle(f.title), places: {}, open: false, by: who.by, at: new Date().toISOString(), carries: [{ file: f.sha }] };
|
|
230
|
+
// a note refused (a file archived meanwhile) leaves the file considered all the same
|
|
231
|
+
if (!write || this.arrangeNotes([{ op: "pnote", note: n }], null, who.by, who.agent, who.person) !== null || !this.book.notes.some((x) => x.id === n.id)) {
|
|
232
|
+
this.save();
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
/** Once, at the start: every file the library has a record of, not archived, that was never
|
|
238
|
+
* considered — the files added before the notes carried them (the owner's decision of
|
|
239
|
+
* 2026-10-11), and those whose records came through the review's logs since the last start —
|
|
240
|
+
* gets its note as one just added would, written by `by` (VisualProof), unless a note carries it
|
|
241
|
+
* already. How many notes were written. */
|
|
242
|
+
noteLibraryFiles(files, by) {
|
|
243
|
+
let made = 0;
|
|
244
|
+
for (const f of files)
|
|
245
|
+
if (!f.archived && this.fileAdded(f, { by, agent: false, person: null }))
|
|
246
|
+
made++;
|
|
247
|
+
return made;
|
|
248
|
+
}
|
|
249
|
+
/** Each file's title as the notes last followed it, by hash (views.json `fileTitles`); null until
|
|
250
|
+
* the notes were first set to their files' titles, once (followTitles). */
|
|
251
|
+
fileTitles = null;
|
|
252
|
+
/** The library's records as the review has them now (server/live.ts calls it at the start and at
|
|
253
|
+
* each change of the review, wherever the change came from): a note's text follows its file's title
|
|
254
|
+
* (the owner's decision of 2026-10-11). Where a file's title is not the one the notes last
|
|
255
|
+
* followed — renamed in the library since, by anyone, on this server or another — every note that
|
|
256
|
+
* carries that file alone and whose text is the title it had, or the name the file was added under,
|
|
257
|
+
* takes the new title, as an edit by `by` (VisualProof); a text someone wrote stays. Once, the first
|
|
258
|
+
* time (no `fileTitles` in views.json): every note that carries one file alone and whose text is any
|
|
259
|
+
* name that file went by before — the name it was added under, the title it was added with, a title
|
|
260
|
+
* an edit took from it (formerNames) — takes its title now, so that the notes made of files renamed
|
|
261
|
+
* before notes followed their titles say them. How many notes followed. */
|
|
262
|
+
followTitles(d, by) {
|
|
263
|
+
const files = filesOf(d);
|
|
264
|
+
let made = 0;
|
|
265
|
+
let first = false;
|
|
266
|
+
if (!this.fileTitles) {
|
|
267
|
+
made += this.retitle(files.map((f) => ({ sha: f.sha, from: formerNames(d, f.sha), to: f.title })), by);
|
|
268
|
+
this.fileTitles = new Map();
|
|
269
|
+
first = true;
|
|
270
|
+
}
|
|
271
|
+
const renamed = [];
|
|
272
|
+
for (const f of files) {
|
|
273
|
+
const was = this.fileTitles.get(f.sha);
|
|
274
|
+
if (was === f.title)
|
|
275
|
+
continue;
|
|
276
|
+
if (was !== undefined)
|
|
277
|
+
renamed.push({ sha: f.sha, from: [was, ...addedNames(f)], to: f.title });
|
|
278
|
+
this.fileTitles.set(f.sha, f.title);
|
|
279
|
+
}
|
|
280
|
+
made += this.retitle(renamed, by);
|
|
281
|
+
// written down where it was done for the first time or a file was renamed, so that the same old
|
|
282
|
+
// title is never followed again; a file seen for the first time is written with whatever is
|
|
283
|
+
// written next (one added here, with the note that carries it: fileAdded)
|
|
284
|
+
if (first || renamed.length)
|
|
285
|
+
this.save();
|
|
286
|
+
return made;
|
|
287
|
+
}
|
|
288
|
+
/** The notes, the project's and the views' own, that carry one of the files `changes` names alone
|
|
289
|
+
* — one file, whole or a clip of it, as their icons show no count — and whose text is one of the
|
|
290
|
+
* names it went by (`from`), each given its title now (`to`), as an edit by `by`. How many. */
|
|
291
|
+
retitle(changes, by) {
|
|
292
|
+
if (!changes.length)
|
|
293
|
+
return 0;
|
|
294
|
+
const of = new Map(changes.map((c) => [c.sha, c]));
|
|
295
|
+
const follows = (n) => {
|
|
296
|
+
const c = n.carries?.length === 1 ? of.get(n.carries[0].file) : undefined;
|
|
297
|
+
if (!c)
|
|
298
|
+
return null;
|
|
299
|
+
const to = noteTitle(c.to);
|
|
300
|
+
return n.text !== to && c.from.some((t) => t === n.text || noteTitle(t) === n.text) ? to : null;
|
|
301
|
+
};
|
|
302
|
+
let made = 0;
|
|
303
|
+
const edits = [];
|
|
304
|
+
for (const n of this.book.notes) {
|
|
305
|
+
const text = follows(n);
|
|
306
|
+
if (text !== null)
|
|
307
|
+
edits.push({ op: "pnote-edit", id: n.id, text });
|
|
308
|
+
}
|
|
309
|
+
if (edits.length && this.arrangeNotes(edits, null, by) === null)
|
|
310
|
+
made += edits.length;
|
|
311
|
+
for (const v of this.book.list()) {
|
|
312
|
+
const ops = [];
|
|
313
|
+
for (const n of v.notes ?? []) {
|
|
314
|
+
const text = follows(n);
|
|
315
|
+
if (text !== null)
|
|
316
|
+
ops.push({ op: "note-edit", id: n.id, text });
|
|
317
|
+
}
|
|
318
|
+
if (ops.length && this.arrange(v.id, ops, null, by) === null)
|
|
319
|
+
made += ops.length;
|
|
320
|
+
}
|
|
321
|
+
return made;
|
|
322
|
+
}
|
|
323
|
+
/** Why operations on notes cannot be taken for what they would make a note carry, or null: each
|
|
324
|
+
* file or clip a note is to carry and did not carry before (`had`, by the note's id) is checked
|
|
325
|
+
* against the library — a carry it has already stays, whatever became of its file since. */
|
|
326
|
+
carriesRefused(ops, had) {
|
|
327
|
+
const file = this.carriedFile;
|
|
328
|
+
if (!file)
|
|
329
|
+
return null;
|
|
330
|
+
for (const o of ops) {
|
|
331
|
+
const fresh = o.op === "pnote" || o.op === "note" ? newCarries(o.note.carries, undefined) : o.op === "pnote-edit" || o.op === "note-edit" ? newCarries(o.carries, had(o.id)) : [];
|
|
332
|
+
for (const c of fresh) {
|
|
333
|
+
const why = carryProblem(c, file(c.file));
|
|
334
|
+
if (why)
|
|
335
|
+
return why;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
/** The past build view `v` shows: the snapshot it is kept at, unless that is the latest build's;
|
|
341
|
+
* null for the graph on show. Its board is that build's graph (HistorySpec decision 19). */
|
|
342
|
+
pastOf(v) {
|
|
343
|
+
return v.version === undefined || v.version === this.latest() ? null : v.version;
|
|
344
|
+
}
|
|
345
|
+
constructor(siteDir, layoutVersion, send, timeline = null) {
|
|
346
|
+
this.file = join(siteDir, "views.json");
|
|
347
|
+
this.memDir = join(siteDir, "view-memory");
|
|
348
|
+
this.layoutVersion = layoutVersion;
|
|
349
|
+
this.send = send;
|
|
350
|
+
this.timeline = timeline;
|
|
351
|
+
let views = [];
|
|
352
|
+
let authors = {};
|
|
353
|
+
let notes = [];
|
|
354
|
+
if (existsSync(this.file)) {
|
|
355
|
+
try {
|
|
356
|
+
const raw = JSON.parse(readFileSync(this.file, "utf8"));
|
|
357
|
+
// a view that cannot be read is kept aside, not dropped: the next save writes it back
|
|
358
|
+
for (const r of Array.isArray(raw.views) ? raw.views : []) {
|
|
359
|
+
const v = readView(r);
|
|
360
|
+
if (typeof v !== "string")
|
|
361
|
+
views.push(v);
|
|
362
|
+
else {
|
|
363
|
+
const o = (r && typeof r === "object" ? r : { record: r });
|
|
364
|
+
this.unreadable.push({ id: typeof o.id === "string" ? o.id : null, name: typeof o.name === "string" ? o.name : null, why: v, raw: o });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// pins dropped under another way of laying out mean nothing in this one (CanvasSpec)
|
|
368
|
+
if (raw.layoutVersion !== layoutVersion) {
|
|
369
|
+
for (const v of views)
|
|
370
|
+
v.pins = emptyLayouts();
|
|
371
|
+
for (const u of this.unreadable)
|
|
372
|
+
if (u.raw.pins !== undefined)
|
|
373
|
+
u.raw.pins = emptyLayouts();
|
|
374
|
+
}
|
|
375
|
+
authors = raw.authors && typeof raw.authors === "object" ? raw.authors : {};
|
|
376
|
+
notes = cleanProjectNotes(raw.notes);
|
|
377
|
+
if (Array.isArray(raw.filesNoted))
|
|
378
|
+
for (const sha of raw.filesNoted)
|
|
379
|
+
if (typeof sha === "string" && /^[0-9a-f]{64}$/.test(sha))
|
|
380
|
+
this.filesNoted.add(sha);
|
|
381
|
+
if (raw.fileTitles && typeof raw.fileTitles === "object" && !Array.isArray(raw.fileTitles))
|
|
382
|
+
this.fileTitles = new Map(Object.entries(raw.fileTitles).filter((e) => validSha(e[0]) && typeof e[1] === "string"));
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
// unreadable: the views start afresh, the file is kept aside to look at
|
|
386
|
+
renameSync(this.file, join(siteDir, `views.broken-${Date.now()}.json`));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
for (const u of this.unreadable) {
|
|
390
|
+
if (u.id && authors[u.id] !== undefined)
|
|
391
|
+
this.unreadableAuthors[u.id] = authors[u.id];
|
|
392
|
+
console.error(`views: ${u.name ?? u.id ?? "a view"} in ${this.file} cannot be read (${u.why}); it is kept there as it is`);
|
|
393
|
+
}
|
|
394
|
+
this.book = new ViewBook(views, notes);
|
|
395
|
+
for (const v of this.book.kept())
|
|
396
|
+
if (authors[v.id])
|
|
397
|
+
this.book.authors.set(v.id, cleanAuthors(authors[v.id], v.pins));
|
|
398
|
+
for (const v of this.book.kept()) {
|
|
399
|
+
try {
|
|
400
|
+
this.book.memory.set(v.id, JSON.parse(readFileSync(this.memFile(v.id), "utf8")));
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
// no memory yet: the board is laid out afresh the first time it is shown
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
setInterval(() => this.sweep(), GRACE_MS / 2).unref();
|
|
407
|
+
}
|
|
408
|
+
/** The views nobody keeps and no page has open go: what is open now, not only what changed last
|
|
409
|
+
* (a page that sits on a view says nothing new), and what private pages hold. */
|
|
410
|
+
sweep(now = Date.now()) {
|
|
411
|
+
this.seenPeers(this.peers(), now);
|
|
412
|
+
this.book.seen(this.held(now), now);
|
|
413
|
+
this.changed(this.book.sweep(now, GRACE_MS));
|
|
414
|
+
}
|
|
415
|
+
/** A private page (by its session) has these views open: they live while it does. */
|
|
416
|
+
hold(session, ids) {
|
|
417
|
+
const h = this.holds.get(session) ?? { ids: new Set(), at: 0 };
|
|
418
|
+
for (const id of ids)
|
|
419
|
+
if (h.ids.size < MAX_HOLDS && this.book.get(id)?.kept === false)
|
|
420
|
+
h.ids.add(id);
|
|
421
|
+
h.at = Date.now();
|
|
422
|
+
this.holds.set(session, h);
|
|
423
|
+
this.book.seen(h.ids);
|
|
424
|
+
}
|
|
425
|
+
/** A page showed itself again: what it has open, its heartbeats say. */
|
|
426
|
+
release(session) {
|
|
427
|
+
this.holds.delete(session);
|
|
428
|
+
}
|
|
429
|
+
/** The views held by private pages that are open, or were a moment ago. */
|
|
430
|
+
held(now) {
|
|
431
|
+
const out = [];
|
|
432
|
+
for (const [session, h] of this.holds) {
|
|
433
|
+
if (!this.sessionOpen(session) && now - h.at > HOLD_MS) {
|
|
434
|
+
this.holds.delete(session);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
out.push(...h.ids);
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
/** The Project view's pins (ViewPlaces). */
|
|
442
|
+
get places() {
|
|
443
|
+
return this.placesOf(PROJECT);
|
|
444
|
+
}
|
|
445
|
+
/** A view's pins (the Project's, by default), read and changed with who dropped each. The
|
|
446
|
+
* pages are told (`view-ops`). */
|
|
447
|
+
placesOf(id = PROJECT) {
|
|
448
|
+
const book = this.book;
|
|
449
|
+
const version = this.layoutVersion;
|
|
450
|
+
return {
|
|
451
|
+
get data() {
|
|
452
|
+
return { layoutVersion: version, layouts: book.get(id)?.pins ?? emptyLayouts() };
|
|
453
|
+
},
|
|
454
|
+
apply: (ops, who) => {
|
|
455
|
+
const r = book.arrange(id, ops, who?.by ?? null, who);
|
|
456
|
+
if (r === null || typeof r === "string")
|
|
457
|
+
return false;
|
|
458
|
+
// an agent's pins keep an open view too
|
|
459
|
+
if (r.length)
|
|
460
|
+
this.changed(r, !!who?.agent);
|
|
461
|
+
else
|
|
462
|
+
this.save();
|
|
463
|
+
return true;
|
|
464
|
+
},
|
|
465
|
+
authorOf: (mode, kind, key) => book.authorOf(id, mode, kind, key),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
list() {
|
|
469
|
+
return this.book.list().map(summary);
|
|
470
|
+
}
|
|
471
|
+
/** The kept views that cannot be read now, as the lists say them: which, and why. */
|
|
472
|
+
unreadableList() {
|
|
473
|
+
return this.unreadable.filter((u) => !u.id || !this.book.get(u.id)).map(({ id, name, why }) => ({ id, name, why }));
|
|
474
|
+
}
|
|
475
|
+
/** Pages have these views open (presence: each page's view and tabs). */
|
|
476
|
+
seenPeers(peers, now = Date.now()) {
|
|
477
|
+
const ids = [];
|
|
478
|
+
for (const p of peers) {
|
|
479
|
+
if (!p.view)
|
|
480
|
+
continue;
|
|
481
|
+
if (p.view.view)
|
|
482
|
+
ids.push(p.view.view);
|
|
483
|
+
ids.push(...p.view.tabs);
|
|
484
|
+
}
|
|
485
|
+
this.book.seen(ids, now);
|
|
486
|
+
}
|
|
487
|
+
/** The review's targets changed: a view whose target was unmarked stays, unlinked, and one a move
|
|
488
|
+
* brought from another name (`formerly`) takes that name's view over (ViewSpec decision 29); a
|
|
489
|
+
* target marked makes no view (the owner's decision of 2026-10-12). The timeline has the targets
|
|
490
|
+
* already. */
|
|
491
|
+
syncTargets(targets, formerly) {
|
|
492
|
+
this.changed(this.book.syncTargets(targets, formerly), false, null, false);
|
|
493
|
+
}
|
|
494
|
+
/** The view a click on target `target` opens, by `by` (ViewSpec decision 29, the owner's decision
|
|
495
|
+
* of 2026-10-11; shared/views.ts, `openTarget`): the one it is linked to, else a view nobody keeps
|
|
496
|
+
* of its TCB, linked to it while it lasts. The pages are told; the timeline is not, as it is not
|
|
497
|
+
* of a view opened. Whether `target` is a target is the caller's to say. */
|
|
498
|
+
openTarget(target, by, agent = false, person = null) {
|
|
499
|
+
const r = this.book.openTarget(target, by, Date.now(), { agent, person });
|
|
500
|
+
this.changed(r.changes, agent, person);
|
|
501
|
+
return r.view;
|
|
502
|
+
}
|
|
503
|
+
/** Declaration `from` is `to` now (a move, HistorySpec.md): on every view, its pins are moved to
|
|
504
|
+
* `to` where `to` has none, its full name in backticks in a note's text is written as `to`, a note
|
|
505
|
+
* attached to it is attached to `to` (the owner's decision of 2026-10-12: the attachment follows
|
|
506
|
+
* renames, at the same offset), and a scope that names it names `to` — through the views' own
|
|
507
|
+
* operations, recorded under `by` (VisualProof for a rename it found), as anyone's changes are.
|
|
508
|
+
* What each view changed: its operations, and its scope before and after (`unmoveDeclaration`
|
|
509
|
+
* undoes them). */
|
|
510
|
+
moveDeclaration(from, to, by, agent = false) {
|
|
511
|
+
const out = [];
|
|
512
|
+
for (const v of this.book.list()) {
|
|
513
|
+
const ops = [];
|
|
514
|
+
for (const mode of CLUSTER_MODES) {
|
|
515
|
+
const nodes = v.pins[mode].nodes;
|
|
516
|
+
if (!Object.hasOwn(nodes, from) || Object.hasOwn(nodes, to))
|
|
517
|
+
continue;
|
|
518
|
+
ops.push({ op: "pin", mode, kind: "node", key: to, at: nodes[from] }, { op: "unpin", mode, kind: "node", key: from });
|
|
519
|
+
}
|
|
520
|
+
for (const n of v.notes ?? []) {
|
|
521
|
+
const text = renameInNote(n.text, from, to);
|
|
522
|
+
if (text !== n.text)
|
|
523
|
+
ops.push({ op: "note-edit", id: n.id, text });
|
|
524
|
+
if (n.decl?.name === from)
|
|
525
|
+
ops.push({ op: "note-decl", id: n.id, decl: { name: to, at: n.decl.at } });
|
|
526
|
+
}
|
|
527
|
+
if (ops.length)
|
|
528
|
+
this.arrange(v.id, ops, null, by, agent);
|
|
529
|
+
// a target's view too: its scope can be changed as any view's (ViewSpec decision 28), and the
|
|
530
|
+
// view goes with its target (ViewBook.retarget)
|
|
531
|
+
let scope;
|
|
532
|
+
const q = renameInQuery(v.scope, from, to);
|
|
533
|
+
if (q !== v.scope) {
|
|
534
|
+
scope = [printQuery(v.scope), printQuery(q)];
|
|
535
|
+
const c = this.book.setScope(v.id, q, by, false);
|
|
536
|
+
if (c && typeof c !== "string")
|
|
537
|
+
this.changed(c, agent);
|
|
538
|
+
}
|
|
539
|
+
// a view focused on it is focused on it under its new name
|
|
540
|
+
const focus = v.focus === from;
|
|
541
|
+
if (focus) {
|
|
542
|
+
const c = this.book.setFocus(v.id, to, by, false);
|
|
543
|
+
if (c && typeof c !== "string")
|
|
544
|
+
this.changed(c, agent);
|
|
545
|
+
}
|
|
546
|
+
if (ops.length || scope || focus)
|
|
547
|
+
out.push({ view: v.id, ops, ...(scope ? { scope } : {}), ...(focus ? { focus: true } : {}) });
|
|
548
|
+
}
|
|
549
|
+
// the project's notes name it under its new name too, and those attached to it are attached to
|
|
550
|
+
// it under its new name
|
|
551
|
+
const edits = [];
|
|
552
|
+
for (const n of this.book.notes) {
|
|
553
|
+
const text = renameInNote(n.text, from, to);
|
|
554
|
+
if (text !== n.text)
|
|
555
|
+
edits.push({ op: "pnote-edit", id: n.id, text });
|
|
556
|
+
if (n.decl?.name === from)
|
|
557
|
+
edits.push({ op: "pnote-decl", id: n.id, decl: { name: to, at: n.decl.at } });
|
|
558
|
+
}
|
|
559
|
+
if (edits.length) {
|
|
560
|
+
this.arrangeNotes(structuredClone(edits), null, by, agent);
|
|
561
|
+
out.push({ view: "", ops: edits });
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
/** A move of a declaration on the views undone, as a move back, on what it changed alone: on each
|
|
566
|
+
* view it changed, a pin it moved to `to` goes back to `from` (where `from` has none since), a
|
|
567
|
+
* note it edited names `from` again where it names `to` — what else was edited since stays —, a
|
|
568
|
+
* note it attached to `to` is attached to `from` again where it is still attached to `to`, and a
|
|
569
|
+
* scope it changed names `from` again, and a focus `from` again; the view of a target that went
|
|
570
|
+
* with it comes back with it (`target`). */
|
|
571
|
+
unmoveDeclaration(from, to, views, by, agent = false, target = false) {
|
|
572
|
+
if (target)
|
|
573
|
+
this.changed(this.book.retarget(to, from, by), agent);
|
|
574
|
+
for (const e of views) {
|
|
575
|
+
if (e.view === "") {
|
|
576
|
+
// the project's notes it edited name `from` again, where they name `to`
|
|
577
|
+
const back = [];
|
|
578
|
+
for (const o of e.ops) {
|
|
579
|
+
if (o.op === "pnote-decl") {
|
|
580
|
+
const n = this.book.notes.find((x) => x.id === o.id);
|
|
581
|
+
if (n?.decl?.name === to)
|
|
582
|
+
back.push({ op: "pnote-decl", id: n.id, decl: { name: from, at: n.decl.at } });
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
const n = o.op === "pnote-edit" ? this.book.notes.find((x) => x.id === o.id) : undefined;
|
|
586
|
+
const text = n ? renameInNote(n.text, to, from) : null;
|
|
587
|
+
if (n && text !== n.text)
|
|
588
|
+
back.push({ op: "pnote-edit", id: n.id, text: text });
|
|
589
|
+
}
|
|
590
|
+
if (back.length)
|
|
591
|
+
this.arrangeNotes(back, null, by, agent);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const v = this.book.get(e.view);
|
|
595
|
+
if (!v)
|
|
596
|
+
continue;
|
|
597
|
+
const ops = [];
|
|
598
|
+
for (const o of e.ops) {
|
|
599
|
+
if (o.op === "pin" && o.kind === "node") {
|
|
600
|
+
const nodes = v.pins[o.mode].nodes;
|
|
601
|
+
if (Object.hasOwn(nodes, to) && !Object.hasOwn(nodes, from))
|
|
602
|
+
ops.push({ op: "pin", mode: o.mode, kind: "node", key: from, at: nodes[to] }, { op: "unpin", mode: o.mode, kind: "node", key: to });
|
|
603
|
+
}
|
|
604
|
+
else if (o.op === "note-edit") {
|
|
605
|
+
const n = v.notes?.find((x) => x.id === o.id);
|
|
606
|
+
const text = n ? renameInNote(n.text, to, from) : null;
|
|
607
|
+
if (n && text !== n.text)
|
|
608
|
+
ops.push({ op: "note-edit", id: n.id, text: text });
|
|
609
|
+
}
|
|
610
|
+
else if (o.op === "note-decl") {
|
|
611
|
+
const n = v.notes?.find((x) => x.id === o.id);
|
|
612
|
+
if (n?.decl?.name === to)
|
|
613
|
+
ops.push({ op: "note-decl", id: n.id, decl: { name: from, at: n.decl.at } });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (ops.length)
|
|
617
|
+
this.arrange(v.id, ops, null, by, agent);
|
|
618
|
+
if (e.scope) {
|
|
619
|
+
const q = renameInQuery(v.scope, to, from);
|
|
620
|
+
if (q !== v.scope) {
|
|
621
|
+
const c = this.book.setScope(v.id, q, by, false);
|
|
622
|
+
if (c && typeof c !== "string")
|
|
623
|
+
this.changed(c, agent);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (e.focus && v.focus === to) {
|
|
627
|
+
const c = this.book.setFocus(v.id, from, by, false);
|
|
628
|
+
if (c && typeof c !== "string")
|
|
629
|
+
this.changed(c, agent);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** Pins, folds and notes of view `id`, from page `from` (a session) or an agent, under `by` (and
|
|
634
|
+
* `person`, the personal link of whoever it is, CollabSpec decision 9). A note written, edited or
|
|
635
|
+
* deleted is an event of the timeline, under the name its note records. */
|
|
636
|
+
arrange(id, ops, from, by, agent = false, person = null) {
|
|
637
|
+
const unknown = this.unknownVersion(ops.map((o) => (o.op === "version" ? o.snapshot : null)));
|
|
638
|
+
if (unknown)
|
|
639
|
+
return unknown;
|
|
640
|
+
const before = new Map((this.book.get(id)?.notes ?? []).map((n) => [n.id, { ...n }]));
|
|
641
|
+
const carries = this.carriesRefused(ops, (n) => before.get(n)?.carries);
|
|
642
|
+
if (carries)
|
|
643
|
+
return carries;
|
|
644
|
+
// which list held each note an item operation names, for the timeline
|
|
645
|
+
const listed = structuredClone(this.book.get(id)?.items);
|
|
646
|
+
const who = { by, agent, person: by ? person : null };
|
|
647
|
+
const r = this.book.arrange(id, ops, by, who);
|
|
648
|
+
if (typeof r === "string")
|
|
649
|
+
return r;
|
|
650
|
+
if (r === null)
|
|
651
|
+
return null;
|
|
652
|
+
this.send("view-ops", { id, ops, from, by });
|
|
653
|
+
this.changed(r, agent, who.person);
|
|
654
|
+
if (!r.length)
|
|
655
|
+
this.save();
|
|
656
|
+
this.noted(id, ops, before, who);
|
|
657
|
+
this.itemsNoted(id, ops, listed, who);
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
/** The timeline's events of the notes of the project item operations `ops` added to view `id` or
|
|
661
|
+
* took out of it (`listed`: its lists as they were), as a scope changed is: under who did it, with
|
|
662
|
+
* the note's text then and whether the view shows it by it. */
|
|
663
|
+
itemsNoted(id, ops, listed, who) {
|
|
664
|
+
const v = this.book.get(id);
|
|
665
|
+
if (!v || !this.timeline)
|
|
666
|
+
return;
|
|
667
|
+
let was = listed;
|
|
668
|
+
for (const o of ops) {
|
|
669
|
+
if (o.op !== "item")
|
|
670
|
+
continue;
|
|
671
|
+
const from = itemIn(was, o.note);
|
|
672
|
+
if (from === o.in)
|
|
673
|
+
continue;
|
|
674
|
+
// each operation read against the lists as the ones before it left them
|
|
675
|
+
was = structuredClone(was) ?? { add: [], remove: [] };
|
|
676
|
+
if (from)
|
|
677
|
+
was[from] = was[from].filter((x) => x !== o.note);
|
|
678
|
+
if (o.in)
|
|
679
|
+
was[o.in].push(o.note);
|
|
680
|
+
const n = this.book.notes.find((x) => x.id === o.note);
|
|
681
|
+
this.timeline.add({ kind: "view", by: who.by, ...(who.agent ? { agent: true } : {}), ...(who.person ? { person: who.person } : {}), what: "items", view: id, name: v.name, scope: printQuery(v.scope), note: o.note, text: n?.text ?? "", shown: itemShown(from, o) });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/** Taken off every view's lists, for the pages: what `drops` did (a note no longer the project's,
|
|
685
|
+
* shared/views.ts dropItems). Nobody's change of the view — no event of the timeline, no view kept
|
|
686
|
+
* by it, no word on the board: the note's own event says what happened. */
|
|
687
|
+
dropped(drops) {
|
|
688
|
+
for (const d of drops)
|
|
689
|
+
this.send("view-ops", { id: d.view, ops: [d.op], from: null, by: null });
|
|
690
|
+
}
|
|
691
|
+
/** The project's notes changed by `ops` (shared/projectnotes.ts), from page `from` or an agent,
|
|
692
|
+
* under `by` (and `person`): every page is told (`note-ops`), and a note written, edited or
|
|
693
|
+
* deleted is an event of the timeline under the name its note records, as a view's is. A note
|
|
694
|
+
* written in view `written` (the board on show where it was written, or the view an agent names)
|
|
695
|
+
* is added to it (the owner's decision of 2026-10-11); a note deleted leaves every view's lists.
|
|
696
|
+
* Why the operations were refused, or null (taken, whether or not they changed anything). */
|
|
697
|
+
arrangeNotes(ops, from, by, agent = false, person = null, written = null) {
|
|
698
|
+
// held to what a page's request is held to, whichever path it came by: a place a file could
|
|
699
|
+
// not read back would lose the note at the next start
|
|
700
|
+
const why = invalidProjectNoteOps(ops);
|
|
701
|
+
if (why)
|
|
702
|
+
return why;
|
|
703
|
+
const before = new Map(this.book.notes.map((n) => [n.id, { ...n }]));
|
|
704
|
+
const carries = this.carriesRefused(ops, (n) => before.get(n)?.carries);
|
|
705
|
+
if (carries)
|
|
706
|
+
return carries;
|
|
707
|
+
const who = { by, agent, person: by ? person : null };
|
|
708
|
+
if (!this.book.noteOps(ops, who))
|
|
709
|
+
return null;
|
|
710
|
+
this.send("note-ops", { ops, from, by });
|
|
711
|
+
const now = new Set(this.book.notes.map((n) => n.id));
|
|
712
|
+
// a note deleted is in no view's lists any more
|
|
713
|
+
for (const o of ops)
|
|
714
|
+
if (o.op === "pnote-delete" && before.has(o.id) && !now.has(o.id))
|
|
715
|
+
this.dropped(this.book.dropItems(o.id));
|
|
716
|
+
// what was written in a view is that view's, wherever it is put: added to it, as the page that
|
|
717
|
+
// wrote it has done already (`from`)
|
|
718
|
+
const fresh = ops.flatMap((o) => (o.op === "pnote" && now.has(o.note.id) && !before.has(o.note.id) ? [o.note.id] : []));
|
|
719
|
+
const w = written && fresh.length ? this.book.written(written, fresh, by, who) : null;
|
|
720
|
+
if (w) {
|
|
721
|
+
this.send("view-ops", { id: written, ops: w.ops, from, by });
|
|
722
|
+
this.changed(w.changes, agent, who.person);
|
|
723
|
+
}
|
|
724
|
+
this.save();
|
|
725
|
+
if (!this.timeline)
|
|
726
|
+
return null;
|
|
727
|
+
for (const o of ops) {
|
|
728
|
+
const e = o.op === "pnote" && now.has(o.note.id) && !before.has(o.note.id)
|
|
729
|
+
? { what: "created", note: o.note.id, text: o.note.text }
|
|
730
|
+
: o.op === "pnote-edit" && now.has(o.id) && edited(before.get(o.id), o)
|
|
731
|
+
? { what: "edited", note: o.id, text: o.text }
|
|
732
|
+
: o.op === "pnote-delete" && before.has(o.id) && !now.has(o.id)
|
|
733
|
+
? { what: "deleted", note: o.id, text: before.get(o.id).text }
|
|
734
|
+
: null;
|
|
735
|
+
if (e)
|
|
736
|
+
this.timeline.add({ kind: "note", ...e, by: who.by, ...(who.agent ? { agent: true } : {}), ...(who.person ? { person: who.person } : {}), view: "", title: "" });
|
|
737
|
+
}
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
/** A note of the project made view `view`'s own, or view `view`'s made the project's (`to`), at
|
|
741
|
+
* `place` where it is on that view's board clustered by `mode` (shared/views.ts, switchNote):
|
|
742
|
+
* every page is told on both sides, and nobody becomes its author by it. Why not, or null. */
|
|
743
|
+
switchNote(id, view, to, place, mode, from, by, agent = false, person = null) {
|
|
744
|
+
const r = this.book.switchNote(id, view, to, place, mode, by);
|
|
745
|
+
if (typeof r === "string")
|
|
746
|
+
return r;
|
|
747
|
+
this.send("note-ops", { ops: r.notes, from, by });
|
|
748
|
+
this.send("view-ops", { id: view, ops: r.view, from, by });
|
|
749
|
+
this.dropped(r.drops);
|
|
750
|
+
this.changed(r.changes, agent, person);
|
|
751
|
+
if (!r.changes.length)
|
|
752
|
+
this.save();
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
/** A new build moved whole clusters (AttachmentSpec decision 18; shared/noteplace.ts,
|
|
756
|
+
* carriedNotes): each project note in a cluster that went, all of it, to another — a folder or a
|
|
757
|
+
* namespace renamed — goes with it, as an edit by `by` (VisualProof) that the timeline records
|
|
758
|
+
* with where it was carried from and to. `before` and `after` are the two builds' declarations,
|
|
759
|
+
* `renamed` the renames of high confidence found between them. How many notes were carried. */
|
|
760
|
+
carryNotes(before, after, renamed, by) {
|
|
761
|
+
const moves = carriedNotes(this.book.notes, before, after, renamed);
|
|
762
|
+
if (!moves.length)
|
|
763
|
+
return 0;
|
|
764
|
+
const at = new Date().toISOString();
|
|
765
|
+
const ops = moves.map((m) => ({ op: "pnote-move", id: m.id, mode: m.mode, place: m.place, by, at }));
|
|
766
|
+
// applied as they are: a carry is the server's own edit, stamped here, not taken from whoever asks
|
|
767
|
+
let changed = false;
|
|
768
|
+
for (const o of ops)
|
|
769
|
+
if (applyProjectNoteOp(this.book.notes, o))
|
|
770
|
+
changed = true;
|
|
771
|
+
if (!changed)
|
|
772
|
+
return 0;
|
|
773
|
+
this.send("note-ops", { ops, from: null, by });
|
|
774
|
+
this.save();
|
|
775
|
+
for (const m of moves) {
|
|
776
|
+
const n = this.book.notes.find((x) => x.id === m.id);
|
|
777
|
+
if (n)
|
|
778
|
+
this.timeline?.add({ kind: "note", what: "edited", note: n.id, text: n.text, by, view: "", title: "", carried: { mode: m.mode, from: m.from, to: m.to } });
|
|
779
|
+
}
|
|
780
|
+
return moves.length;
|
|
781
|
+
}
|
|
782
|
+
/** Why a version cannot be kept, of those asked for (null: the latest), or null. */
|
|
783
|
+
unknownVersion(snaps) {
|
|
784
|
+
for (const s of snaps)
|
|
785
|
+
if (typeof s === "number" && this.snapshotAt(s) === null)
|
|
786
|
+
return `There is no snapshot ${s}: GET /api/history lists them.`;
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
/** The timeline's events of the notes `ops` wrote, edited or deleted on view `id` (`before`: its
|
|
790
|
+
* notes as they were), under who did it as the notes record it; opening, collapsing and moving a
|
|
791
|
+
* note are not events, as a drag is not. */
|
|
792
|
+
noted(id, ops, before, who) {
|
|
793
|
+
const v = this.book.get(id);
|
|
794
|
+
if (!v || !this.timeline)
|
|
795
|
+
return;
|
|
796
|
+
const now = new Set((v.notes ?? []).map((n) => n.id));
|
|
797
|
+
const title = viewTitle(v, this.titles());
|
|
798
|
+
for (const o of ops) {
|
|
799
|
+
const e = o.op === "note" && now.has(o.note.id) && !before.has(o.note.id)
|
|
800
|
+
? { what: "created", note: o.note.id, text: o.note.text }
|
|
801
|
+
: o.op === "note-edit" && now.has(o.id) && edited(before.get(o.id), o)
|
|
802
|
+
? { what: "edited", note: o.id, text: o.text }
|
|
803
|
+
: o.op === "note-delete" && before.has(o.id) && !now.has(o.id)
|
|
804
|
+
? { what: "deleted", note: o.id, text: before.get(o.id).text }
|
|
805
|
+
: null;
|
|
806
|
+
if (e)
|
|
807
|
+
this.timeline.add({ kind: "note", ...e, by: who.by, ...(who.agent ? { agent: true } : {}), ...(who.person ? { person: who.person } : {}), view: id, title });
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
/** Tell the pages, write down what is kept, record what the timeline keeps (`person`: the
|
|
811
|
+
* personal link of whoever made the changes; `record` false for what the server does by itself
|
|
812
|
+
* with the targets, which the timeline has already). */
|
|
813
|
+
changed(changes, agent = false, person = null, record = true) {
|
|
814
|
+
if (!changes.length)
|
|
815
|
+
return;
|
|
816
|
+
for (const c of changes) {
|
|
817
|
+
const v = this.book.get(c.id);
|
|
818
|
+
if (c.what === "kept" && v)
|
|
819
|
+
this.saveMemory(v.id);
|
|
820
|
+
if (c.what === "deleted" || c.what === "gone")
|
|
821
|
+
rmSync(this.memFile(c.id), { force: true });
|
|
822
|
+
// what the timeline keeps: views saved for everyone, renamed, rescoped, deleted, laid out
|
|
823
|
+
// afresh, under a name or by someone without one (a private reader, CollabSpec decision 8); a
|
|
824
|
+
// target's view's scope changed is said as any view's (ViewSpec decision 28)
|
|
825
|
+
const what = c.what === "kept" ? "created" : c.what;
|
|
826
|
+
const rescoped = what === "scope" && !!v;
|
|
827
|
+
// kept at a past build: which one, and when it was built, so that the timeline says it alone
|
|
828
|
+
const version = what === "version" ? { snapshot: c.snapshot ?? null, ...(typeof c.snapshot === "number" && this.snapshotAt(c.snapshot) ? { built: this.snapshotAt(c.snapshot) } : {}) } : {};
|
|
829
|
+
// a target linked to a view (ViewSpec decision 29), by someone: said once, of the view it is
|
|
830
|
+
// linked to now; the view it left only stops being the target's. A click on a target with no
|
|
831
|
+
// view links it to a view nobody keeps, which is no more an event than opening one is. A view
|
|
832
|
+
// deleted that targets were linked to says those targets, which have no view now, in the same
|
|
833
|
+
// event (the owner's decisions of 2026-10-11)
|
|
834
|
+
const linked = what === "linked" && typeof c.target === "string" && !c.temporary && record;
|
|
835
|
+
const unlinked = what === "deleted" && !!c.targets?.length;
|
|
836
|
+
if (what === "created" || what === "renamed" || rescoped || what === "deleted" || what === "relayout" || what === "version" || linked)
|
|
837
|
+
this.timeline?.add({ kind: "view", by: c.by, ...(agent ? { agent: true } : {}), ...(person && c.by ? { person } : {}), what: what, view: c.id, name: v?.name ?? null, ...(c.was !== undefined ? { was: c.was } : {}), ...(linked ? { target: c.target } : {}), ...(unlinked ? { targets: c.targets } : {}), scope: v ? printQuery(v.scope) : c.scope ?? "", ...version });
|
|
838
|
+
}
|
|
839
|
+
this.save();
|
|
840
|
+
this.send("views", { views: this.list(), changes });
|
|
841
|
+
}
|
|
842
|
+
save() {
|
|
843
|
+
const kept = this.book.kept();
|
|
844
|
+
const authors = Object.fromEntries(kept.flatMap((v) => (this.book.authors.has(v.id) ? [[v.id, this.book.authors.get(v.id)]] : [])));
|
|
845
|
+
// the views that could not be read go back as they came, unless one of the same id took their
|
|
846
|
+
// place since
|
|
847
|
+
const aside = this.unreadable.filter((u) => !u.id || !this.book.get(u.id));
|
|
848
|
+
for (const u of aside)
|
|
849
|
+
if (u.id && this.unreadableAuthors[u.id] !== undefined)
|
|
850
|
+
authors[u.id] = this.unreadableAuthors[u.id];
|
|
851
|
+
const data = {
|
|
852
|
+
layoutVersion: this.layoutVersion,
|
|
853
|
+
views: [...kept, ...aside.map((u) => ({ ...u.raw, unreadable: u.why }))],
|
|
854
|
+
authors,
|
|
855
|
+
...(this.book.notes.length ? { notes: this.book.notes } : {}),
|
|
856
|
+
...(this.filesNoted.size ? { filesNoted: [...this.filesNoted] } : {}),
|
|
857
|
+
...(this.fileTitles ? { fileTitles: Object.fromEntries(this.fileTitles) } : {}),
|
|
858
|
+
};
|
|
859
|
+
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
860
|
+
writeFileSync(tmp, JSON.stringify(data) + "\n");
|
|
861
|
+
renameSync(tmp, this.file);
|
|
862
|
+
}
|
|
863
|
+
memFile(id) {
|
|
864
|
+
return join(this.memDir, createHash("sha1").update(id).digest("hex").slice(0, 20) + ".json");
|
|
865
|
+
}
|
|
866
|
+
saveMemory(id) {
|
|
867
|
+
const m = this.book.memory.get(id);
|
|
868
|
+
if (!m || !this.book.get(id)?.kept)
|
|
869
|
+
return;
|
|
870
|
+
mkdirSync(this.memDir, { recursive: true });
|
|
871
|
+
const tmp = `${this.memFile(id)}.tmp-${process.pid}`;
|
|
872
|
+
writeFileSync(tmp, JSON.stringify(m));
|
|
873
|
+
renameSync(tmp, this.memFile(id));
|
|
874
|
+
}
|
|
875
|
+
/** A board's layout memory, handed over by a page or the agents' API (compare and set). */
|
|
876
|
+
setMemory(id, mode, base, next) {
|
|
877
|
+
const r = this.book.setMemory(id, mode, base, next);
|
|
878
|
+
if (typeof r === "string" || !r.accepted)
|
|
879
|
+
return r;
|
|
880
|
+
this.saveMemory(id);
|
|
881
|
+
this.send("view-memory", { id, mode, base, set: next.set });
|
|
882
|
+
return r;
|
|
883
|
+
}
|
|
884
|
+
/** Lay a view out afresh: its pins and its layout's memory go; the Project's graph is laid out
|
|
885
|
+
* anew by the server too. The timeline says who did it, and whether an agent did. */
|
|
886
|
+
relayout(id, by, agent = false, person = null) {
|
|
887
|
+
const c = this.book.relayout(id, by);
|
|
888
|
+
if (typeof c === "string")
|
|
889
|
+
return c;
|
|
890
|
+
rmSync(this.memFile(id), { force: true });
|
|
891
|
+
this.send("view-ops", { id, ops: [{ op: "clear" }], from: null, by });
|
|
892
|
+
for (const mode of CLUSTER_MODES)
|
|
893
|
+
this.send("view-memory", { id, mode, base: null, set: null });
|
|
894
|
+
this.changed([c], agent, person);
|
|
895
|
+
if (id === PROJECT)
|
|
896
|
+
this.onProjectRelayout();
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
// ---------------------------------------------------------------- the page's endpoints
|
|
900
|
+
/** A request under /api/views, read by main.ts: what the page asks, under the name it gives. An
|
|
901
|
+
* agent may use them too, within what `--agents` allows (AgentSpec decision 9): opening a view
|
|
902
|
+
* is reading; everything else, deleting a view and laying one out afresh included, is level
|
|
903
|
+
* full, recorded as the agent's. A private reader's changes are recorded without a name, and
|
|
904
|
+
* need none (CollabSpec decision 8). */
|
|
905
|
+
handle(a, level) {
|
|
906
|
+
// who what is done here is recorded under: the caller's name, or none for a private reader
|
|
907
|
+
// (identity.ts, layoutAuthor); what needs a name is what records one
|
|
908
|
+
const me = layoutAuthor(a.caller);
|
|
909
|
+
const nameless = !me && !a.caller.private;
|
|
910
|
+
// the personal link of whoever it is (CollabSpec decision 9), with their name only: never with
|
|
911
|
+
// what a private reader arranges
|
|
912
|
+
const person = me ? a.caller.person ?? null : null;
|
|
913
|
+
// a private page holds the views it reads or opens: it says nothing else of what it has open
|
|
914
|
+
const session = a.caller.private?.session ?? null;
|
|
915
|
+
const holds = (id) => {
|
|
916
|
+
if (session)
|
|
917
|
+
this.hold(session, [id]);
|
|
918
|
+
};
|
|
919
|
+
const agent = a.caller.kind === "agent";
|
|
920
|
+
const b = (a.body && typeof a.body === "object" ? a.body : {});
|
|
921
|
+
const id = typeof b.id === "string" ? b.id : a.query.get("id") ?? "";
|
|
922
|
+
const mode = (typeof b.mode === "string" ? b.mode : a.query.get("mode") ?? "namespace");
|
|
923
|
+
const arranging = () => (agent ? (me ? refusal(level, "full") : NEED_NAME) : null);
|
|
924
|
+
const result = (r, body = () => ({ ok: true })) => {
|
|
925
|
+
if (typeof r === "string")
|
|
926
|
+
return no(r.startsWith("There is no view") ? 404 : 400, r);
|
|
927
|
+
this.changed(r === null ? [] : Array.isArray(r) ? r : [r], agent, person);
|
|
928
|
+
return ok(body());
|
|
929
|
+
};
|
|
930
|
+
const path = a.path.replace(/\/+$/, "");
|
|
931
|
+
if (a.method === "GET") {
|
|
932
|
+
if (path === "/api/views")
|
|
933
|
+
return ok({ views: this.list(), ...(this.unreadable.length ? { unreadable: this.unreadableList() } : {}) });
|
|
934
|
+
if (path === "/api/views/view") {
|
|
935
|
+
const v = this.book.get(id);
|
|
936
|
+
if (!v)
|
|
937
|
+
return no(404, `There is no view ${id}.`);
|
|
938
|
+
holds(v.id);
|
|
939
|
+
const memory = {};
|
|
940
|
+
for (const m of CLUSTER_MODES) {
|
|
941
|
+
const s = this.book.memoryOf(id, m)?.set;
|
|
942
|
+
if (s)
|
|
943
|
+
memory[m] = s;
|
|
944
|
+
}
|
|
945
|
+
return ok({ view: v, memory });
|
|
946
|
+
}
|
|
947
|
+
// the project's notes, every one (AttachmentSpec decision 13): each page decides which its
|
|
948
|
+
// board shows
|
|
949
|
+
if (path === "/api/views/notes")
|
|
950
|
+
return ok({ notes: this.book.notes });
|
|
951
|
+
if (path === "/api/views/memory") {
|
|
952
|
+
if (!this.book.get(id))
|
|
953
|
+
return no(404, `There is no view ${id}.`);
|
|
954
|
+
if (!CLUSTER_MODES.includes(mode))
|
|
955
|
+
return no(400, "mode is namespace or module.");
|
|
956
|
+
return ok({ memory: this.book.memoryOf(id, mode) });
|
|
957
|
+
}
|
|
958
|
+
return no(404, "not found");
|
|
959
|
+
}
|
|
960
|
+
if (a.method !== "POST")
|
|
961
|
+
return no(405, `${a.path} answers GET and POST.`);
|
|
962
|
+
switch (path) {
|
|
963
|
+
case "/api/views/open": {
|
|
964
|
+
const q = readQuery(b.scope);
|
|
965
|
+
if (typeof q === "string")
|
|
966
|
+
return no(400, q);
|
|
967
|
+
// a view made from a board of a past build shows that build (the owner's decision of
|
|
968
|
+
// 2026-10-09): found or made by its query and the build together
|
|
969
|
+
if (b.version !== undefined && b.version !== null && !validVersion(b.version))
|
|
970
|
+
return no(400, "version is a snapshot's id (GET /api/history lists them), or null for the latest build.");
|
|
971
|
+
const unknown = this.unknownVersion([b.version]);
|
|
972
|
+
if (unknown)
|
|
973
|
+
return no(404, unknown);
|
|
974
|
+
if (b.clustering !== undefined && !CLUSTER_MODES.includes(b.clustering))
|
|
975
|
+
return no(400, "The clustering is namespace or module.");
|
|
976
|
+
// a new view's focus (ViewSpec decision 28): the target it is made from, or the focus of the
|
|
977
|
+
// view a Zoom in came from
|
|
978
|
+
if (b.focus !== undefined && b.focus !== null && (typeof b.focus !== "string" || !this.isTarget(b.focus)))
|
|
979
|
+
return no(400, "focus is a target's full name.");
|
|
980
|
+
const { view, change } = this.book.open(q, me, Date.now(), { agent, person, ...(typeof b.version === "number" ? { version: b.version } : {}), ...(b.clustering ? { clustering: b.clustering } : {}), ...(typeof b.focus === "string" ? { focus: b.focus } : {}) });
|
|
981
|
+
holds(view.id);
|
|
982
|
+
return result(change, () => ({ view }));
|
|
983
|
+
}
|
|
984
|
+
case "/api/views/create": {
|
|
985
|
+
const refused = arranging();
|
|
986
|
+
if (refused)
|
|
987
|
+
return refused;
|
|
988
|
+
if (nameless)
|
|
989
|
+
return NEED_NAME;
|
|
990
|
+
const q = b.scope === undefined ? undefined : readQuery(b.scope);
|
|
991
|
+
if (typeof q === "string")
|
|
992
|
+
return no(400, q);
|
|
993
|
+
if (b.clustering !== undefined && !CLUSTER_MODES.includes(b.clustering))
|
|
994
|
+
return no(400, "The clustering is namespace or module.");
|
|
995
|
+
// Save as new view (HistorySpec decision 19): the build it shows, a snapshot the history has
|
|
996
|
+
if (b.version !== undefined && b.version !== null && !validVersion(b.version))
|
|
997
|
+
return no(400, "version is a snapshot's id (GET /api/history lists them), or null for the latest build.");
|
|
998
|
+
const unknown = this.unknownVersion([b.version]);
|
|
999
|
+
if (unknown)
|
|
1000
|
+
return no(404, unknown);
|
|
1001
|
+
const v = this.book.create(typeof b.name === "string" ? b.name.trim() : "", me, { scope: q, clustering: b.clustering, from: typeof b.from === "string" ? b.from : undefined, version: b.version, agent, person });
|
|
1002
|
+
if (typeof v === "string")
|
|
1003
|
+
return no(400, v);
|
|
1004
|
+
return result({ id: v.id, what: "kept", by: me }, () => ({ view: v }));
|
|
1005
|
+
}
|
|
1006
|
+
case "/api/views/rename": {
|
|
1007
|
+
const refused = arranging();
|
|
1008
|
+
if (refused)
|
|
1009
|
+
return refused;
|
|
1010
|
+
if (nameless)
|
|
1011
|
+
return NEED_NAME;
|
|
1012
|
+
const name = b.name === null ? null : typeof b.name === "string" ? b.name.trim() : "";
|
|
1013
|
+
return result(this.book.rename(id, name === "" ? null : name, me));
|
|
1014
|
+
}
|
|
1015
|
+
case "/api/views/delete": {
|
|
1016
|
+
const refused = arranging();
|
|
1017
|
+
if (refused)
|
|
1018
|
+
return refused;
|
|
1019
|
+
if (nameless)
|
|
1020
|
+
return NEED_NAME;
|
|
1021
|
+
return result(this.book.remove(id, me));
|
|
1022
|
+
}
|
|
1023
|
+
case "/api/views/scope": {
|
|
1024
|
+
const refused = arranging();
|
|
1025
|
+
if (refused)
|
|
1026
|
+
return refused;
|
|
1027
|
+
const q = readQuery(b.scope);
|
|
1028
|
+
if (typeof q === "string")
|
|
1029
|
+
return no(400, q);
|
|
1030
|
+
return result(this.book.setScope(id, q, me), () => ({ ok: true, scope: printQuery(q) }));
|
|
1031
|
+
}
|
|
1032
|
+
case "/api/views/focus": {
|
|
1033
|
+
// the view's own focus, for everyone on it (ViewSpec decision 28): a target, or null for none
|
|
1034
|
+
const refused = arranging();
|
|
1035
|
+
if (refused)
|
|
1036
|
+
return refused;
|
|
1037
|
+
if (b.focus !== null && (typeof b.focus !== "string" || !this.isTarget(b.focus)))
|
|
1038
|
+
return no(400, "focus is a target's full name, or null for none.");
|
|
1039
|
+
return result(this.book.setFocus(id, b.focus, me));
|
|
1040
|
+
}
|
|
1041
|
+
case "/api/views/link": {
|
|
1042
|
+
// a target linked to this view, for everyone (ViewSpec decision 29): its clicks open it; a
|
|
1043
|
+
// view linked to other targets takes it too (the owner's decision of 2026-10-11)
|
|
1044
|
+
const refused = arranging();
|
|
1045
|
+
if (refused)
|
|
1046
|
+
return refused;
|
|
1047
|
+
if (nameless)
|
|
1048
|
+
return NEED_NAME;
|
|
1049
|
+
if (typeof b.target !== "string" || !this.isTarget(b.target))
|
|
1050
|
+
return no(400, "target is a target's full name.");
|
|
1051
|
+
return result(this.book.link(id, b.target, me));
|
|
1052
|
+
}
|
|
1053
|
+
case "/api/views/target": {
|
|
1054
|
+
// the view a click on a target opens (ViewSpec decision 29, the owner's decision of
|
|
1055
|
+
// 2026-10-11): the one it is linked to, else a view nobody keeps of its TCB, linked to it
|
|
1056
|
+
// for as long as that view lasts. Opening a view is reading, as `open` is
|
|
1057
|
+
if (typeof b.target !== "string" || !this.isTarget(b.target))
|
|
1058
|
+
return no(400, "target is a target's full name.");
|
|
1059
|
+
const r = this.openTarget(b.target, me, agent, person);
|
|
1060
|
+
holds(r.id);
|
|
1061
|
+
return ok({ view: r });
|
|
1062
|
+
}
|
|
1063
|
+
case "/api/views/clustering": {
|
|
1064
|
+
const refused = arranging();
|
|
1065
|
+
if (refused)
|
|
1066
|
+
return refused;
|
|
1067
|
+
return result(this.book.setClustering(id, b.clustering, me));
|
|
1068
|
+
}
|
|
1069
|
+
case "/api/views/arrange": {
|
|
1070
|
+
const refused = arranging();
|
|
1071
|
+
if (refused)
|
|
1072
|
+
return refused;
|
|
1073
|
+
const why = invalidViewOps(b.ops);
|
|
1074
|
+
if (why)
|
|
1075
|
+
return no(400, why);
|
|
1076
|
+
// a clear is a re-layout's, which says so on the timeline
|
|
1077
|
+
const ops = b.ops.filter((o) => o.op !== "clear");
|
|
1078
|
+
// a note written, edited or deleted records who did it, as a view made does; so does a view
|
|
1079
|
+
// kept at a build, for everyone on it, as a re-layout does
|
|
1080
|
+
if (nameless && ops.some((o) => o.op === "note" || o.op === "note-edit" || o.op === "note-delete" || o.op === "version"))
|
|
1081
|
+
return NEED_NAME;
|
|
1082
|
+
// the page says which it is by its stream's secret: the others are told its public id
|
|
1083
|
+
const err = this.arrange(id, ops, this.pageOf(b.token), me, agent, person);
|
|
1084
|
+
return err ? no(this.book.get(id) ? 400 : 404, err) : ok({ ok: true });
|
|
1085
|
+
}
|
|
1086
|
+
case "/api/views/notes": {
|
|
1087
|
+
// the project's notes, as a view's are arranged: writing, editing and deleting one is
|
|
1088
|
+
// recorded under a name; opening, collapsing and moving one need none
|
|
1089
|
+
const refused = arranging();
|
|
1090
|
+
if (refused)
|
|
1091
|
+
return refused;
|
|
1092
|
+
const why = invalidProjectNoteOps(b.ops);
|
|
1093
|
+
if (why)
|
|
1094
|
+
return no(400, why);
|
|
1095
|
+
const ops = b.ops;
|
|
1096
|
+
if (nameless && ops.some((o) => o.op === "pnote" || o.op === "pnote-edit" || o.op === "pnote-delete"))
|
|
1097
|
+
return NEED_NAME;
|
|
1098
|
+
// the view they were written in, which they are added to (the owner's decision of 2026-10-11)
|
|
1099
|
+
if (b.in !== undefined && b.in !== null && (typeof b.in !== "string" || !this.book.get(b.in)))
|
|
1100
|
+
return no(404, `There is no view ${String(b.in).slice(0, 80)} to write in.`);
|
|
1101
|
+
// what a note is to carry is checked against the library there
|
|
1102
|
+
const err = this.arrangeNotes(ops, this.pageOf(b.token), me, agent, person, typeof b.in === "string" ? b.in : null);
|
|
1103
|
+
return err ? no(400, err) : ok({ ok: true });
|
|
1104
|
+
}
|
|
1105
|
+
case "/api/views/note-kind": {
|
|
1106
|
+
// a note made a view's own, or the project's again (AttachmentSpec decision 14), keeping
|
|
1107
|
+
// where it is on the board it was switched on
|
|
1108
|
+
const refused = arranging();
|
|
1109
|
+
if (refused)
|
|
1110
|
+
return refused;
|
|
1111
|
+
if (nameless)
|
|
1112
|
+
return NEED_NAME;
|
|
1113
|
+
if (typeof b.note !== "string" || (b.to !== "view" && b.to !== "project"))
|
|
1114
|
+
return no(400, "Send {id, note, to: view | project, place, mode}: the view, the note, and where it is on that view's board.");
|
|
1115
|
+
if (!CLUSTER_MODES.includes(mode))
|
|
1116
|
+
return no(400, "mode is namespace or module.");
|
|
1117
|
+
if (b.to === "view" ? invalidPlace(b.place) : invalidClusterPlace(b.place))
|
|
1118
|
+
return no(400, "place is where the note is on the board: a view note's {mode, in, world}, or a project note's {in} or {ring}.");
|
|
1119
|
+
const err = this.switchNote(b.note, id, b.to, b.place, mode, this.pageOf(b.token), me, agent, person);
|
|
1120
|
+
return err ? no(this.book.get(id) ? 400 : 404, err) : ok({ ok: true });
|
|
1121
|
+
}
|
|
1122
|
+
case "/api/views/relayout": {
|
|
1123
|
+
const refused = arranging();
|
|
1124
|
+
if (refused)
|
|
1125
|
+
return refused;
|
|
1126
|
+
if (nameless)
|
|
1127
|
+
return NEED_NAME;
|
|
1128
|
+
const err = this.relayout(id, me, agent, person);
|
|
1129
|
+
return err ? no(404, err) : ok({ ok: true });
|
|
1130
|
+
}
|
|
1131
|
+
case "/api/views/memory": {
|
|
1132
|
+
// the memory decides where everything on the board is for everyone on it: handing it over
|
|
1133
|
+
// is laying the board out, under a name and within what agents may do, as arranging is
|
|
1134
|
+
const refused = arranging();
|
|
1135
|
+
if (refused)
|
|
1136
|
+
return refused;
|
|
1137
|
+
if (nameless)
|
|
1138
|
+
return NEED_NAME;
|
|
1139
|
+
if (!CLUSTER_MODES.includes(mode))
|
|
1140
|
+
return no(400, "mode is namespace or module.");
|
|
1141
|
+
const v = this.book.get(id);
|
|
1142
|
+
if (!v)
|
|
1143
|
+
return no(404, `There is no view ${id}.`);
|
|
1144
|
+
const next = cleanMemory(b.memory);
|
|
1145
|
+
if (!next)
|
|
1146
|
+
return no(400, "memory is {set, mem: {nodes, clusters}} as shared/layout.ts memoryOf makes it.");
|
|
1147
|
+
// for the set the board holds now, of what is on it: a page that read another graph or
|
|
1148
|
+
// review lays the board out for itself, and nothing else gets in
|
|
1149
|
+
const unfit = this.checkMemory(v, mode, next);
|
|
1150
|
+
if (unfit)
|
|
1151
|
+
return no(unfit.status, unfit.error);
|
|
1152
|
+
const r = this.setMemory(id, mode, typeof b.base === "string" ? b.base : null, next);
|
|
1153
|
+
if (typeof r === "string")
|
|
1154
|
+
return no(404, r);
|
|
1155
|
+
// the memory that won, unless it is the one sent: the page lays the board out from it
|
|
1156
|
+
return ok({ accepted: r.accepted, current: r.accepted ? { set: next.set } : r.current });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
return no(404, "not found");
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
/** A file's title as a note's text: a note needs some, within its limit and without control
|
|
1163
|
+
* characters (a title read from a PDF may carry any). */
|
|
1164
|
+
function noteTitle(title) {
|
|
1165
|
+
const t = title.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim() || "A file of the library";
|
|
1166
|
+
return t.length > MAX_NOTE_TEXT ? t.slice(0, MAX_NOTE_TEXT - 1) + "…" : t;
|
|
1167
|
+
}
|