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,156 @@
|
|
|
1
|
+
// From a project to what the page loads: extraction (or a saved one), then the view, both cached
|
|
2
|
+
// in the project's cache directory by the extraction's fingerprint (server/dirs.ts); the memory of
|
|
3
|
+
// the layout last served is the site's.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { gzipSync } from "node:zlib";
|
|
9
|
+
import { LAYOUT_VERSION } from "../shared/graph.js";
|
|
10
|
+
import { cachedExtraction, extract, loadSaved, prune } from "./extract.js";
|
|
11
|
+
import { memoryOf } from "../shared/layout.js";
|
|
12
|
+
import { buildViewOffThread } from "./view.js";
|
|
13
|
+
import { cacheHome } from "./dirs.js";
|
|
14
|
+
/** the tool's own folder, `src/`; the repository is above it */
|
|
15
|
+
export const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
|
+
export const REPO = resolve(SRC, "..");
|
|
17
|
+
export const WEB = join(SRC, "web");
|
|
18
|
+
/** The page's bundle made when the package was built (scripts/build.mjs), beside the page: an
|
|
19
|
+
* installed package serves it as it is, and has no esbuild to make another. A checkout has none. */
|
|
20
|
+
export const PREBUILT = join(WEB, "bundle");
|
|
21
|
+
const prebuilt = existsSync(join(PREBUILT, "app.js"));
|
|
22
|
+
/** The page's bundle under a cache root: one folder per checkout of the tool, so that two never
|
|
23
|
+
* write over each other's. */
|
|
24
|
+
export function bundleDirIn(cacheRoot) {
|
|
25
|
+
return join(cacheRoot, "web", createHash("sha1").update(SRC).digest("hex").slice(0, 8));
|
|
26
|
+
}
|
|
27
|
+
let bundle = null;
|
|
28
|
+
/** Where the page's bundle is made and served from: set once at start, from `--cache`. */
|
|
29
|
+
export function useBundleDir(dir) {
|
|
30
|
+
bundle = dir;
|
|
31
|
+
}
|
|
32
|
+
export function bundleDir() {
|
|
33
|
+
if (prebuilt)
|
|
34
|
+
return PREBUILT;
|
|
35
|
+
return (bundle ??= bundleDirIn(cacheHome()));
|
|
36
|
+
}
|
|
37
|
+
/** Bumped when the page data derived from an extraction changes shape or layout, so cached
|
|
38
|
+
* views from an older server are rebuilt rather than served. 7: a notation unit's statement is
|
|
39
|
+
* its command's text. 8: notation units carry their Lean constant, display form, what they
|
|
40
|
+
* interpret and their rules. 9: a node's hash is its version, beside the kernel's
|
|
41
|
+
* (server/versions.ts). */
|
|
42
|
+
const VIEW_VERSION = 9;
|
|
43
|
+
function loadMemory(siteDir) {
|
|
44
|
+
try {
|
|
45
|
+
const mem = JSON.parse(readFileSync(join(siteDir, "layout-memory.json"), "utf8"));
|
|
46
|
+
return mem.version === LAYOUT_VERSION ? mem : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function saveMemory(siteDir, fingerprint, meta, before) {
|
|
53
|
+
const mem = { version: LAYOUT_VERSION, fingerprint, layouts: {}, ...(before !== undefined ? { before } : {}) };
|
|
54
|
+
for (const mode of ["namespace", "module"])
|
|
55
|
+
mem.layouts[mode] = memoryOf(meta.layouts[mode], meta.nodes);
|
|
56
|
+
mkdirSync(siteDir, { recursive: true });
|
|
57
|
+
writeFileSync(join(siteDir, "layout-memory.json"), JSON.stringify(mem));
|
|
58
|
+
}
|
|
59
|
+
export async function prepare(project, dirs, saved, log, opts = {}) {
|
|
60
|
+
// `reuse`: lay out an extraction already held, without reading the build (which may be
|
|
61
|
+
// half-written right now); `extraction`: one in hand (the usage pass's, merged); `provisional`:
|
|
62
|
+
// the graph waits for its usage pass; `completes`: the fingerprint of the build's extraction that
|
|
63
|
+
// this graph, the usage pass's merged, completes
|
|
64
|
+
const held = opts.extraction ?? (opts.reuse ? cachedExtraction(dirs.cache, opts.reuse) : null);
|
|
65
|
+
if (opts.reuse && !held)
|
|
66
|
+
throw new Error("the graph on screen is no longer cached; it is laid out afresh at the next build");
|
|
67
|
+
const ex = held ?? (saved ? loadSaved(saved) : await extract(project, dirs, log));
|
|
68
|
+
if (!opts.extraction)
|
|
69
|
+
log(ex.cached ? `extraction ${ex.fingerprint} reused` : `extraction ${ex.fingerprint} done`);
|
|
70
|
+
const usage = ex.graph.usage ?? null;
|
|
71
|
+
const viewDir = join(dirs.cache, "view");
|
|
72
|
+
mkdirSync(viewDir, { recursive: true });
|
|
73
|
+
const metaFile = join(viewDir, `${ex.fingerprint}-v${VIEW_VERSION}.meta.json.gz`);
|
|
74
|
+
const edgeFile = join(viewDir, `${ex.fingerprint}-v${VIEW_VERSION}.edges.bin.gz`);
|
|
75
|
+
const memory = opts.freshLayout ? null : loadMemory(dirs.site);
|
|
76
|
+
// a cached view is only right if it is the one this site last served (a cache may outlive its
|
|
77
|
+
// site, or serve another): otherwise lay out again, from the memory of what was last served
|
|
78
|
+
const current = memory !== null && memory.fingerprint === ex.fingerprint;
|
|
79
|
+
if (!opts.freshLayout && current && existsSync(metaFile) && existsSync(edgeFile)) {
|
|
80
|
+
return { fingerprint: ex.fingerprint, cached: ex.cached, metaGz: readFileSync(metaFile), edgesGz: readFileSync(edgeFile), usage };
|
|
81
|
+
}
|
|
82
|
+
// the usage pass completing the build last laid out: against what that provisional layout was
|
|
83
|
+
// laid out against, so that the build's graph is laid out as if the notation had come with it
|
|
84
|
+
const completing = memory !== null && opts.completes !== undefined && memory.fingerprint === opts.completes && memory.before !== undefined;
|
|
85
|
+
const against = completing ? (memory.before ?? null) : (memory?.layouts ?? null);
|
|
86
|
+
const view = await buildViewOffThread(project, ex, log, against);
|
|
87
|
+
saveMemory(dirs.site, ex.fingerprint, view.meta, opts.provisional ? against : undefined);
|
|
88
|
+
const metaGz = gzipSync(JSON.stringify(view.meta));
|
|
89
|
+
const edgesGz = gzipSync(Buffer.from(view.edges.buffer, view.edges.byteOffset, view.edges.byteLength));
|
|
90
|
+
writeFileSync(metaFile, metaGz);
|
|
91
|
+
writeFileSync(edgeFile, edgesGz);
|
|
92
|
+
prune(viewDir, /\.meta\.json\.gz$/, 3);
|
|
93
|
+
prune(viewDir, /\.edges\.bin\.gz$/, 3);
|
|
94
|
+
return { fingerprint: ex.fingerprint, cached: ex.cached, metaGz, edgesGz, usage };
|
|
95
|
+
}
|
|
96
|
+
function readdirRec(dir) {
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
99
|
+
const p = join(dir, ent.name);
|
|
100
|
+
if (ent.isDirectory())
|
|
101
|
+
out.push(...readdirRec(p));
|
|
102
|
+
else
|
|
103
|
+
out.push(p);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/** The page is bundled when its sources are newer than the bundle, so a checkout runs without a
|
|
108
|
+
* separate build step; an installed package serves the bundle it was built with (`PREBUILT`).
|
|
109
|
+
* What the page shares with the server (shared/) is its source too. Bundled into a folder of its
|
|
110
|
+
* own beside the bundle's, then moved into it a file at a time: a server reading the bundle
|
|
111
|
+
* meanwhile never reads a file half written. */
|
|
112
|
+
export async function ensureBundle() {
|
|
113
|
+
if (prebuilt)
|
|
114
|
+
return;
|
|
115
|
+
const dist = bundleDir();
|
|
116
|
+
const out = join(dist, "app.js");
|
|
117
|
+
let newest = 0;
|
|
118
|
+
for (const f of [...readdirRec(join(WEB, "src")), ...readdirRec(join(SRC, "shared"))])
|
|
119
|
+
newest = Math.max(newest, statSync(f).mtimeMs);
|
|
120
|
+
const beside = ["layout.js", "edges.js", "pdfworker.js"].every((f) => existsSync(join(dist, f)));
|
|
121
|
+
if (existsSync(out) && beside && statSync(out).mtimeMs >= newest)
|
|
122
|
+
return;
|
|
123
|
+
mkdirSync(dirname(dist), { recursive: true });
|
|
124
|
+
const tmp = mkdtempSync(`${dist}.tmp-`);
|
|
125
|
+
try {
|
|
126
|
+
await bundlePage(tmp);
|
|
127
|
+
mkdirSync(dist, { recursive: true });
|
|
128
|
+
for (const f of readdirSync(tmp))
|
|
129
|
+
renameSync(join(tmp, f), join(dist, f));
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** The page's bundle, from `web/src/` of a checkout, into `outdir`: by `ensureBundle` at start, and
|
|
136
|
+
* by scripts/build.mjs into the package. Beside the page, the workers that lay out the Time
|
|
137
|
+
* Machine's past graphs (web/src/layoutworker.ts) and choose the overview's edges
|
|
138
|
+
* (web/src/edgeworker.ts) off the page's thread. */
|
|
139
|
+
export async function bundlePage(outdir, sourcemap = true) {
|
|
140
|
+
const esbuild = await import("esbuild");
|
|
141
|
+
const srcDir = join(WEB, "src");
|
|
142
|
+
await esbuild.build({
|
|
143
|
+
// pdf.js's worker, which reads the library's PDFs for the reference window (AttachmentSpec
|
|
144
|
+
// decision 11), is bundled beside the page's own workers and served as they are: no outside
|
|
145
|
+
// service. Its legacy build, as the page's, for browsers that lack what the modern one uses
|
|
146
|
+
entryPoints: { app: join(srcDir, "main.ts"), layout: join(srcDir, "layoutworker.ts"), edges: join(srcDir, "edgeworker.ts"), pdfworker: fileURLToPath(import.meta.resolve("pdfjs-dist/legacy/build/pdf.worker.min.mjs")) },
|
|
147
|
+
bundle: true,
|
|
148
|
+
format: "esm",
|
|
149
|
+
// the owner reads in Safari: what Safari 16 lacks of the syntax (pdf.js's class static blocks; a
|
|
150
|
+
// regular expression's lookbehind, read at run time instead) is lowered
|
|
151
|
+
target: ["es2022", "safari16"],
|
|
152
|
+
sourcemap,
|
|
153
|
+
outdir,
|
|
154
|
+
logLevel: "warning",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// What the server reads of a PDF (AttachmentSpec, "Implementation notes"): poppler's tools, run on
|
|
2
|
+
// the file in the library's store — `pdfinfo` for its title, its pages and their sizes,
|
|
3
|
+
// `pdftotext -bbox-layout` for its words and where each is on its page, `pdftoppm` for a page as an
|
|
4
|
+
// image and for a clip, a region of a page (what a note will carry, decision 12). The words are read
|
|
5
|
+
// once per file, every page at a time, and kept; images are kept by file, page, region and width,
|
|
6
|
+
// so that the same request is answered from the disk.
|
|
7
|
+
//
|
|
8
|
+
// Every place is in the page's own fractions (0–1, from its top left), as a link's region is
|
|
9
|
+
// (shared/library.ts), so that nothing depends on how large the page is drawn. A PDF is untrusted
|
|
10
|
+
// input: the tools are run without a shell, on a path of the server's own, with a time limit, and at
|
|
11
|
+
// most two at once.
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
16
|
+
import { wordsText } from "../shared/library.js";
|
|
17
|
+
/** the longest each tool may run, in ms */
|
|
18
|
+
const INFO_MS = 20_000;
|
|
19
|
+
const TEXT_MS = 120_000;
|
|
20
|
+
const IMAGE_MS = 60_000;
|
|
21
|
+
/** the most the tools' output is read, in bytes: the words of a long paper, a large page */
|
|
22
|
+
const MAX_OUT = 256 * 1024 * 1024;
|
|
23
|
+
/** how many of the tools run at once */
|
|
24
|
+
const AT_ONCE = 2;
|
|
25
|
+
let running = 0;
|
|
26
|
+
const waiting = [];
|
|
27
|
+
/** Run a tool, at most AT_ONCE at a time; its standard output, or why it failed. */
|
|
28
|
+
async function run(cmd, args, ms) {
|
|
29
|
+
if (running >= AT_ONCE)
|
|
30
|
+
await new Promise((ok) => waiting.push(ok));
|
|
31
|
+
running++;
|
|
32
|
+
try {
|
|
33
|
+
return await new Promise((ok, no) => {
|
|
34
|
+
execFile(cmd, args, { timeout: ms, maxBuffer: MAX_OUT, encoding: "buffer", windowsHide: true }, (err, out, errOut) => {
|
|
35
|
+
if (err)
|
|
36
|
+
no(new Error(`${cmd} failed: ${(errOut?.toString("utf8").trim().split("\n").pop() ?? "") || err.message}`));
|
|
37
|
+
else
|
|
38
|
+
ok(out);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
running--;
|
|
44
|
+
waiting.shift()?.();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** A PDF's own title (where it has one that says something), its pages, and each page's size. */
|
|
48
|
+
export async function pdfInfo(file) {
|
|
49
|
+
const first = (await run("pdfinfo", ["-enc", "UTF-8", file], INFO_MS)).toString("utf8");
|
|
50
|
+
const pages = Number(/^Pages:[ \t]+(\d+)/m.exec(first)?.[1] ?? "0");
|
|
51
|
+
const title = /^Title:[ \t]*(.*)$/m.exec(first)?.[1]?.trim() || null;
|
|
52
|
+
const sizes = [];
|
|
53
|
+
if (pages > 0) {
|
|
54
|
+
// every page's size and rotation, so that a region in a page's fractions is read on that page
|
|
55
|
+
const all = (await run("pdfinfo", ["-f", "1", "-l", String(pages), file], INFO_MS)).toString("utf8");
|
|
56
|
+
const size = new Map();
|
|
57
|
+
const rot = new Map();
|
|
58
|
+
for (const m of all.matchAll(/^Page\s+(\d+) size:\s+([\d.]+) x ([\d.]+) pts/gm))
|
|
59
|
+
size.set(Number(m[1]), { w: Number(m[2]), h: Number(m[3]) });
|
|
60
|
+
for (const m of all.matchAll(/^Page\s+(\d+) rot:\s+(\d+)/gm))
|
|
61
|
+
rot.set(Number(m[1]), Number(m[2]));
|
|
62
|
+
for (let p = 1; p <= pages; p++) {
|
|
63
|
+
const s = size.get(p) ?? size.get(1) ?? { w: 612, h: 792 };
|
|
64
|
+
sizes.push((rot.get(p) ?? 0) % 180 ? { w: s.h, h: s.w } : s);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { title: usefulTitle(title), pages, sizes };
|
|
68
|
+
}
|
|
69
|
+
/** A title a PDF gives itself that says something: not empty, not a file name or a word-processor's
|
|
70
|
+
* placeholder, nor only an identifier (an arXiv paper's, a DOI), which names a paper without saying
|
|
71
|
+
* what it is. */
|
|
72
|
+
export function usefulTitle(t) {
|
|
73
|
+
if (!t)
|
|
74
|
+
return null;
|
|
75
|
+
const s = t.replace(/\s+/g, " ").trim();
|
|
76
|
+
if (!s || /^(untitled|microsoft word - .*|.*\.(docx?|tex|dvi|pdf))$/i.test(s) || IDENTIFIER.test(s))
|
|
77
|
+
return null;
|
|
78
|
+
return s.length > 300 ? s.slice(0, 299) + "…" : s;
|
|
79
|
+
}
|
|
80
|
+
/** A paper's identifier alone: arXiv's, new style (`1910.09336`, `1910.09336v1`) or old
|
|
81
|
+
* (`math/0211159v1`, `math.GT/0309136`), maybe as `arXiv:…` and with its category after it in
|
|
82
|
+
* brackets, as arXiv stamps it; or a DOI, bare, as `doi:…` or as its address. */
|
|
83
|
+
const IDENTIFIER = /^(?:(?:arxiv:\s*)?(?:\d{4}\.\d{4,5}|[a-z-]+(?:\.[a-z-]+)?\/\d{7})(?:v\d+)?(?:\s*\[[^\]]+\])?|(?:doi:\s*|https?:\/\/(?:dx\.)?doi\.org\/)?10\.\d{4,9}\/\S+)$/i;
|
|
84
|
+
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
85
|
+
const unescapeXml = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (_, e) => e[0] === "#" ? String.fromCodePoint(e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : Number(e.slice(1))) : ENTITIES[e.toLowerCase()] ?? "");
|
|
86
|
+
const frac = (v) => Math.round(Math.min(1, Math.max(0, v)) * 1e4) / 1e4;
|
|
87
|
+
/** `pdftotext -bbox-layout`'s XHTML, read: every page's words with their boxes. */
|
|
88
|
+
export function parseBboxLayout(xhtml) {
|
|
89
|
+
const out = [];
|
|
90
|
+
const pageRe = /<page width="([\d.]+)" height="([\d.]+)">([\s\S]*?)<\/page>/g;
|
|
91
|
+
for (let m = pageRe.exec(xhtml), p = 1; m; m = pageRe.exec(xhtml), p++) {
|
|
92
|
+
const width = Number(m[1]), height = Number(m[2]);
|
|
93
|
+
const words = [];
|
|
94
|
+
const lines = [];
|
|
95
|
+
for (const line of m[3].matchAll(/<line[^>]*>([\s\S]*?)<\/line>/g)) {
|
|
96
|
+
lines.push(words.length);
|
|
97
|
+
for (const w of line[1].matchAll(/<word xMin="([\d.-]+)" yMin="([\d.-]+)" xMax="([\d.-]+)" yMax="([\d.-]+)">([\s\S]*?)<\/word>/g)) {
|
|
98
|
+
// a glyph its font maps to no character comes as a control character: no text
|
|
99
|
+
const text = unescapeXml(w[5]).replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
|
100
|
+
if (text)
|
|
101
|
+
words.push([text, frac(Number(w[1]) / width), frac(Number(w[2]) / height), frac(Number(w[3]) / width), frac(Number(w[4]) / height)]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
out.push({ page: p, width, height, words, lines });
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/** A page's text as its words make it: a line's words joined by spaces, lines by line breaks; and
|
|
109
|
+
* where each word starts in it. */
|
|
110
|
+
export function pageText(p) {
|
|
111
|
+
return wordsText(p.words, p.lines);
|
|
112
|
+
}
|
|
113
|
+
/** Find `q` in every page's text: case and runs of white space read loosely; at most `most`
|
|
114
|
+
* matches, in page order. */
|
|
115
|
+
export function searchWords(pages, q, most = 100) {
|
|
116
|
+
const want = q.replace(/\s+/g, " ").trim().toLowerCase();
|
|
117
|
+
const matches = [];
|
|
118
|
+
if (!want)
|
|
119
|
+
return { matches, more: false };
|
|
120
|
+
for (const p of pages) {
|
|
121
|
+
const { text, starts } = pageText(p);
|
|
122
|
+
const flat = text.replace(/\n/g, " ").toLowerCase();
|
|
123
|
+
for (let at = flat.indexOf(want); at >= 0; at = flat.indexOf(want, at + 1)) {
|
|
124
|
+
if (matches.length >= most)
|
|
125
|
+
return { matches, more: true };
|
|
126
|
+
const end = at + want.length;
|
|
127
|
+
const first = Math.max(0, upper(starts, at));
|
|
128
|
+
const last = Math.max(first, upper(starts, end - 1));
|
|
129
|
+
const boxes = p.words.slice(first, last + 1).map(([, x0, y0, x1, y1]) => [x0, y0, x1, y1]);
|
|
130
|
+
matches.push({ page: p.page, boxes, text: text.slice(at, end), before: text.slice(Math.max(0, at - 60), at).replace(/\s+/g, " "), after: text.slice(end, end + 60).replace(/\s+/g, " ") });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { matches, more: false };
|
|
134
|
+
}
|
|
135
|
+
/** The index of the last start at or before `at`. */
|
|
136
|
+
function upper(starts, at) {
|
|
137
|
+
let lo = 0, hi = starts.length - 1, best = -1;
|
|
138
|
+
while (lo <= hi) {
|
|
139
|
+
const mid = (lo + hi) >> 1;
|
|
140
|
+
if (starts[mid] <= at) {
|
|
141
|
+
best = mid;
|
|
142
|
+
lo = mid + 1;
|
|
143
|
+
}
|
|
144
|
+
else
|
|
145
|
+
hi = mid - 1;
|
|
146
|
+
}
|
|
147
|
+
return best;
|
|
148
|
+
}
|
|
149
|
+
/** The widths a page or a clip is drawn at: a few, so that what is kept stays small. */
|
|
150
|
+
const WIDTHS = [400, 800, 1200, 1600, 2000];
|
|
151
|
+
export const clampWidth = (w) => WIDTHS.find((x) => x >= w) ?? WIDTHS[WIDTHS.length - 1];
|
|
152
|
+
/** A PDF's words, pages and images, kept in `cacheDir/<sha>/`. */
|
|
153
|
+
export class PdfReader {
|
|
154
|
+
cacheDir;
|
|
155
|
+
words = new Map();
|
|
156
|
+
infos = new Map();
|
|
157
|
+
constructor(cacheDir) {
|
|
158
|
+
this.cacheDir = cacheDir;
|
|
159
|
+
}
|
|
160
|
+
dir(sha) {
|
|
161
|
+
const d = join(this.cacheDir, sha);
|
|
162
|
+
mkdirSync(d, { recursive: true });
|
|
163
|
+
return d;
|
|
164
|
+
}
|
|
165
|
+
/** What `pdfinfo` says of it, once per run. */
|
|
166
|
+
info(sha, file) {
|
|
167
|
+
let p = this.infos.get(sha);
|
|
168
|
+
if (!p) {
|
|
169
|
+
p = pdfInfo(file);
|
|
170
|
+
p.catch(() => this.infos.delete(sha));
|
|
171
|
+
this.infos.set(sha, p);
|
|
172
|
+
}
|
|
173
|
+
return p;
|
|
174
|
+
}
|
|
175
|
+
/** Every page's words: read once, kept on disk (gzipped JSON) and in memory for the last few. */
|
|
176
|
+
allWords(sha, file) {
|
|
177
|
+
let p = this.words.get(sha);
|
|
178
|
+
if (!p) {
|
|
179
|
+
p = (async () => {
|
|
180
|
+
const kept = join(this.dir(sha), "words.json.gz");
|
|
181
|
+
if (existsSync(kept)) {
|
|
182
|
+
try {
|
|
183
|
+
return JSON.parse(gunzipSync(readFileSync(kept)).toString("utf8"));
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// a file cut short: read again
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const xhtml = (await run("pdftotext", ["-bbox-layout", "-enc", "UTF-8", file, "-"], TEXT_MS)).toString("utf8");
|
|
190
|
+
const pages = parseBboxLayout(xhtml);
|
|
191
|
+
writeAtomic(kept, gzipSync(JSON.stringify(pages)));
|
|
192
|
+
return pages;
|
|
193
|
+
})();
|
|
194
|
+
p.catch(() => this.words.delete(sha));
|
|
195
|
+
if (this.words.size >= 8)
|
|
196
|
+
this.words.delete(this.words.keys().next().value);
|
|
197
|
+
this.words.set(sha, p);
|
|
198
|
+
}
|
|
199
|
+
return p;
|
|
200
|
+
}
|
|
201
|
+
/** A page drawn as a PNG `width` pixels wide (one of a few widths). */
|
|
202
|
+
async page(sha, file, page, width) {
|
|
203
|
+
const w = clampWidth(width);
|
|
204
|
+
const kept = join(this.dir(sha), `page-${page}-w${w}.png`);
|
|
205
|
+
if (existsSync(kept))
|
|
206
|
+
return readFileSync(kept);
|
|
207
|
+
const tmp = join(this.dir(sha), `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
208
|
+
try {
|
|
209
|
+
await run("pdftoppm", ["-png", "-f", String(page), "-l", String(page), "-singlefile", "-scale-to-x", String(w), "-scale-to-y", "-1", file, tmp], IMAGE_MS);
|
|
210
|
+
renameSync(`${tmp}.png`, kept);
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
rmSync(`${tmp}.png`, { force: true });
|
|
214
|
+
}
|
|
215
|
+
return readFileSync(kept);
|
|
216
|
+
}
|
|
217
|
+
/** A region of a page drawn as a PNG about `width` pixels wide: what a clip shows. */
|
|
218
|
+
async clip(sha, file, page, size, region, width) {
|
|
219
|
+
const w = clampWidth(width);
|
|
220
|
+
const r = region.map((x) => Math.round(x * 1e4) / 1e4);
|
|
221
|
+
const kept = join(this.dir(sha), `clip-${page}-${r.join("_")}-w${w}.png`);
|
|
222
|
+
if (existsSync(kept))
|
|
223
|
+
return readFileSync(kept);
|
|
224
|
+
// the resolution at which the region is `w` pixels wide, within reason
|
|
225
|
+
const dpi = Math.min(600, Math.max(36, (w / ((r[2] - r[0]) * size.w)) * 72));
|
|
226
|
+
const px = (v, of) => Math.round((v * of * dpi) / 72);
|
|
227
|
+
const x = px(r[0], size.w), y = px(r[1], size.h);
|
|
228
|
+
const W = Math.max(1, px(r[2], size.w) - x), H = Math.max(1, px(r[3], size.h) - y);
|
|
229
|
+
const tmp = join(this.dir(sha), `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
230
|
+
try {
|
|
231
|
+
await run("pdftoppm", ["-png", "-f", String(page), "-l", String(page), "-singlefile", "-r", dpi.toFixed(2), "-x", String(x), "-y", String(y), "-W", String(W), "-H", String(H), file, tmp], IMAGE_MS);
|
|
232
|
+
renameSync(`${tmp}.png`, kept);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
rmSync(`${tmp}.png`, { force: true });
|
|
236
|
+
}
|
|
237
|
+
return readFileSync(kept);
|
|
238
|
+
}
|
|
239
|
+
/** What is kept of a file goes (its bytes purged). */
|
|
240
|
+
forget(sha) {
|
|
241
|
+
this.words.delete(sha);
|
|
242
|
+
this.infos.delete(sha);
|
|
243
|
+
rmSync(join(this.cacheDir, sha), { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** Write a file whole or not at all: to a name of its own first, then renamed into place. */
|
|
247
|
+
export function writeAtomic(path, data) {
|
|
248
|
+
const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
|
|
249
|
+
writeFileSync(tmp, data);
|
|
250
|
+
renameSync(tmp, path);
|
|
251
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Who is here (CollabSpec decision 4): one entry per open page, kept by a heartbeat every ten
|
|
2
|
+
// seconds and dropped when the page's event stream closes or its heartbeats stop. Every change goes
|
|
3
|
+
// out to every page as the whole list — a few readers, so a few hundred bytes.
|
|
4
|
+
//
|
|
5
|
+
// A page's entry is its event stream's (server/live.ts): the server names each stream with a public
|
|
6
|
+
// id, the one every page is told, and hands the page alone a secret its heartbeats carry. So no page
|
|
7
|
+
// can speak for another, rename it or make it leave, and there are no more entries than streams.
|
|
8
|
+
//
|
|
9
|
+
// An agent (AgentSpec decision 1) has no page and no event stream: one entry per agent name, kept
|
|
10
|
+
// by its requests. An agent thinks between them for longer than a page waits between heartbeats, so
|
|
11
|
+
// it is shown away after a minute without one and gone after three, or when it says it leaves. At
|
|
12
|
+
// most MAX_AGENTS are listed: a name beyond them is served, and listed once one goes.
|
|
13
|
+
//
|
|
14
|
+
// An agent with a token of its own (server/tokens.ts) is one entry by its token, not by the name it
|
|
15
|
+
// acts under, so that nobody else can speak for it by giving that name; and it is listed as long as
|
|
16
|
+
// its token is in force (AgentSpec decision 8), with its state as server/talk.ts infers it from what
|
|
17
|
+
// it does — listening, thinking, working or offline — and when it was last seen. It is shown away
|
|
18
|
+
// only while offline.
|
|
19
|
+
//
|
|
20
|
+
// A private page (CollabSpec decision 8) is an entry with nothing in it — no name, no place — and is
|
|
21
|
+
// never listed: every page is told how many there are, and nothing else of them. Its stream is named
|
|
22
|
+
// afresh when it goes private and when it shows itself again (server/live.ts), so its entries under
|
|
23
|
+
// either id say nothing of the other.
|
|
24
|
+
//
|
|
25
|
+
// What an agent asks to follow the people it helps (GET /api/agent/companion) waits for the list to
|
|
26
|
+
// change: `rev` counts the changes that matter to it — someone came, went, moved, went idle, an
|
|
27
|
+
// agent went offline or came back — and leaves out a camera that moved, which a page reports
|
|
28
|
+
// every second or two while one pans, and what an agent that is not offline does from one request
|
|
29
|
+
// to the next.
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
31
|
+
/** a page not heard from for this long has gone */
|
|
32
|
+
const GONE_MS = 35_000;
|
|
33
|
+
/** an agent without a token not heard from for this long is away, and then gone */
|
|
34
|
+
const AGENT_IDLE_MS = 60_000;
|
|
35
|
+
const AGENT_GONE_MS = 180_000;
|
|
36
|
+
/** the most agents listed at once: every name an agent gives is an entry everyone is sent */
|
|
37
|
+
export const MAX_AGENTS = 32;
|
|
38
|
+
/** The entry an agent's requests stand for: one per name; its id, as pages are told it. */
|
|
39
|
+
export function agentSession(name) {
|
|
40
|
+
return createHash("sha1").update(`agent\u0000${name}`).digest("hex");
|
|
41
|
+
}
|
|
42
|
+
/** The entry an agent stands for: its token's, or its name's. */
|
|
43
|
+
export function agentKey(who) {
|
|
44
|
+
const w = typeof who === "string" ? { name: who } : who;
|
|
45
|
+
return w.token ? createHash("sha1").update(`agent token\u0000${w.token}`).digest("hex") : agentSession(w.name);
|
|
46
|
+
}
|
|
47
|
+
export class Presence {
|
|
48
|
+
sessions = new Map();
|
|
49
|
+
/** agents with a token in force, by the token's id */
|
|
50
|
+
tokens = new Map();
|
|
51
|
+
/** pages whose event stream closed, and when: a heartbeat sent as one closes (a reload) comes
|
|
52
|
+
* after its stream ended, and would keep it on the list — and the views it had open alive
|
|
53
|
+
* (server/views.ts) — until it times out */
|
|
54
|
+
closed = new Map();
|
|
55
|
+
send;
|
|
56
|
+
/** the changes an agent following people waits for (GET /api/agent/companion), counted */
|
|
57
|
+
rev = 0;
|
|
58
|
+
lastSig = "";
|
|
59
|
+
waiters = new Set();
|
|
60
|
+
constructor(send) {
|
|
61
|
+
this.send = send;
|
|
62
|
+
setInterval(() => this.expire(), 5000).unref();
|
|
63
|
+
}
|
|
64
|
+
/** The list changed: every page is sent it, and an agent waiting for a change that matters to
|
|
65
|
+
* it is told. */
|
|
66
|
+
notify() {
|
|
67
|
+
const list = this.list();
|
|
68
|
+
this.send(list);
|
|
69
|
+
// neither a camera that pans nor what an agent does while online is a move; an agent going
|
|
70
|
+
// offline or coming back is, and so is a private reader coming or going
|
|
71
|
+
const sig = JSON.stringify([this.privateCount(), list.map((p) => ({ ...p, view: p.view ? { ...p.view, cam: null } : null, talk: p.talk ? p.talk.state === "offline" : undefined }))]);
|
|
72
|
+
if (sig === this.lastSig)
|
|
73
|
+
return;
|
|
74
|
+
this.lastSig = sig;
|
|
75
|
+
this.rev++;
|
|
76
|
+
for (const w of [...this.waiters])
|
|
77
|
+
w();
|
|
78
|
+
}
|
|
79
|
+
/** Resolves once `rev` is not `since` any more, or after `ms`. */
|
|
80
|
+
waitChange(since, ms) {
|
|
81
|
+
if (since !== this.rev || ms <= 0)
|
|
82
|
+
return Promise.resolve();
|
|
83
|
+
return new Promise((done) => {
|
|
84
|
+
const end = () => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
this.waiters.delete(end);
|
|
87
|
+
done();
|
|
88
|
+
};
|
|
89
|
+
const timer = setTimeout(end, ms);
|
|
90
|
+
this.waiters.add(end);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** A page says where it is (by its stream's id); or an agent, with each request (`agent`), where
|
|
94
|
+
* `view` null keeps the view it gave last. `verified`: the page of someone invited by a personal
|
|
95
|
+
* link, whose name is theirs. */
|
|
96
|
+
beat(id, name, view, idle, agent = false, verified = false) {
|
|
97
|
+
if (this.closed.has(id))
|
|
98
|
+
return;
|
|
99
|
+
const was = this.sessions.get(id);
|
|
100
|
+
if (!was && agent && [...this.sessions.values()].filter((e) => e.agent).length >= MAX_AGENTS)
|
|
101
|
+
return;
|
|
102
|
+
const next = { name, view: view ?? was?.view ?? null, idle, seen: Date.now(), agent, ...(verified && name ? { verified: true } : {}) };
|
|
103
|
+
this.sessions.set(id, next);
|
|
104
|
+
const same = was && !was.private && was.name === next.name && was.idle === next.idle && was.agent === next.agent && was.verified === next.verified && JSON.stringify(was.view) === JSON.stringify(next.view);
|
|
105
|
+
if (!same)
|
|
106
|
+
this.notify();
|
|
107
|
+
}
|
|
108
|
+
/** A private page's heartbeat (CollabSpec decision 8): it is here, and that is all that is kept
|
|
109
|
+
* of it — no name, no place, never idle. */
|
|
110
|
+
beatPrivate(id) {
|
|
111
|
+
if (this.closed.has(id))
|
|
112
|
+
return;
|
|
113
|
+
const was = this.sessions.get(id);
|
|
114
|
+
this.sessions.set(id, { name: null, view: null, idle: false, seen: Date.now(), agent: false, private: true });
|
|
115
|
+
if (!was?.private)
|
|
116
|
+
this.notify();
|
|
117
|
+
}
|
|
118
|
+
/** A page named afresh as it went private or showed itself again (server/live.ts): its entry under
|
|
119
|
+
* `old` goes, and comes back under `id` — counted, or listed with its name and place — as one
|
|
120
|
+
* change, so that no list goes out with the page under both ids, or under neither. */
|
|
121
|
+
renamed(old, id, priv, name, view, idle, verified = false) {
|
|
122
|
+
this.sessions.delete(old);
|
|
123
|
+
this.closed.delete(id);
|
|
124
|
+
this.sessions.set(id, priv ? { name: null, view: null, idle: false, seen: Date.now(), agent: false, private: true } : { name, view, idle, seen: Date.now(), agent: false, ...(verified && name ? { verified: true } : {}) });
|
|
125
|
+
this.notify();
|
|
126
|
+
}
|
|
127
|
+
/** How many private pages are here: what every page and the agents' companion are told of them. */
|
|
128
|
+
privateCount() {
|
|
129
|
+
let n = 0;
|
|
130
|
+
for (const e of this.sessions.values())
|
|
131
|
+
if (e.private)
|
|
132
|
+
n++;
|
|
133
|
+
return n;
|
|
134
|
+
}
|
|
135
|
+
/** Where a page that is listed last said it was; null for any other. */
|
|
136
|
+
pageView(id) {
|
|
137
|
+
const e = this.sessions.get(id);
|
|
138
|
+
return e && !e.agent && !e.private ? e.view : null;
|
|
139
|
+
}
|
|
140
|
+
/** An agent's request: it is here, with the view it gave (or the one it gave last). */
|
|
141
|
+
agentBeat(who, view = null) {
|
|
142
|
+
if (typeof who !== "string" && who.token) {
|
|
143
|
+
// its state is server/talk.ts's to say (setTalk); a token that is not listed (ended) is not
|
|
144
|
+
// listed again
|
|
145
|
+
const t = this.tokens.get(who.token);
|
|
146
|
+
if (!t)
|
|
147
|
+
return;
|
|
148
|
+
const before = JSON.stringify(t.view);
|
|
149
|
+
t.view = view ?? t.view;
|
|
150
|
+
if (JSON.stringify(t.view) !== before)
|
|
151
|
+
this.notify();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const key = agentKey(who);
|
|
155
|
+
// an agent that said it leaves is back with its next request: it has no stream to reopen
|
|
156
|
+
this.closed.delete(key);
|
|
157
|
+
this.beat(key, typeof who === "string" ? who : who.name, view, false, true);
|
|
158
|
+
}
|
|
159
|
+
/** What an agent last said it looks at; null when it is not here. */
|
|
160
|
+
agentView(who) {
|
|
161
|
+
if (typeof who !== "string" && who.token)
|
|
162
|
+
return this.tokens.get(who.token)?.view ?? null;
|
|
163
|
+
return this.sessions.get(agentKey(who))?.view ?? null;
|
|
164
|
+
}
|
|
165
|
+
/** A page closed (its event stream ended), or an agent left (its key, agentKey). An agent with a
|
|
166
|
+
* token stays listed: it is no longer anywhere. */
|
|
167
|
+
drop(session) {
|
|
168
|
+
for (const [id, t] of this.tokens)
|
|
169
|
+
if (agentKey({ name: t.name, token: id }) === session) {
|
|
170
|
+
if (t.view) {
|
|
171
|
+
t.view = null;
|
|
172
|
+
this.notify();
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this.closed.set(session, Date.now());
|
|
177
|
+
if (this.sessions.delete(session))
|
|
178
|
+
this.notify();
|
|
179
|
+
}
|
|
180
|
+
/** The tokens in force (server/tokens.ts): each one's agent is listed, in the state `talkOf`
|
|
181
|
+
* says (offline until it is seen), with when its token was last used until it is; an agent whose
|
|
182
|
+
* token ended goes. */
|
|
183
|
+
setTokens(list, talkOf) {
|
|
184
|
+
let changed = false;
|
|
185
|
+
const ids = new Set(list.map((r) => r.id));
|
|
186
|
+
for (const id of [...this.tokens.keys()])
|
|
187
|
+
if (!ids.has(id)) {
|
|
188
|
+
this.tokens.delete(id);
|
|
189
|
+
changed = true;
|
|
190
|
+
}
|
|
191
|
+
for (const r of list) {
|
|
192
|
+
const t = this.tokens.get(r.id);
|
|
193
|
+
if (t) {
|
|
194
|
+
t.by = r.by;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const talk = talkOf?.(r.id) ?? { state: "offline", since: new Date().toISOString(), doing: null, status: null, read: 0, lastSeen: null };
|
|
198
|
+
this.tokens.set(r.id, { name: r.name, by: r.by, view: null, talk: { ...talk, lastSeen: talk.lastSeen ?? r.lastUsed ?? null } });
|
|
199
|
+
changed = true;
|
|
200
|
+
}
|
|
201
|
+
if (changed)
|
|
202
|
+
this.notify();
|
|
203
|
+
}
|
|
204
|
+
/** What an agent with a token does now changed (server/talk.ts). */
|
|
205
|
+
setTalk(id, talk) {
|
|
206
|
+
const t = this.tokens.get(id);
|
|
207
|
+
if (!t)
|
|
208
|
+
return;
|
|
209
|
+
t.talk = { ...talk, lastSeen: talk.lastSeen ?? t.talk.lastSeen };
|
|
210
|
+
this.notify();
|
|
211
|
+
}
|
|
212
|
+
/** The token of the agent pages know by `id` (a token agent's entry), or null. */
|
|
213
|
+
tokenOf(id) {
|
|
214
|
+
for (const [tok, t] of this.tokens)
|
|
215
|
+
if (agentKey({ name: t.name, token: tok }) === id)
|
|
216
|
+
return tok;
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
/** A page's event stream opened (again, after a lost connection): its heartbeats count. */
|
|
220
|
+
open(session) {
|
|
221
|
+
this.closed.delete(session);
|
|
222
|
+
}
|
|
223
|
+
/** Who is here, as every page is told: a private page is not (privateCount). */
|
|
224
|
+
list() {
|
|
225
|
+
const out = [...this.sessions]
|
|
226
|
+
.filter(([, e]) => !e.private)
|
|
227
|
+
.map(([id, e]) => ({ id, name: e.name, view: e.view, idle: e.idle, ...(e.agent ? { agent: true } : {}), ...(e.verified ? { verified: true } : {}) }));
|
|
228
|
+
for (const [tok, t] of this.tokens)
|
|
229
|
+
out.push({ id: agentKey({ name: t.name, token: tok }), name: t.name, view: t.view, idle: t.talk.state === "offline", agent: true, talk: t.talk, by: t.by });
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
/** Pages whose heartbeats stopped go; agents silent for a while are away, then go. */
|
|
233
|
+
expire(now = Date.now()) {
|
|
234
|
+
let changed = false;
|
|
235
|
+
for (const [s, at] of this.closed)
|
|
236
|
+
if (now - at > GONE_MS)
|
|
237
|
+
this.closed.delete(s);
|
|
238
|
+
for (const [s, e] of this.sessions) {
|
|
239
|
+
if (now - e.seen > (e.agent ? AGENT_GONE_MS : GONE_MS)) {
|
|
240
|
+
this.sessions.delete(s);
|
|
241
|
+
changed = true;
|
|
242
|
+
}
|
|
243
|
+
else if (e.agent && !e.idle && now - e.seen > AGENT_IDLE_MS) {
|
|
244
|
+
e.idle = true;
|
|
245
|
+
changed = true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// an agent with a token is away while offline, which server/talk.ts says
|
|
249
|
+
if (changed)
|
|
250
|
+
this.notify();
|
|
251
|
+
}
|
|
252
|
+
}
|