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,859 @@
|
|
|
1
|
+
// Views (ViewSpec.md): a view is a board — what is on it (a scope, shared/query.ts) and how it is
|
|
2
|
+
// arranged (pins, folds, the clustering, and the notes stuck on it: shared/sticky.ts) are shared by
|
|
3
|
+
// everyone on it; the camera, the selection and what is lit are each reader's own. This file holds
|
|
4
|
+
// what the server and the pages share: the record of a view, the operations that arrange it
|
|
5
|
+
// (applied the same way on both sides, as pins were: shared/places.ts), and the book of views —
|
|
6
|
+
// which exist, which are kept, what becomes of them — that the server keeps (server/views.ts) and a
|
|
7
|
+
// snapshot keeps in the page.
|
|
8
|
+
//
|
|
9
|
+
// A board may hide levels of its cluster tree (ViewSpec decision 24), per way of clustering: what a
|
|
10
|
+
// hidden cluster holds sits in its parent. Like folds, they are the board's, kept for everyone and
|
|
11
|
+
// changed by operations; a fold on a cluster that is hidden goes, since the cluster is not there.
|
|
12
|
+
//
|
|
13
|
+
// A view may be kept at a past build (HistorySpec decision 19): its `version`, a snapshot's id, set
|
|
14
|
+
// by an operation for everyone on it; it then shows that build's graph, read-only.
|
|
15
|
+
//
|
|
16
|
+
// A view opened from a query (Zoom in, a link, an agent) is only what that query looks like: it is
|
|
17
|
+
// not kept, and goes when no page has it open. Opened from a board of a past build, it shows that
|
|
18
|
+
// build (its `version`; the owner's decision of 2026-10-09): a view on a query is found by the query
|
|
19
|
+
// and the build together. Pinning, folding or naming it keeps it, for
|
|
20
|
+
// everyone; deleting a kept view is explicit. The Project view (everything, today's shared pins)
|
|
21
|
+
// is always there and cannot be deleted.
|
|
22
|
+
//
|
|
23
|
+
// A target is linked to at most one view, its view, and a view may be linked to several targets (ViewSpec
|
|
24
|
+
// decision 29; the owner's decisions of 2026-10-11): the record's `targets`, in the order they were
|
|
25
|
+
// linked. Marking a target makes nothing (the owner's decision of 2026-10-12): a target has no view
|
|
26
|
+
// until a click on it opens a view nobody keeps on its TCB, linked to it (`openTarget`) — the link
|
|
27
|
+
// lasting as long as that view, kept with it — or someone links it to a view, for everyone, the view
|
|
28
|
+
// it leaves keeping its scope and arrangement. Unmarking a target unlinks it alone. A view linked to
|
|
29
|
+
// targets is deleted as any kept view is, and its targets then have no view. Nothing makes a target a
|
|
30
|
+
// view by itself: not a start, not the targets synced, not a rename. A view named `review:<target>`,
|
|
31
|
+
// as marking made them before, is a view as any other.
|
|
32
|
+
//
|
|
33
|
+
// A view has a focus target, or none (ViewSpec decision 28): where the review works on it. The
|
|
34
|
+
// view's own focus is kept with it (`focus`), set when it is made from a target or a focused view,
|
|
35
|
+
// and else follows from the rules of `viewFocus`; each reader may focus otherwise for themselves,
|
|
36
|
+
// which is the page's (web/src/focus.ts) and never changes the record.
|
|
37
|
+
//
|
|
38
|
+
// The book also holds the project's notes (AttachmentSpec decisions 13–19, shared/projectnotes.ts):
|
|
39
|
+
// kept beside the views, shared as their operations are, and switched with a view's own notes (Only
|
|
40
|
+
// in this view, In every view) in one step, so that a note is never in both places nor in none.
|
|
41
|
+
//
|
|
42
|
+
// Items belong to views as declarations do (the owner's decision of 2026-10-11, shared/items.ts): a
|
|
43
|
+
// view keeps the notes of the project added to it and those taken out of it, two lists beside its
|
|
44
|
+
// scope, changed by the `item` operation as its pins are. The Project holds every note and keeps no
|
|
45
|
+
// lists. A copy of a view has its lists; a copy of the Project has, as added, the notes its scope
|
|
46
|
+
// would not give it (`derives`, which the server sets: it reads the graph).
|
|
47
|
+
import { applyItemOp, dropItem, invalidItemOp } from "./items.js";
|
|
48
|
+
import { applyOps, CLUSTER_MODES, emptyLayouts, invalidOps } from "./places.js";
|
|
49
|
+
import { chainOf, describe, describeCondition, parseQuery, printQuery, readKeys, sameQuery } from "./query.js";
|
|
50
|
+
import { validPersonId } from "./review.js";
|
|
51
|
+
import { applyNoteOp, invalidNoteOp, invalidPlace, MAX_NOTES, NOTE_OPS, stampNoteOp } from "./sticky.js";
|
|
52
|
+
import { applyProjectNoteOp, asProjectNote, asViewNote, invalidClusterPlace, MAX_PROJECT_NOTES, stampProjectNoteOp } from "./projectnotes.js";
|
|
53
|
+
import { atBuild, lastPart, scopeTitle, TITLE_MAX, withoutBuild } from "./titles.js";
|
|
54
|
+
export const PROJECT = "project";
|
|
55
|
+
export function summary(v) {
|
|
56
|
+
return {
|
|
57
|
+
id: v.id,
|
|
58
|
+
name: v.name,
|
|
59
|
+
scope: printQuery(v.scope),
|
|
60
|
+
clustering: v.clustering,
|
|
61
|
+
kept: v.kept,
|
|
62
|
+
...(v.targets?.length ? { targets: [...v.targets] } : {}),
|
|
63
|
+
...(v.focus !== undefined ? { focus: v.focus } : {}),
|
|
64
|
+
by: v.by,
|
|
65
|
+
at: v.at,
|
|
66
|
+
...(v.version !== undefined ? { version: v.version } : {}),
|
|
67
|
+
...(v.items ? { items: structuredClone(v.items) } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** The view's focus (ViewSpec decision 28), shared by everyone on it: the target it was focused on
|
|
71
|
+
* (`focus`, set when it was made from a target or from a focused view, when its first target was
|
|
72
|
+
* linked, or by hand; null, on none), else the target linked to it first (the owner's decision of
|
|
73
|
+
* 2026-10-11: a view may be several targets'), else the one target the board holds (`held`, the
|
|
74
|
+
* targets its scope holds now), else none. A focus on what is no target now goes by the next rule. */
|
|
75
|
+
export function viewFocus(v, isTarget, held) {
|
|
76
|
+
if (v.focus === null)
|
|
77
|
+
return null;
|
|
78
|
+
if (v.focus !== undefined && isTarget(v.focus))
|
|
79
|
+
return v.focus;
|
|
80
|
+
const linked = v.targets?.find(isTarget);
|
|
81
|
+
if (linked)
|
|
82
|
+
return linked;
|
|
83
|
+
const ts = held();
|
|
84
|
+
return ts.length === 1 ? ts[0] : null;
|
|
85
|
+
}
|
|
86
|
+
/** The focus a new view takes from the view it is made from (Duplicate, a copy without something,
|
|
87
|
+
* Save as new view): its focus as set, else the target linked to it first, which is its focus to
|
|
88
|
+
* begin with. Undefined where neither says. */
|
|
89
|
+
export const focusFrom = (v) => (v.focus !== undefined ? v.focus : v.targets?.[0]);
|
|
90
|
+
/** The scope of the view a click on a target with no view opens (ViewSpec decision 29, the owner's
|
|
91
|
+
* decision of 2026-10-11): its TCB, `tcb(T)`, the query Zoom in on the target under Rests on → TCB
|
|
92
|
+
* makes from the Project (decision 13) — the same whatever board the click came from. */
|
|
93
|
+
export const targetScope = (target) => ({ op: "tcb", of: [target] });
|
|
94
|
+
/** What a view is called on a tab, in a list, in Activity, presence and the agents' answers: its
|
|
95
|
+
* name, as people gave it; the Project; or, for a view nobody named, its scope in a few words (the
|
|
96
|
+
* owner's decision of 2026-10-10, shared/titles.ts: "TCB of ns Fermat42"), with the build it is kept
|
|
97
|
+
* at ("… @ Oct 9 22:27"). Whether a view is linked to targets is said beside its title, by a star
|
|
98
|
+
* (ViewSpec decision 29), not in it: a target's temporary view reads "★ TCB of T" (the owner's
|
|
99
|
+
* decision of 2026-10-12). */
|
|
100
|
+
export function viewTitle(v, ctx = {}) {
|
|
101
|
+
if (v.name)
|
|
102
|
+
return v.name;
|
|
103
|
+
if (v.id === PROJECT)
|
|
104
|
+
return "Project";
|
|
105
|
+
if (v.version === undefined)
|
|
106
|
+
return scopeTitle(v.scope, ctx.names);
|
|
107
|
+
const when = ctx.build?.(v.version) ?? `build ${v.version}`;
|
|
108
|
+
return atBuild(scopeTitle(v.scope, ctx.names, TITLE_MAX - when.length - 3), when);
|
|
109
|
+
}
|
|
110
|
+
/** What a view on a query is called from the query's text (a tab, or a reader's place, whose view
|
|
111
|
+
* the list does not hold): its title as a view nobody named, at the build it shows; the text itself
|
|
112
|
+
* where it cannot be read. */
|
|
113
|
+
export function titleOfScope(text, ctx = {}, version) {
|
|
114
|
+
try {
|
|
115
|
+
return viewTitle({ id: "", name: null, scope: parseQuery(text), ...(version !== undefined ? { version } : {}) }, ctx);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return text;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** What a view's scope holds, in full (shared/query.ts, `describe`): the set it starts from, then
|
|
122
|
+
* its conditions — what the page shows under the pointer where it shows a title, and agents read as
|
|
123
|
+
* `description`. */
|
|
124
|
+
export function viewDescription(v) {
|
|
125
|
+
// a view of a Keys selection (Zoom in): its and is the one part it is, not a condition
|
|
126
|
+
if (readKeys(v.scope))
|
|
127
|
+
return describe(v.scope);
|
|
128
|
+
const { base, conditions } = chainOf(v.scope);
|
|
129
|
+
return [describe(base), ...conditions.map(describeCondition)].join(", ");
|
|
130
|
+
}
|
|
131
|
+
/** The title of a view's exported document (ExportSpec decision 14): the view's title as it is
|
|
132
|
+
* named — a name people gave exactly as they wrote it, the Project — and,
|
|
133
|
+
* for a view nobody named at a past build, with that build as its title says it ("… @ Oct 9
|
|
134
|
+
* 22:27"; decision 16): the document is that build's. A title made from the scope is lower case
|
|
135
|
+
* only because it is made, and takes a capital as a title does, a namespace written in full
|
|
136
|
+
* ("Namespace Fermat42"); one that begins with a declaration's name (a view of `decl(…)`) keeps it as
|
|
137
|
+
* Lean writes it. */
|
|
138
|
+
export function docTitle(v, ctx = {}) {
|
|
139
|
+
const t = viewTitle(v, ctx);
|
|
140
|
+
if (v.name || v.id === PROJECT)
|
|
141
|
+
return t;
|
|
142
|
+
if (!readKeys(v.scope) && chainOf(v.scope).base.op === "decl")
|
|
143
|
+
return t;
|
|
144
|
+
if (t.startsWith("ns "))
|
|
145
|
+
return `Namespace ${t.slice(3)}`;
|
|
146
|
+
return t.charAt(0).toUpperCase() + t.slice(1);
|
|
147
|
+
}
|
|
148
|
+
/** The name Save as new view gives the view it makes of view `title` at a build (HistorySpec
|
|
149
|
+
* decision 19): its title at that build, "Project @ Oct 9 22:27" — a build the view's own name
|
|
150
|
+
* ended with replaced — and a number where another view has that name. */
|
|
151
|
+
export function versionName(title, when, taken) {
|
|
152
|
+
const base = atBuild(withoutBuild(title), when);
|
|
153
|
+
let name = base;
|
|
154
|
+
for (let k = 2; taken.has(name); k++)
|
|
155
|
+
name = `${base} (${k})`;
|
|
156
|
+
return name;
|
|
157
|
+
}
|
|
158
|
+
/** The marks a record keeps of `who` beside its name: `agent`, and `person` where it may be. */
|
|
159
|
+
export function marksOf(who) {
|
|
160
|
+
if (!who)
|
|
161
|
+
return {};
|
|
162
|
+
if (who.agent)
|
|
163
|
+
return { agent: true };
|
|
164
|
+
return who.by && validPersonId(who.person) ? { person: who.person } : {};
|
|
165
|
+
}
|
|
166
|
+
/** Why target `target` cannot be linked to view `v`, or null (ViewSpec decision 29). The Project is
|
|
167
|
+
* everyone's way in, no target's; a view kept at a past build is not reviewed. A view linked to
|
|
168
|
+
* other targets takes one more (the owner's decision of 2026-10-11). */
|
|
169
|
+
export function linkRefused(v, target) {
|
|
170
|
+
const t = lastPart(target);
|
|
171
|
+
if (v.id === PROJECT)
|
|
172
|
+
return `The Project is everyone's way in, no target's: link ★${t} to another view.`;
|
|
173
|
+
if (v.version !== undefined)
|
|
174
|
+
return `This view shows a past build, where nothing is reviewed: ★${t}'s view shows the latest build.`;
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
/** A view whose scope stays as it is: the Project, everything (ViewSpec decisions 5 and 22), where
|
|
178
|
+
* Remove from this view makes a copy without it instead. Every other view's scope can change, a
|
|
179
|
+
* target's view's too (decision 28). */
|
|
180
|
+
export const fixedScope = (v) => v.id === PROJECT;
|
|
181
|
+
/** Why a view cannot be kept at a past build, or null. The Project and a target's view always show
|
|
182
|
+
* the latest: the one is everyone's way in, and on the other the review is given, which a past
|
|
183
|
+
* graph cannot take; Save as new view keeps a build in a view of its own. */
|
|
184
|
+
export function versionRefused(v) {
|
|
185
|
+
return v.id === PROJECT || v.targets?.length ? "The Project and a target's view always show the latest build: Save as new view keeps this one in a view of its own." : null;
|
|
186
|
+
}
|
|
187
|
+
/** Why notes of the project cannot be added to view `v` or taken out of it, or null: the Project holds
|
|
188
|
+
* every one, so there a note is deleted, not taken out; and what is added or taken out is a note of
|
|
189
|
+
* the project (`isNote`). Taking an id off the lists is always taken. */
|
|
190
|
+
export function itemsRefused(v, ops, isNote) {
|
|
191
|
+
if (fixedScope(v))
|
|
192
|
+
return "The Project holds every note of the project: there a note is deleted, not added or taken out.";
|
|
193
|
+
const stray = ops.find((o) => o.in !== null && !isNote(o.note));
|
|
194
|
+
return stray ? `There is no note ${stray.note} of the project: GET /api/agent/notes?all=1 lists them.` : null;
|
|
195
|
+
}
|
|
196
|
+
/** A snapshot's id as a view's version: a whole number from 1. */
|
|
197
|
+
export const validVersion = (x) => typeof x === "number" && Number.isInteger(x) && x > 0 && x < 1e9;
|
|
198
|
+
export const noAuthors = () => ({ namespace: { nodes: {}, clusters: {} }, module: { nodes: {}, clusters: {} } });
|
|
199
|
+
/** The authors of `pins` as read back from disk: one kept only for a pin there is. */
|
|
200
|
+
export function cleanAuthors(raw, pins) {
|
|
201
|
+
const out = noAuthors();
|
|
202
|
+
const a = raw && typeof raw === "object" ? raw : {};
|
|
203
|
+
for (const m of CLUSTER_MODES)
|
|
204
|
+
for (const kind of ["nodes", "clusters"])
|
|
205
|
+
for (const [key, who] of Object.entries(a[m]?.[kind] ?? {}))
|
|
206
|
+
if (Object.hasOwn(pins[m][kind], key) && who && typeof who === "object" && typeof who.at === "string")
|
|
207
|
+
out[m][kind][key] = who;
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
const isNoteOp = (o) => NOTE_OPS.includes(o.op);
|
|
211
|
+
/** the most levels a board hides by one clustering */
|
|
212
|
+
export const MAX_HIDDEN = 2000;
|
|
213
|
+
/** Why a list of operations is refused, or null. */
|
|
214
|
+
export function invalidViewOps(ops) {
|
|
215
|
+
if (!Array.isArray(ops) || ops.length === 0 || ops.length > 4000)
|
|
216
|
+
return "ops";
|
|
217
|
+
const places = [];
|
|
218
|
+
for (const o of ops) {
|
|
219
|
+
if (!o || typeof o !== "object")
|
|
220
|
+
return "op";
|
|
221
|
+
if (isNoteOp(o)) {
|
|
222
|
+
const why = invalidNoteOp(o);
|
|
223
|
+
if (why)
|
|
224
|
+
return why;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (o.op === "version") {
|
|
228
|
+
if (o.snapshot !== null && !validVersion(o.snapshot))
|
|
229
|
+
return "snapshot";
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (o.op === "item") {
|
|
233
|
+
const why = invalidItemOp(o);
|
|
234
|
+
if (why)
|
|
235
|
+
return why;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (o.op !== "fold" && o.op !== "hide") {
|
|
239
|
+
places.push(o);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!CLUSTER_MODES.includes(o.mode))
|
|
243
|
+
return "mode";
|
|
244
|
+
if (typeof o.key !== "string" || !o.key || o.key.length > 2000)
|
|
245
|
+
return "key";
|
|
246
|
+
if (typeof o.on !== "boolean")
|
|
247
|
+
return "on";
|
|
248
|
+
}
|
|
249
|
+
return places.length ? invalidOps(places) : null;
|
|
250
|
+
}
|
|
251
|
+
/** Apply operations to a view's arrangement in place; true when anything changed. A clear drops
|
|
252
|
+
* the pins (Re-layout), not the folds, the hidden levels, the notes, the items nor the version.
|
|
253
|
+
* Hiding a level drops its fold; a fold on a hidden level is not taken, as nobody can fold what the
|
|
254
|
+
* board does not show. */
|
|
255
|
+
export function applyViewOps(v, ops) {
|
|
256
|
+
let changed = false;
|
|
257
|
+
const places = [];
|
|
258
|
+
const flush = () => {
|
|
259
|
+
if (places.length && applyOps(v.pins, places.splice(0)))
|
|
260
|
+
changed = true;
|
|
261
|
+
};
|
|
262
|
+
for (const o of ops) {
|
|
263
|
+
if (isNoteOp(o)) {
|
|
264
|
+
if (applyNoteOp((v.notes ??= []), o))
|
|
265
|
+
changed = true;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (o.op === "item") {
|
|
269
|
+
if (applyItemOp(v, o))
|
|
270
|
+
changed = true;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (o.op === "version") {
|
|
274
|
+
if ((v.version ?? null) === o.snapshot)
|
|
275
|
+
continue;
|
|
276
|
+
if (o.snapshot === null)
|
|
277
|
+
delete v.version;
|
|
278
|
+
else
|
|
279
|
+
v.version = o.snapshot;
|
|
280
|
+
changed = true;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (o.op !== "fold" && o.op !== "hide") {
|
|
284
|
+
places.push(o);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
flush();
|
|
288
|
+
if (o.op === "hide") {
|
|
289
|
+
const hid = v.hidden[o.mode];
|
|
290
|
+
if (o.on && !hid.includes(o.key) && hid.length < MAX_HIDDEN) {
|
|
291
|
+
hid.push(o.key);
|
|
292
|
+
v.folds[o.mode] = v.folds[o.mode].filter((k) => k !== o.key);
|
|
293
|
+
}
|
|
294
|
+
else if (!o.on && hid.includes(o.key))
|
|
295
|
+
v.hidden[o.mode] = hid.filter((k) => k !== o.key);
|
|
296
|
+
else
|
|
297
|
+
continue;
|
|
298
|
+
changed = true;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (o.on && v.hidden[o.mode].includes(o.key))
|
|
302
|
+
continue;
|
|
303
|
+
const now = v.folds[o.mode];
|
|
304
|
+
const has = now.includes(o.key);
|
|
305
|
+
if (o.on && !has)
|
|
306
|
+
now.push(o.key);
|
|
307
|
+
else if (!o.on && has)
|
|
308
|
+
v.folds[o.mode] = now.filter((k) => k !== o.key);
|
|
309
|
+
else
|
|
310
|
+
continue;
|
|
311
|
+
changed = true;
|
|
312
|
+
}
|
|
313
|
+
flush();
|
|
314
|
+
return changed;
|
|
315
|
+
}
|
|
316
|
+
/** The fold operations that turn one set of folded keys into another. */
|
|
317
|
+
export function diffFolds(mode, before, after) {
|
|
318
|
+
const a = new Set(after), b = new Set(before);
|
|
319
|
+
return [...before.filter((k) => !a.has(k)).map((key) => ({ op: "fold", mode, key, on: false })), ...after.filter((k) => !b.has(k)).map((key) => ({ op: "fold", mode, key, on: true }))];
|
|
320
|
+
}
|
|
321
|
+
/** A view's name: 1–80 characters, no line breaks or other control characters. */
|
|
322
|
+
export function validViewName(s) {
|
|
323
|
+
return typeof s === "string" && s.trim() === s && s.length > 0 && s.length <= 80 && !/[\u0000-\u001f\u007f]/.test(s);
|
|
324
|
+
}
|
|
325
|
+
const empty = () => ({ namespace: [], module: [] });
|
|
326
|
+
const newId = () => "v" + Math.random().toString(36).slice(2, 9);
|
|
327
|
+
/**
|
|
328
|
+
* Which views exist and what they hold. Pure: the server (server/views.ts) keeps it on disk and
|
|
329
|
+
* tells the pages; a snapshot keeps one in the page. Every method that refuses says why, as text.
|
|
330
|
+
*/
|
|
331
|
+
export class ViewBook {
|
|
332
|
+
views = new Map();
|
|
333
|
+
/** the layout's memory, per view and way of clustering (shared/board.ts) */
|
|
334
|
+
memory = new Map();
|
|
335
|
+
/** who dropped each pin, per view (PinAuthor); a pin dropped before the server kept this has
|
|
336
|
+
* none */
|
|
337
|
+
authors = new Map();
|
|
338
|
+
/** the project's notes (shared/projectnotes.ts), in the order they were written */
|
|
339
|
+
notes;
|
|
340
|
+
/** an open view nobody keeps: when a page last had it open */
|
|
341
|
+
seenAt = new Map();
|
|
342
|
+
/** Which notes a board of view `v` gives from what follows from its scope alone (shared/noteplace.ts,
|
|
343
|
+
* derivesOn), read on the graph on show: what a copy of the Project leaves out of its added
|
|
344
|
+
* notes. Set by the server; null where nothing reads a graph (a copy of the Project then takes
|
|
345
|
+
* none as added). */
|
|
346
|
+
derives = null;
|
|
347
|
+
constructor(views = [], notes = []) {
|
|
348
|
+
for (const v of views)
|
|
349
|
+
this.views.set(v.id, v);
|
|
350
|
+
this.notes = notes;
|
|
351
|
+
this.project();
|
|
352
|
+
}
|
|
353
|
+
/** The project's notes changed by `ops`, written and edited by `who` where it is known; the
|
|
354
|
+
* operations are stamped in place with who and when, so that what is sent on is what was
|
|
355
|
+
* applied. Whether anything changed. */
|
|
356
|
+
noteOps(ops, who) {
|
|
357
|
+
const at = new Date().toISOString();
|
|
358
|
+
let changed = false;
|
|
359
|
+
for (const o of ops) {
|
|
360
|
+
stampProjectNoteOp(o, who, at);
|
|
361
|
+
if (applyProjectNoteOp(this.notes, o))
|
|
362
|
+
changed = true;
|
|
363
|
+
}
|
|
364
|
+
return changed;
|
|
365
|
+
}
|
|
366
|
+
/** Make a note of the project view `view`'s own (`to` "view": Only in this view), at `place`, where
|
|
367
|
+
* it is on that view's board; or make view `view`'s note the project's (`to` "project": In every
|
|
368
|
+
* view), at `place` under that board's clustering `mode`. Its text, author, state and what it
|
|
369
|
+
* carries are kept, and no one becomes its author by switching it. A note made a view's is work on
|
|
370
|
+
* that board, as a note written there is: a view nobody kept is kept by it (`by`); it leaves every
|
|
371
|
+
* view's lists (`drops`). A note made the project's is added to the view it was switched on, so
|
|
372
|
+
* that it stays on that board. The operations that do it, for the pages — on the project's notes
|
|
373
|
+
* and on the view's — and what changed in the book; or why not. */
|
|
374
|
+
switchNote(id, view, to, place, mode, by = null) {
|
|
375
|
+
const v = this.find(view);
|
|
376
|
+
if (typeof v === "string")
|
|
377
|
+
return v;
|
|
378
|
+
if (to === "view") {
|
|
379
|
+
const n = this.notes.find((x) => x.id === id);
|
|
380
|
+
if (!n)
|
|
381
|
+
return `The project has no note ${id}.`;
|
|
382
|
+
if (invalidPlace(place))
|
|
383
|
+
return "place is a view note's place: {mode, in, world}.";
|
|
384
|
+
if ((v.notes?.length ?? 0) >= MAX_NOTES)
|
|
385
|
+
return `This view holds ${MAX_NOTES} notes already.`;
|
|
386
|
+
if (v.notes?.some((x) => x.id === id))
|
|
387
|
+
return `This view has a note ${id} already.`;
|
|
388
|
+
const out = { notes: [{ op: "pnote-delete", id }], view: [{ op: "note", note: asViewNote(n, place) }], changes: v.kept ? [] : [this.keep(v, by)], drops: [] };
|
|
389
|
+
applyProjectNoteOp(this.notes, out.notes[0]);
|
|
390
|
+
applyViewOps(v, out.view);
|
|
391
|
+
// no longer the project's: no view's lists name it (shared/items.ts)
|
|
392
|
+
out.drops = this.dropItems(id);
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
const n = v.notes?.find((x) => x.id === id);
|
|
396
|
+
if (!n)
|
|
397
|
+
return `View ${view} has no note ${id}.`;
|
|
398
|
+
if (invalidClusterPlace(place))
|
|
399
|
+
return "place is a project note's place under the board's clustering: {in: [[key, x, y], …]} or {ring: degrees}.";
|
|
400
|
+
if (this.notes.length >= MAX_PROJECT_NOTES)
|
|
401
|
+
return `The project holds ${MAX_PROJECT_NOTES} notes already.`;
|
|
402
|
+
if (this.notes.some((x) => x.id === id))
|
|
403
|
+
return `The project has a note ${id} already.`;
|
|
404
|
+
// made the project's on this board, it stays on it whatever the board holds: added to the view
|
|
405
|
+
// (the Project holds it already)
|
|
406
|
+
const ops = [{ op: "note-delete", id }, ...(v.id === PROJECT ? [] : [{ op: "item", note: id, in: "add" }])];
|
|
407
|
+
const out = { notes: [{ op: "pnote", note: asProjectNote(n, mode, place) }], view: ops, changes: [], drops: [] };
|
|
408
|
+
applyProjectNoteOp(this.notes, out.notes[0]);
|
|
409
|
+
applyViewOps(v, out.view);
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
/** Note `id` is no longer the project's (deleted, or made a view's own): taken off every view's
|
|
413
|
+
* lists. The operations that did it, by view, for the pages. */
|
|
414
|
+
dropItems(id) {
|
|
415
|
+
const out = dropItem(this.views.values(), id);
|
|
416
|
+
for (const d of out)
|
|
417
|
+
applyViewOps(this.views.get(d.view), [d.op]);
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
/** Notes of the project just written in view `view` (Add a note here, a file dropped or pasted, Clip
|
|
421
|
+
* to a note, while that view is on show): added to it, so that it shows them wherever they are
|
|
422
|
+
* put, as work on that board — a view nobody kept is kept by it. Nothing on the Project, which
|
|
423
|
+
* holds every note, and nothing for what is no note of the project. The operations and what
|
|
424
|
+
* changed in the book, or null where nothing did. */
|
|
425
|
+
written(view, ids, by, who) {
|
|
426
|
+
const v = this.views.get(view);
|
|
427
|
+
if (!v || v.id === PROJECT)
|
|
428
|
+
return null;
|
|
429
|
+
const ops = ids.filter((id) => this.notes.some((n) => n.id === id)).map((id) => ({ op: "item", note: id, in: "add" }));
|
|
430
|
+
if (!ops.length)
|
|
431
|
+
return null;
|
|
432
|
+
const r = this.arrange(v.id, ops, by, who);
|
|
433
|
+
return r === null || typeof r === "string" ? null : { ops, changes: r };
|
|
434
|
+
}
|
|
435
|
+
/** The Project view: everything, with its shared pins (ViewSpec decision 5). */
|
|
436
|
+
project() {
|
|
437
|
+
let p = this.views.get(PROJECT);
|
|
438
|
+
if (!p) {
|
|
439
|
+
p = { id: PROJECT, name: null, scope: { op: "all" }, clustering: "namespace", pins: emptyLayouts(), folds: empty(), hidden: empty(), by: null, at: new Date(0).toISOString(), kept: true };
|
|
440
|
+
this.views.set(PROJECT, p);
|
|
441
|
+
}
|
|
442
|
+
return p;
|
|
443
|
+
}
|
|
444
|
+
get(id) {
|
|
445
|
+
return this.views.get(id);
|
|
446
|
+
}
|
|
447
|
+
/** Every view: the Project first, then the targets' views by the target linked to each first, then
|
|
448
|
+
* the rest by when they were made. */
|
|
449
|
+
list() {
|
|
450
|
+
const first = (v) => v.targets?.[0];
|
|
451
|
+
const rank = (v) => (v.id === PROJECT ? 0 : first(v) ? 1 : 2);
|
|
452
|
+
return [...this.views.values()].sort((a, b) => {
|
|
453
|
+
const ta = first(a), tb = first(b);
|
|
454
|
+
return rank(a) - rank(b) || (ta && tb && ta !== tb ? (ta < tb ? -1 : 1) : a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
kept() {
|
|
458
|
+
return this.list().filter((v) => v.kept);
|
|
459
|
+
}
|
|
460
|
+
/** A view on a query: the open one nobody keeps that has the same scope and shows the same build,
|
|
461
|
+
* or a new one, made by `by` (with its marks, `agent` and `person`: an agent, a personal link).
|
|
462
|
+
* `version`: the build it shows — what is made from a board of a past build keeps that build
|
|
463
|
+
* (the owner's decision of 2026-10-09) — absent for the latest; `clustering`: a new one's (a
|
|
464
|
+
* cluster's Zoom in keeps the board's), else by namespace; `focus`: a new one's focus (ViewSpec
|
|
465
|
+
* decision 28), the target it is made from (a target's Key view) or the focus of the view a Zoom
|
|
466
|
+
* in came from. */
|
|
467
|
+
open(scope, by, now = Date.now(), opts = {}) {
|
|
468
|
+
const version = opts.version ?? null;
|
|
469
|
+
for (const v of this.views.values()) {
|
|
470
|
+
if (!v.kept && sameQuery(v.scope, scope) && (v.version ?? null) === version) {
|
|
471
|
+
this.seenAt.set(v.id, now);
|
|
472
|
+
return { view: v, change: null };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const v = {
|
|
476
|
+
id: this.fresh(),
|
|
477
|
+
name: null,
|
|
478
|
+
scope,
|
|
479
|
+
clustering: opts.clustering ?? "namespace",
|
|
480
|
+
pins: emptyLayouts(),
|
|
481
|
+
folds: empty(),
|
|
482
|
+
hidden: empty(),
|
|
483
|
+
by,
|
|
484
|
+
at: new Date(now).toISOString(),
|
|
485
|
+
...marksOf({ by, agent: opts.agent, person: opts.person }),
|
|
486
|
+
kept: false,
|
|
487
|
+
...(version !== null ? { version } : {}),
|
|
488
|
+
...(opts.focus ? { focus: opts.focus } : {}),
|
|
489
|
+
};
|
|
490
|
+
this.views.set(v.id, v);
|
|
491
|
+
this.seenAt.set(v.id, now);
|
|
492
|
+
return { view: v, change: { id: v.id, what: "opened", by } };
|
|
493
|
+
}
|
|
494
|
+
/** A new kept view, named: on `scope`, or a copy of view `from` (its scope, clustering, pins,
|
|
495
|
+
* folds, hidden levels, version and focus, `focusFrom` — a board of one's own to arrange, no
|
|
496
|
+
* target's); a copy given a `scope` too holds that one instead, arranged as the view it was
|
|
497
|
+
* copied from (the canvas's menu: a copy of the Project without what was removed). `version`: the
|
|
498
|
+
* build it shows (Save as new view, HistorySpec decision 19), null for the latest; left out, the
|
|
499
|
+
* copied view's. `agent` and `person`: who `by` is, as the record keeps it. */
|
|
500
|
+
create(name, by, opts = {}) {
|
|
501
|
+
if (!validViewName(name))
|
|
502
|
+
return "A view's name is 1 to 80 characters, without line breaks.";
|
|
503
|
+
const src = opts.from !== undefined ? this.views.get(opts.from) : undefined;
|
|
504
|
+
if (opts.from !== undefined && !src)
|
|
505
|
+
return `There is no view ${opts.from}.`;
|
|
506
|
+
if (opts.version !== undefined && opts.version !== null && !validVersion(opts.version))
|
|
507
|
+
return "A version is a snapshot's id (GET /api/history lists them), or null for the latest build.";
|
|
508
|
+
const version = opts.version === undefined ? src?.version : opts.version ?? undefined;
|
|
509
|
+
const v = {
|
|
510
|
+
id: this.fresh(),
|
|
511
|
+
name,
|
|
512
|
+
scope: opts.scope ?? (src ? structuredClone(src.scope) : { op: "all" }),
|
|
513
|
+
clustering: opts.clustering ?? src?.clustering ?? "namespace",
|
|
514
|
+
pins: src ? structuredClone(src.pins) : emptyLayouts(),
|
|
515
|
+
folds: src ? structuredClone(src.folds) : empty(),
|
|
516
|
+
// a copy is the board it was copied from, its notes on it, and what it shows of the
|
|
517
|
+
// project's: its added and taken-out notes, or, from the Project, what it would not give
|
|
518
|
+
...(src?.notes?.length ? { notes: structuredClone(src.notes) } : {}),
|
|
519
|
+
...this.copiedItems(src),
|
|
520
|
+
hidden: src ? structuredClone(src.hidden) : empty(),
|
|
521
|
+
by,
|
|
522
|
+
at: new Date().toISOString(),
|
|
523
|
+
...marksOf({ by, agent: opts.agent, person: opts.person }),
|
|
524
|
+
kept: true,
|
|
525
|
+
...(version !== undefined ? { version } : {}),
|
|
526
|
+
};
|
|
527
|
+
const focus = src ? focusFrom(src) : undefined;
|
|
528
|
+
if (focus !== undefined)
|
|
529
|
+
v.focus = focus;
|
|
530
|
+
this.views.set(v.id, v);
|
|
531
|
+
if (src) {
|
|
532
|
+
// the copy starts from the picture it was copied from, its pins still theirs who dropped them
|
|
533
|
+
const m = this.memory.get(src.id);
|
|
534
|
+
if (m)
|
|
535
|
+
this.memory.set(v.id, structuredClone(m));
|
|
536
|
+
const a = this.authors.get(src.id);
|
|
537
|
+
if (a)
|
|
538
|
+
this.authors.set(v.id, structuredClone(a));
|
|
539
|
+
}
|
|
540
|
+
return v;
|
|
541
|
+
}
|
|
542
|
+
/** What a copy of view `src` holds of the project's notes besides what its scope gives: the lists
|
|
543
|
+
* of `src`; from the Project, which holds every note, the notes a view of its scope would not
|
|
544
|
+
* give, as added (the owner's decision of 2026-10-11) — reckoned on the Project's scope, so that a
|
|
545
|
+
* copy without something (New view without it…) leaves out the notes about it with it. */
|
|
546
|
+
copiedItems(src) {
|
|
547
|
+
if (!src)
|
|
548
|
+
return {};
|
|
549
|
+
if (src.id !== PROJECT)
|
|
550
|
+
return src.items ? { items: structuredClone(src.items) } : {};
|
|
551
|
+
const gives = this.derives?.(src);
|
|
552
|
+
const add = gives ? this.notes.filter((n) => !gives(n)).map((n) => n.id) : [];
|
|
553
|
+
return add.length ? { items: { add, remove: [] } } : {};
|
|
554
|
+
}
|
|
555
|
+
fresh() {
|
|
556
|
+
let id = newId();
|
|
557
|
+
while (this.views.has(id))
|
|
558
|
+
id = newId();
|
|
559
|
+
return id;
|
|
560
|
+
}
|
|
561
|
+
find(id) {
|
|
562
|
+
return this.views.get(id) ?? `There is no view ${id}: it was deleted, or it was not kept and every page closed it.`;
|
|
563
|
+
}
|
|
564
|
+
/** Name a view (which keeps it), or take a kept view's name away (null). */
|
|
565
|
+
rename(id, name, by) {
|
|
566
|
+
const v = this.find(id);
|
|
567
|
+
if (typeof v === "string")
|
|
568
|
+
return v;
|
|
569
|
+
if (v.id === PROJECT)
|
|
570
|
+
return "The Project keeps its name.";
|
|
571
|
+
if (name !== null && !validViewName(name))
|
|
572
|
+
return "A view's name is 1 to 80 characters, without line breaks.";
|
|
573
|
+
if (name === v.name)
|
|
574
|
+
return [];
|
|
575
|
+
const was = v.name;
|
|
576
|
+
v.name = name;
|
|
577
|
+
const out = [];
|
|
578
|
+
if (!v.kept && name !== null)
|
|
579
|
+
out.push(this.keep(v, by));
|
|
580
|
+
out.push({ id, what: "renamed", by, was });
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
/** Delete a kept view: explicit, and never the Project. A view targets are linked to is deleted as
|
|
584
|
+
* any is, and unlinks them all, which then have no view (the owner's decisions of 2026-10-11): the
|
|
585
|
+
* deletion says which (`targets`), so that the timeline tells both in one event. */
|
|
586
|
+
remove(id, by) {
|
|
587
|
+
const v = this.find(id);
|
|
588
|
+
if (typeof v === "string")
|
|
589
|
+
return v;
|
|
590
|
+
if (v.id === PROJECT)
|
|
591
|
+
return "The Project cannot be deleted.";
|
|
592
|
+
if (!v.kept)
|
|
593
|
+
return "This view is not kept: it goes when the last page closes it.";
|
|
594
|
+
this.forget(id);
|
|
595
|
+
return { id, what: "deleted", by, was: v.name, scope: printQuery(v.scope), ...(v.targets?.length ? { targets: [...v.targets] } : {}) };
|
|
596
|
+
}
|
|
597
|
+
/** A view's scope changed by `by`. A scope changed is work done on the board, as a pin dropped is:
|
|
598
|
+
* a view not kept yet is kept by it (ViewSpec decision 6), so that what was added to it or taken
|
|
599
|
+
* out of it from the canvas's menu outlives the pages that had it open. A rename carried into the
|
|
600
|
+
* scope (`keep` false: server/views.ts, moveDeclaration) is no one's work and keeps nothing. */
|
|
601
|
+
setScope(id, scope, by, keep = true) {
|
|
602
|
+
const v = this.find(id);
|
|
603
|
+
if (typeof v === "string")
|
|
604
|
+
return v;
|
|
605
|
+
if (sameQuery(v.scope, scope))
|
|
606
|
+
return null;
|
|
607
|
+
v.scope = scope;
|
|
608
|
+
const change = { id, what: "scope", by };
|
|
609
|
+
return v.kept || !keep ? [change] : [change, this.keep(v, by)];
|
|
610
|
+
}
|
|
611
|
+
/** A view's own focus set by `by` (ViewSpec decision 28): a target's name, or null for none, for
|
|
612
|
+
* everyone on it; each reader's own focus is left as it is. Work on the board, as a scope changed
|
|
613
|
+
* is: a view not kept yet is kept by it. A rename carried into it (`keep` false) keeps nothing. */
|
|
614
|
+
setFocus(id, focus, by, keep = true) {
|
|
615
|
+
const v = this.find(id);
|
|
616
|
+
if (typeof v === "string")
|
|
617
|
+
return v;
|
|
618
|
+
if (v.focus === focus)
|
|
619
|
+
return null;
|
|
620
|
+
v.focus = focus;
|
|
621
|
+
const change = { id, what: "focus", by };
|
|
622
|
+
return v.kept || !keep ? [change] : [change, this.keep(v, by)];
|
|
623
|
+
}
|
|
624
|
+
/** The view target `target` is linked to (ViewSpec decision 29), or undefined: it has none. */
|
|
625
|
+
viewOf(target) {
|
|
626
|
+
for (const v of this.views.values())
|
|
627
|
+
if (v.targets?.includes(target))
|
|
628
|
+
return v;
|
|
629
|
+
return undefined;
|
|
630
|
+
}
|
|
631
|
+
/** Link target `target` to view `id`, for everyone (ViewSpec decision 29): the target's clicks open
|
|
632
|
+
* it. A view linked to other targets takes it too (the owner's decision of 2026-10-11); a view's
|
|
633
|
+
* first target is its focus to begin with, and a target linked after leaves its focus as it is. The
|
|
634
|
+
* view the target leaves keeps its scope, arrangement and focus, and only stops being the target's.
|
|
635
|
+
* Linking keeps a view nobody kept, and with it its other links. Whether `target` is a target is
|
|
636
|
+
* the caller's to say. */
|
|
637
|
+
link(id, target, by) {
|
|
638
|
+
const v = this.find(id);
|
|
639
|
+
if (typeof v === "string")
|
|
640
|
+
return v;
|
|
641
|
+
const no = linkRefused(v, target);
|
|
642
|
+
if (no)
|
|
643
|
+
return no;
|
|
644
|
+
if (v.targets?.includes(target))
|
|
645
|
+
return null;
|
|
646
|
+
const out = [];
|
|
647
|
+
const was = this.viewOf(target);
|
|
648
|
+
if (was)
|
|
649
|
+
out.push(this.unlink(was, target, by));
|
|
650
|
+
// saved for everyone first, as the timeline tells it, then the target's
|
|
651
|
+
if (!v.kept)
|
|
652
|
+
out.push(this.keep(v, by));
|
|
653
|
+
v.targets = [...(v.targets ?? []), target];
|
|
654
|
+
if (v.targets.length === 1)
|
|
655
|
+
v.focus = target;
|
|
656
|
+
out.push({ id: v.id, what: "linked", by, target });
|
|
657
|
+
return out;
|
|
658
|
+
}
|
|
659
|
+
/** Target `target` off view `v`, where a link moved it to another view: the view keeps its scope
|
|
660
|
+
* and arrangement, and its focus — on the target as it was, where it was the view's first target
|
|
661
|
+
* and nothing else set its focus. */
|
|
662
|
+
unlink(v, target, by) {
|
|
663
|
+
if (v.focus === undefined && v.targets?.[0] === target)
|
|
664
|
+
v.focus = target;
|
|
665
|
+
this.drop(v, target);
|
|
666
|
+
return { id: v.id, what: "unlinked", by, target };
|
|
667
|
+
}
|
|
668
|
+
/** Target `target` taken off view `v`'s links, and the list with it where it was the last. */
|
|
669
|
+
drop(v, target) {
|
|
670
|
+
const left = (v.targets ?? []).filter((t) => t !== target);
|
|
671
|
+
if (left.length)
|
|
672
|
+
v.targets = left;
|
|
673
|
+
else
|
|
674
|
+
delete v.targets;
|
|
675
|
+
}
|
|
676
|
+
/** The view a click on target `target` opens (ViewSpec decision 29, the owner's decision of
|
|
677
|
+
* 2026-10-11): the one it is linked to; where it has none, a view nobody keeps of its TCB, as Zoom
|
|
678
|
+
* in on it under Rests on → TCB makes from the Project (`targetScope`, by namespace, at the latest
|
|
679
|
+
* build: `open`), found open or made, linked to it and focused on it where it is its first target.
|
|
680
|
+
* The link keeps nothing: it lasts as long as the view, which is not written down while nobody
|
|
681
|
+
* keeps it, so that when the view goes — or the server starts again — the target has none again;
|
|
682
|
+
* keeping the view keeps the link (`keep`). Made by `by` (with its marks: an agent, a personal link); whether `target` is a target
|
|
683
|
+
* is the caller's to say. */
|
|
684
|
+
openTarget(target, by, now = Date.now(), who = {}) {
|
|
685
|
+
const have = this.viewOf(target);
|
|
686
|
+
if (have) {
|
|
687
|
+
if (!have.kept)
|
|
688
|
+
this.seenAt.set(have.id, now);
|
|
689
|
+
return { view: have, changes: [] };
|
|
690
|
+
}
|
|
691
|
+
const { view, change } = this.open(targetScope(target), by, now, { focus: target, ...who });
|
|
692
|
+
view.targets = [...(view.targets ?? []), target];
|
|
693
|
+
if (view.targets.length === 1)
|
|
694
|
+
view.focus = target;
|
|
695
|
+
return { view, changes: [...(change ? [change] : []), { id: view.id, what: "linked", by, target, temporary: true }] };
|
|
696
|
+
}
|
|
697
|
+
setClustering(id, mode, by) {
|
|
698
|
+
const v = this.find(id);
|
|
699
|
+
if (typeof v === "string")
|
|
700
|
+
return v;
|
|
701
|
+
if (!CLUSTER_MODES.includes(mode))
|
|
702
|
+
return "The clustering is namespace or module.";
|
|
703
|
+
if (v.clustering === mode)
|
|
704
|
+
return null;
|
|
705
|
+
v.clustering = mode;
|
|
706
|
+
return { id, what: "clustering", by };
|
|
707
|
+
}
|
|
708
|
+
/** Pins, folds, hidden levels and notes, dropped and written by `who` where it is known (a pin
|
|
709
|
+
* dropped without is recorded with no author, a note written without has none); arranging a view
|
|
710
|
+
* keeps it. The note operations are stamped in place with who and when, so that what is sent on
|
|
711
|
+
* is what was applied. Null when nothing changed. */
|
|
712
|
+
arrange(id, ops, by, who) {
|
|
713
|
+
const v = this.find(id);
|
|
714
|
+
if (typeof v === "string")
|
|
715
|
+
return v;
|
|
716
|
+
const version = ops.find((o) => o.op === "version");
|
|
717
|
+
if (version && version.snapshot !== null) {
|
|
718
|
+
const no = versionRefused(v);
|
|
719
|
+
if (no)
|
|
720
|
+
return no;
|
|
721
|
+
}
|
|
722
|
+
const items = ops.filter((o) => o.op === "item");
|
|
723
|
+
if (items.length) {
|
|
724
|
+
const no = itemsRefused(v, items, (id) => this.notes.some((n) => n.id === id));
|
|
725
|
+
if (no)
|
|
726
|
+
return no;
|
|
727
|
+
}
|
|
728
|
+
const at = new Date().toISOString();
|
|
729
|
+
for (const o of ops)
|
|
730
|
+
if (isNoteOp(o))
|
|
731
|
+
stampNoteOp(o, who, at);
|
|
732
|
+
const was = v.version ?? null;
|
|
733
|
+
if (!applyViewOps(v, ops))
|
|
734
|
+
return null;
|
|
735
|
+
// the build it shows changed: the list says it (each tab's clock), the timeline records it
|
|
736
|
+
const kept = (v.version ?? null) !== was ? [{ id, what: "version", by, snapshot: v.version ?? null }] : [];
|
|
737
|
+
let authors = this.authors.get(id);
|
|
738
|
+
for (const o of ops) {
|
|
739
|
+
if (o.op !== "pin" && o.op !== "unpin" && o.op !== "clear")
|
|
740
|
+
continue;
|
|
741
|
+
if (o.op === "clear") {
|
|
742
|
+
this.authors.delete(id);
|
|
743
|
+
authors = undefined;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (!authors)
|
|
747
|
+
this.authors.set(id, (authors = noAuthors()));
|
|
748
|
+
const set = o.kind === "node" ? authors[o.mode].nodes : authors[o.mode].clusters;
|
|
749
|
+
if (o.op === "pin" && who)
|
|
750
|
+
set[o.key] = { by: who.by, ...marksOf(who), at };
|
|
751
|
+
else
|
|
752
|
+
delete set[o.key];
|
|
753
|
+
}
|
|
754
|
+
return v.kept ? kept : [this.keep(v, by), ...kept];
|
|
755
|
+
}
|
|
756
|
+
/** Who dropped a pin of view `id`, or null when that is not known (or there is no pin). */
|
|
757
|
+
authorOf(id, mode, kind, key) {
|
|
758
|
+
const set = this.authors.get(id)?.[mode]?.[kind === "node" ? "nodes" : "clusters"];
|
|
759
|
+
return set && Object.hasOwn(set, key) ? set[key] : null;
|
|
760
|
+
}
|
|
761
|
+
forget(id) {
|
|
762
|
+
this.views.delete(id);
|
|
763
|
+
this.memory.delete(id);
|
|
764
|
+
this.authors.delete(id);
|
|
765
|
+
this.seenAt.delete(id);
|
|
766
|
+
}
|
|
767
|
+
/** View `v` saved for everyone. Its links are kept with it: a target linked to it by a click,
|
|
768
|
+
* while nobody kept it (`openTarget`), has a view of its own now. */
|
|
769
|
+
keep(v, by) {
|
|
770
|
+
v.kept = true;
|
|
771
|
+
this.seenAt.delete(v.id);
|
|
772
|
+
return { id: v.id, what: "kept", by };
|
|
773
|
+
}
|
|
774
|
+
/** Lay a board out afresh: its pins go (in both ways of clustering) and its layout's memory with
|
|
775
|
+
* them; its folds and hidden levels stay. */
|
|
776
|
+
relayout(id, by) {
|
|
777
|
+
const v = this.find(id);
|
|
778
|
+
if (typeof v === "string")
|
|
779
|
+
return v;
|
|
780
|
+
v.pins = emptyLayouts();
|
|
781
|
+
this.memory.delete(id);
|
|
782
|
+
this.authors.delete(id);
|
|
783
|
+
return { id, what: "relayout", by };
|
|
784
|
+
}
|
|
785
|
+
memoryOf(id, mode) {
|
|
786
|
+
return this.memory.get(id)?.[mode] ?? null;
|
|
787
|
+
}
|
|
788
|
+
/** A page laid the board out for a set the memory was not made for, starting from the memory it
|
|
789
|
+
* had (`base`, the set that memory was made for, or null): the new memory is taken when nobody
|
|
790
|
+
* handed another over since. `current` is what the memory is after. */
|
|
791
|
+
setMemory(id, mode, base, next) {
|
|
792
|
+
const v = this.find(id);
|
|
793
|
+
if (typeof v === "string")
|
|
794
|
+
return v;
|
|
795
|
+
const now = this.memoryOf(id, mode);
|
|
796
|
+
if ((now?.set ?? null) !== base)
|
|
797
|
+
return { accepted: false, current: now };
|
|
798
|
+
const m = this.memory.get(id) ?? {};
|
|
799
|
+
m[mode] = next;
|
|
800
|
+
this.memory.set(id, m);
|
|
801
|
+
return { accepted: true, current: next };
|
|
802
|
+
}
|
|
803
|
+
/** A target's view linked to another target's name: the target was moved (HistorySpec.md), and its
|
|
804
|
+
* view goes with it, its focus too, the view's other targets staying; the view keeps its id, so
|
|
805
|
+
* that a link to it keeps working. Its scope names the new name already (server/views.ts,
|
|
806
|
+
* `moveDeclaration`), as every view's does. A target with no view has none under its new name
|
|
807
|
+
* either, and one linked to a view nobody keeps keeps that link. */
|
|
808
|
+
retarget(from, to, by) {
|
|
809
|
+
const v = this.viewOf(from);
|
|
810
|
+
if (!v || this.viewOf(to))
|
|
811
|
+
return [];
|
|
812
|
+
v.targets = v.targets.map((t) => (t === from ? to : t));
|
|
813
|
+
if (v.focus === from)
|
|
814
|
+
v.focus = to;
|
|
815
|
+
return [{ id: v.id, what: "linked", by, target: to }];
|
|
816
|
+
}
|
|
817
|
+
/** The links as the review's targets say (ViewSpec decision 29): no view linked to what is no
|
|
818
|
+
* target, and a target that a move brought from another name (`formerly`, its names before, oldest
|
|
819
|
+
* first) takes over that name's view where it has none and that name is no target now. A target
|
|
820
|
+
* unmarked is unlinked alone: its view stays, its scope, arrangement, focus and other targets as
|
|
821
|
+
* they were. Marking a target makes no view and links none (the owner's decision of 2026-10-12):
|
|
822
|
+
* it has none until a click on it gives it a temporary one (`openTarget`), or someone links it. */
|
|
823
|
+
syncTargets(targets, formerly = () => undefined) {
|
|
824
|
+
const out = [];
|
|
825
|
+
const want = new Set(targets);
|
|
826
|
+
for (const t of targets) {
|
|
827
|
+
if (this.viewOf(t))
|
|
828
|
+
continue;
|
|
829
|
+
const from = [...(formerly(t) ?? [])].reverse().find((w) => !want.has(w) && this.viewOf(w));
|
|
830
|
+
if (from)
|
|
831
|
+
out.push(...this.retarget(from, t, null));
|
|
832
|
+
}
|
|
833
|
+
for (const v of [...this.views.values()])
|
|
834
|
+
for (const t of v.targets ?? [])
|
|
835
|
+
if (!want.has(t)) {
|
|
836
|
+
this.drop(v, t);
|
|
837
|
+
out.push({ id: v.id, what: "unlinked", by: null, target: t });
|
|
838
|
+
}
|
|
839
|
+
return out;
|
|
840
|
+
}
|
|
841
|
+
/** The views pages have open now (presence says which: each page's tabs). */
|
|
842
|
+
seen(ids, now = Date.now()) {
|
|
843
|
+
for (const id of ids)
|
|
844
|
+
if (this.views.get(id)?.kept === false)
|
|
845
|
+
this.seenAt.set(id, now);
|
|
846
|
+
}
|
|
847
|
+
/** Views nobody keeps and no page has had open for `grace` ms go, and the links of targets to them
|
|
848
|
+
* with them: those targets have no view again (`openTarget`). */
|
|
849
|
+
sweep(now = Date.now(), grace = 8000) {
|
|
850
|
+
const out = [];
|
|
851
|
+
for (const v of [...this.views.values()]) {
|
|
852
|
+
if (v.kept || now - (this.seenAt.get(v.id) ?? 0) < grace)
|
|
853
|
+
continue;
|
|
854
|
+
this.forget(v.id);
|
|
855
|
+
out.push({ id: v.id, what: "gone", by: null, ...(v.targets?.length ? { targets: [...v.targets] } : {}) });
|
|
856
|
+
}
|
|
857
|
+
return out;
|
|
858
|
+
}
|
|
859
|
+
}
|