papilot 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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -2
  3. package/README.zh-CN.md +127 -0
  4. package/bin/papilot.mjs +113 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/Inconsolatazi4-Regular-Dbtq7t_r.otf +0 -0
  7. package/dist/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  8. package/dist/assets/TeXGyrePagellaX-Italic-CSuYIstn.otf +0 -0
  9. package/dist/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  10. package/dist/assets/cursor-editorTextHighlight-kG3uUOwL.svg +8 -0
  11. package/dist/assets/index-BhQFCMTx.js +131 -0
  12. package/dist/assets/index-CAV3-87U.css +1 -0
  13. package/dist/assets/pdf-view-DJo0X38w.js +598 -0
  14. package/dist/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
  15. package/dist/favicon.png +0 -0
  16. package/dist/index.html +4 -0
  17. package/examples/papilot-paper/.latex-review.json +4 -0
  18. package/examples/papilot-paper/appendix/proofs.tex +60 -0
  19. package/examples/papilot-paper/appendix/reference.tex +54 -0
  20. package/examples/papilot-paper/main.tex +57 -0
  21. package/examples/papilot-paper/preamble.tex +53 -0
  22. package/examples/papilot-paper/references.bib +158 -0
  23. package/examples/papilot-paper/sections/01-introduction.tex +66 -0
  24. package/examples/papilot-paper/sections/02-using.tex +134 -0
  25. package/examples/papilot-paper/sections/03-background.tex +97 -0
  26. package/examples/papilot-paper/sections/04-model.tex +185 -0
  27. package/examples/papilot-paper/sections/05-conflicts.tex +99 -0
  28. package/examples/papilot-paper/sections/06-building.tex +70 -0
  29. package/examples/papilot-paper/sections/07-geometry.tex +193 -0
  30. package/examples/papilot-paper/sections/08-notes.tex +104 -0
  31. package/examples/papilot-paper/sections/09-agents.tex +106 -0
  32. package/examples/papilot-paper/sections/10-usage.tex +56 -0
  33. package/examples/papilot-paper/sections/11-git.tex +62 -0
  34. package/examples/papilot-paper/sections/12-security.tex +41 -0
  35. package/examples/papilot-paper/sections/13-evaluation.tex +106 -0
  36. package/examples/papilot-paper/sections/14-related.tex +38 -0
  37. package/examples/papilot-paper/sections/15-conclusion.tex +27 -0
  38. package/package.json +85 -4
  39. package/scripts/demo.ts +21 -0
  40. package/scripts/service.ts +35 -0
  41. package/src/notes.ts +40 -0
  42. package/src/server/agents.ts +343 -0
  43. package/src/server/codex.ts +63 -0
  44. package/src/server/frontend.ts +15 -0
  45. package/src/server/main.ts +202 -0
  46. package/src/server/manuscript.ts +337 -0
  47. package/src/server/paths.ts +10 -0
  48. package/src/server/pdf.ts +27 -0
  49. package/src/server/review.ts +53 -0
  50. package/src/server/security.ts +15 -0
  51. package/src/server/store.ts +60 -0
  52. package/src/server/workspace.ts +529 -0
  53. package/src/shared.ts +28 -0
  54. package/vendor/latex-review/LICENSE +21 -0
  55. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  56. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  57. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +1 -0
  58. package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +19 -0
  59. package/vendor/latex-review/dist/web/index.html +18 -0
  60. package/vendor/latex-review/package-lock.json +3114 -0
  61. package/vendor/latex-review/package.json +49 -0
  62. package/vendor/latex-review/src/cli.ts +494 -0
  63. package/vendor/latex-review/src/client.ts +198 -0
  64. package/vendor/latex-review/src/core/build.ts +296 -0
  65. package/vendor/latex-review/src/core/changes.ts +126 -0
  66. package/vendor/latex-review/src/core/config.ts +270 -0
  67. package/vendor/latex-review/src/core/diff.ts +54 -0
  68. package/vendor/latex-review/src/core/files.ts +96 -0
  69. package/vendor/latex-review/src/core/git.ts +317 -0
  70. package/vendor/latex-review/src/core/outline.ts +314 -0
  71. package/vendor/latex-review/src/core/process.ts +111 -0
  72. package/vendor/latex-review/src/core/publish.ts +339 -0
  73. package/vendor/latex-review/src/core/review.ts +734 -0
  74. package/vendor/latex-review/src/core/store.ts +66 -0
  75. package/vendor/latex-review/src/core/tasks.ts +237 -0
  76. package/vendor/latex-review/src/render/inline.ts +805 -0
  77. package/vendor/latex-review/src/render/locate.ts +481 -0
  78. package/vendor/latex-review/src/render/pdf.ts +281 -0
  79. package/vendor/latex-review/src/render/pdfdiff.ts +272 -0
  80. package/vendor/latex-review/src/render/round.ts +236 -0
  81. package/vendor/latex-review/src/render/worker.ts +10 -0
  82. package/vendor/latex-review/src/runtime.ts +23 -0
  83. package/vendor/latex-review/src/server.ts +401 -0
  84. package/vendor/latex-review/src/types.ts +283 -0
  85. package/vendor/latex-review/src/web/ChangeMap.tsx +212 -0
  86. package/vendor/latex-review/src/web/PdfStrip.tsx +77 -0
  87. package/vendor/latex-review/src/web/fonts/GUST-FONT-LICENSE.txt +29 -0
  88. package/vendor/latex-review/src/web/fonts/LPPL.txt +415 -0
  89. package/vendor/latex-review/src/web/fonts/MANIFEST-newpx.txt +20 -0
  90. package/vendor/latex-review/src/web/fonts/README-newpx.txt +473 -0
  91. package/vendor/latex-review/src/web/fonts/README.md +12 -0
  92. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
  93. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
  94. package/vendor/latex-review/src/web/index.html +17 -0
  95. package/vendor/latex-review/src/web/main.tsx +10 -0
  96. package/vendor/latex-review/src/web/model.ts +153 -0
  97. package/vendor/latex-review/src/web/overview.tsx +146 -0
  98. package/vendor/latex-review/src/web/review.tsx +991 -0
  99. package/vendor/latex-review/src/web/style.css +1249 -0
@@ -0,0 +1,236 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { within } from "../core/git.ts";
4
+ import { deepestNode } from "../core/outline.ts";
5
+ import { Build } from "./locate.ts";
6
+ import { renderSection, snap, Strips } from "./inline.ts";
7
+ import { pixelDifference, textChanges } from "./pdfdiff.ts";
8
+ import type {
9
+ Card,
10
+ ChangeBlock,
11
+ Pair,
12
+ RenderedRound,
13
+ RenderRequest,
14
+ Strip,
15
+ Unit,
16
+ } from "../types.ts";
17
+ export function typesetWords(text: string) {
18
+ // TeX comments consume the end-of-line too. Keeping that newline would
19
+ // turn a standalone comment into a paragraph break, or split foo%\nbar.
20
+ const uncommented = (text.match(/[^\n]*\n|[^\n]+$/g) ?? [])
21
+ .map((line) => {
22
+ line = line.replace(/^[ \t]+/, "");
23
+ for (let i = 0; i < line.length; i++) {
24
+ if (line[i] === "\\") {
25
+ i++;
26
+ continue;
27
+ }
28
+ if (line[i] === "%") return line.slice(0, i);
29
+ }
30
+ return line;
31
+ })
32
+ .join("");
33
+ return uncommented
34
+ .split(/\n[ \t]*\n/)
35
+ .filter((p) => p.trim())
36
+ .map((p) => p.trim().replace(/\s+/g, " "));
37
+ }
38
+ export function classify(
39
+ old: Build,
40
+ next: Build,
41
+ unit: Unit,
42
+ body: Set<string>,
43
+ figureFiles: (file: string) => boolean,
44
+ ): Pick<Card, "pairs" | "comparison" | "own"> {
45
+ const name = unit.file;
46
+ // A changed line may be inside a whitespace-sensitive environment without
47
+ // including its \begin in the hunk. Inspect the owning file before using
48
+ // the source-only no-op shortcut; the PDF is the authority for these files.
49
+ const whitespaceSensitive =
50
+ /\\(?:verb\b|lstinline\b|mintinline\b|begin\{(?:verbatim\*?|Verbatim|lstlisting|minted)\})/.test(
51
+ (old.read(name) ?? "") + (next.read(name) ?? ""),
52
+ );
53
+ if (
54
+ unit.kind !== "binary" &&
55
+ !whitespaceSensitive &&
56
+ !unit.truncated &&
57
+ unit.status === "modified" &&
58
+ JSON.stringify(typesetWords(unit.old_text)) ===
59
+ JSON.stringify(typesetWords(unit.new_text))
60
+ )
61
+ return { pairs: [], comparison: "identical" };
62
+ const located =
63
+ name.endsWith(".tex") && unit.kind !== "binary" && body.has(name),
64
+ whole = unit.whole_file || figureFiles(name),
65
+ left =
66
+ located && unit.status !== "added"
67
+ ? old.locator.locate(name, unit.old_start, unit.old_end, whole)
68
+ : [],
69
+ right =
70
+ located && unit.status !== "deleted"
71
+ ? next.locator.locate(name, unit.new_start, unit.new_end, whole)
72
+ : [],
73
+ analysis = textChanges(old.document, next.document, left, right),
74
+ pairs: Pair[] = [];
75
+ let pixels = 0;
76
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
77
+ const a = left[i] ?? null,
78
+ b = right[i] ?? null;
79
+ pairs.push({
80
+ old: a,
81
+ new: b,
82
+ before_marks: analysis.old_marks[i] ?? [],
83
+ after_marks: analysis.new_marks[i] ?? [],
84
+ });
85
+ if (!analysis.text_changed)
86
+ pixels += pixelDifference(
87
+ a ? old.document.page(a.page).raster(a.clip) : null,
88
+ b ? next.document.page(b.page).raster(b.clip) : null,
89
+ );
90
+ }
91
+ const own = {
92
+ old:
93
+ left.length && !whole
94
+ ? old.locator.boxes(name, unit.old_start, unit.old_end)
95
+ : [],
96
+ new:
97
+ right.length && !whole
98
+ ? next.locator.boxes(name, unit.new_start, unit.new_end)
99
+ : [],
100
+ },
101
+ moved = left.some(
102
+ (a, i) =>
103
+ right[i] &&
104
+ (a.page !== right[i].page ||
105
+ a.label !== right[i].label ||
106
+ a.clip.some((v, j) => Math.abs(v - right[i].clip[j]) > 1)),
107
+ );
108
+ return {
109
+ pairs,
110
+ comparison: !pairs.length
111
+ ? "unmapped"
112
+ : analysis.text_changed
113
+ ? "text"
114
+ : pixels
115
+ ? "layout"
116
+ : moved
117
+ ? "position"
118
+ : "identical",
119
+ own,
120
+ };
121
+ }
122
+ function looseBlock(
123
+ card: Card,
124
+ strips: Strips,
125
+ old: Build,
126
+ next: Build,
127
+ ): ChangeBlock {
128
+ const before: Strip[] = [],
129
+ after: Strip[] = [];
130
+ for (const pair of card.pairs)
131
+ for (const [side, build, images, marks] of [
132
+ ["old", old, before, pair.before_marks],
133
+ ["new", next, after, pair.after_marks],
134
+ ] as const) {
135
+ const position = pair[side];
136
+ if (position) {
137
+ const image = strips.write(
138
+ build.document,
139
+ position.page,
140
+ ...snap(
141
+ build.document,
142
+ position.page,
143
+ position.clip[1],
144
+ position.clip[3],
145
+ ),
146
+ marks,
147
+ side,
148
+ );
149
+ if (image) images.push(image);
150
+ }
151
+ }
152
+ return {
153
+ kind: "change",
154
+ units: [card.id],
155
+ identical: card.comparison === "identical",
156
+ before,
157
+ after,
158
+ };
159
+ }
160
+ export function renderRound(
161
+ request: RenderRequest,
162
+ out: string,
163
+ ): RenderedRound {
164
+ fs.mkdirSync(out, { recursive: true });
165
+ const figureFiles = (name: string) => within(name, request.figures),
166
+ old = new Build(request.old, request.main, request.source_dir),
167
+ next = new Build(request.new, request.main, request.source_dir);
168
+ try {
169
+ const body = new Set(
170
+ [old, next].flatMap((b) => b.outline.flatMap((n) => n.files)),
171
+ ),
172
+ cards: Card[] = [],
173
+ result: RenderedRound = { units: {}, sections: [], loose: [] };
174
+ for (const unit of request.units) {
175
+ const card: Card = {
176
+ id: unit.id,
177
+ file: unit.file,
178
+ ...classify(old, next, unit, body, figureFiles),
179
+ },
180
+ node = card.pairs.length
181
+ ? unit.status === "deleted"
182
+ ? deepestNode(old.outline, unit.file, unit.old_start, unit.old_end)
183
+ : deepestNode(next.outline, unit.file, unit.new_start, unit.new_end)
184
+ : null;
185
+ card.context_node = node?.id ?? null;
186
+ cards.push(card);
187
+ result.units[unit.id] = {
188
+ visual: card.comparison,
189
+ pages: [
190
+ ...new Set(card.pairs.map((p) => (p.new ?? p.old)!.label)),
191
+ ].sort(),
192
+ section: node ? `${node.number} ${node.title}`.trim() : null,
193
+ };
194
+ }
195
+ const strips = new Strips(out),
196
+ placed = new Set<number>();
197
+ for (const node of next.outline.filter((n) =>
198
+ cards.some((c) => c.context_node === n.id),
199
+ )) {
200
+ const members = cards.flatMap((c, i): [number, Card][] =>
201
+ c.context_node === node.id ? [[i, c]] : [],
202
+ );
203
+ let rendered: ReturnType<typeof renderSection>;
204
+ try {
205
+ rendered = renderSection(old, next, node, members, strips, figureFiles);
206
+ } catch (error) {
207
+ if (
208
+ !(error as Error).message.includes("PDF outline") &&
209
+ !(error as Error).message.includes("outside this PDF")
210
+ )
211
+ throw error;
212
+ continue;
213
+ }
214
+ const [blocks, unlocated] = rendered;
215
+ if (!blocks.some((b) => b.kind === "change")) continue;
216
+ result.sections.push({
217
+ node: node.id,
218
+ number: node.number,
219
+ title: node.title,
220
+ blocks,
221
+ });
222
+ members.forEach(([i]) => {
223
+ if (!unlocated.includes(i)) placed.add(i);
224
+ });
225
+ }
226
+ cards.forEach((card, index) => {
227
+ if (!placed.has(index))
228
+ result.loose.push(looseBlock(card, strips, old, next));
229
+ });
230
+ fs.writeFileSync(path.join(out, "round.json"), JSON.stringify(result));
231
+ return result;
232
+ } finally {
233
+ old.close();
234
+ next.close();
235
+ }
236
+ }
@@ -0,0 +1,10 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { renderRound } from "./round.ts";
4
+ import type { RenderRequest } from "../types.ts";
5
+ const file = process.argv[2];
6
+ if (!file) throw new Error("Expected a renderer request file.");
7
+ renderRound(
8
+ JSON.parse(fs.readFileSync(file, "utf8")) as RenderRequest,
9
+ path.dirname(file),
10
+ );
@@ -0,0 +1,23 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createHash } from "node:crypto";
5
+ import { listFiles } from "./core/files.ts";
6
+ export const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
7
+ export const WEB = path.join(ROOT, "dist/web");
8
+ let version: string | undefined;
9
+ export function codeVersion() {
10
+ if (!version) {
11
+ const digest = createHash("sha256");
12
+ for (const file of [
13
+ ...listFiles(path.join(ROOT, "src")),
14
+ ...listFiles(WEB),
15
+ path.join(ROOT, "package-lock.json"),
16
+ ].sort())
17
+ digest
18
+ .update(path.relative(ROOT, file) + "\0")
19
+ .update(fs.readFileSync(file));
20
+ version = digest.digest("hex").slice(0, 16);
21
+ }
22
+ return version;
23
+ }
@@ -0,0 +1,401 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomBytes, timingSafeEqual } from "node:crypto";
4
+ import Fastify from "fastify";
5
+ import staticFiles from "@fastify/static";
6
+ import compress from "@fastify/compress";
7
+ import { WEB, codeVersion } from "./runtime.ts";
8
+ import { Repo } from "./core/git.ts";
9
+ import { CONFIG_NAME, loadProject, type Project } from "./core/config.ts";
10
+ import { Store } from "./core/store.ts";
11
+ import { Tasks } from "./core/tasks.ts";
12
+ import { Review } from "./core/review.ts";
13
+ import { Publisher } from "./core/publish.ts";
14
+ import { acquireLock, now, readJson, writeJson } from "./core/files.ts";
15
+ import { stopAll } from "./core/process.ts";
16
+ import type { ActionKind } from "./types.ts";
17
+ export class App {
18
+ root: string;
19
+ repo: Repo;
20
+ state: string;
21
+ store: Store;
22
+ csrf = randomBytes(32).toString("base64url");
23
+ viewers = new Map<string, number>();
24
+ url = "";
25
+ activity = now();
26
+ tasks: Tasks;
27
+ review: Review;
28
+ publisher: Publisher;
29
+ private cachedProject?: Project;
30
+ private stamp?: number;
31
+ constructor(root: string) {
32
+ this.root = fs.realpathSync(root);
33
+ this.repo = new Repo(this.root);
34
+ this.state = path.join(this.repo.gitDir(), "latex-review");
35
+ this.store = new Store(path.join(this.state, "state.sqlite3"));
36
+ this.tasks = new Tasks(this);
37
+ this.review = new Review(this);
38
+ this.publisher = new Publisher(this);
39
+ this.review.recover();
40
+ }
41
+ get project() {
42
+ const config = path.join(this.root, CONFIG_NAME),
43
+ stamp = fs.existsSync(config) ? fs.statSync(config).mtimeMs : 0;
44
+ if (!this.cachedProject || this.stamp !== stamp) {
45
+ this.cachedProject = loadProject(this.root);
46
+ this.stamp = stamp;
47
+ }
48
+ return this.cachedProject;
49
+ }
50
+ page(name: string) {
51
+ return `${this.url}/?task=${encodeURIComponent(name)}`;
52
+ }
53
+ taskInfo(name: string) {
54
+ const task = this.tasks.get(name),
55
+ scope = task.scope;
56
+ return {
57
+ ...this.review.summary(name),
58
+ worktree: task.worktree,
59
+ url: this.page(name),
60
+ viewing: now() - (this.viewers.get(name) ?? 0) < 15,
61
+ scope: {
62
+ sections: scope.sections.map((s) => `${s.number} ${s.title}`.trim()),
63
+ files: scope.ranges
64
+ ? Object.entries(scope.ranges).flatMap(([file, spans]) =>
65
+ spans.map(([a, b]) => `${file}:${a}-${b}`),
66
+ )
67
+ : scope.paths,
68
+ },
69
+ };
70
+ }
71
+ tasksOverview() {
72
+ return this.tasks.list().map((task) => {
73
+ const { next: _next, ...summary } = this.review.summary(task.name);
74
+ return { ...summary, url: this.page(task.name) };
75
+ });
76
+ }
77
+ async close() {
78
+ this.publisher.stop();
79
+ await this.publisher.join();
80
+ this.store.close();
81
+ }
82
+ }
83
+ export function createServer(app: App) {
84
+ const server = Fastify({ logger: false, bodyLimit: 999999 });
85
+ server.register(compress, { threshold: 1024 });
86
+ server.addHook("onRequest", async (request, reply) => {
87
+ app.activity = now();
88
+ const port = new URL(app.url || "http://127.0.0.1:80").port || "80",
89
+ hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
90
+ reply
91
+ .header("Cache-Control", "no-store")
92
+ .header("X-Content-Type-Options", "nosniff")
93
+ .header("X-Frame-Options", "DENY");
94
+ if (
95
+ !hosts.has(request.headers.host ?? "") ||
96
+ (request.headers.origin &&
97
+ ![...hosts].some((h) => request.headers.origin === "http://" + h))
98
+ )
99
+ return reply
100
+ .code(403)
101
+ .send({ error: "Only same-origin localhost requests are accepted." });
102
+ if (request.method === "POST") {
103
+ const token = Buffer.from(
104
+ String(request.headers["x-review-token"] ?? ""),
105
+ ),
106
+ expected = Buffer.from(app.csrf);
107
+ if (token.length !== expected.length || !timingSafeEqual(token, expected))
108
+ return reply
109
+ .code(403)
110
+ .send({ error: "This page is out of date. Reload it." });
111
+ }
112
+ });
113
+ server.setErrorHandler((error, _request, reply) => {
114
+ const e = error as Error & { statusCode?: number };
115
+ reply.code(e.statusCode ?? 400).send({ error: e.message });
116
+ });
117
+ server.after(() => {
118
+ server.get("/api/info", () => ({
119
+ root: app.root,
120
+ version: codeVersion(),
121
+ pid: process.pid,
122
+ busy: app.publisher.busy(),
123
+ build_workers: app.project.build_workers,
124
+ csrf: app.csrf,
125
+ runtime: "typescript",
126
+ }));
127
+ server.get("/api/tasks", () => ({
128
+ tasks: app.tasksOverview(),
129
+ csrf: app.csrf,
130
+ }));
131
+ server.get<{ Params: { id: string } }>("/api/jobs/:id", (request) => {
132
+ const j = app.publisher.job(request.params.id);
133
+ return {
134
+ id: j.id,
135
+ task: j.task,
136
+ status: j.status,
137
+ phase: j.phase,
138
+ ...j.result,
139
+ };
140
+ });
141
+ server.get<{ Params: { name: string } }>("/api/tasks/:name", (request) =>
142
+ app.taskInfo(request.params.name),
143
+ );
144
+ server.get<{ Params: { name: string }; Querystring: { round?: string } }>(
145
+ "/api/tasks/:name/state",
146
+ (request) =>
147
+ app.review.pageState(
148
+ request.params.name,
149
+ request.query.round && /^\d+$/.test(request.query.round)
150
+ ? Number(request.query.round)
151
+ : undefined,
152
+ ),
153
+ );
154
+ server.get<{ Params: { name: string }; Querystring: { after?: string } }>(
155
+ "/api/tasks/:name/feedback",
156
+ (request) =>
157
+ app.review.feedback(
158
+ request.params.name,
159
+ /^\d+$/.test(request.query.after ?? "")
160
+ ? Number(request.query.after)
161
+ : 0,
162
+ ),
163
+ );
164
+ server.get<{ Params: { name: string; number: string } }>(
165
+ "/api/tasks/:name/rounds/:number",
166
+ (request) =>
167
+ app.review.roundDetail(
168
+ request.params.name,
169
+ Number(request.params.number),
170
+ ),
171
+ );
172
+ server.get<{ Params: { name: string; number: string; file: string } }>(
173
+ "/files/:name/:number/:file",
174
+ (request, reply) => {
175
+ const { name, number, file } = request.params;
176
+ app.tasks.get(name, false);
177
+ if (!/^\d+$/.test(number) || !/^[\w.-]+\.(svg|png)$/.test(file))
178
+ return reply.code(404).send({ error: "No such file" });
179
+ const target = path.join(
180
+ app.state,
181
+ "rounds",
182
+ name,
183
+ String(Number(number)).padStart(3, "0"),
184
+ file,
185
+ );
186
+ if (!fs.existsSync(target))
187
+ return reply.code(404).send({ error: "No such file" });
188
+ reply
189
+ .header("Cache-Control", "private, max-age=31536000, immutable")
190
+ .type(file.endsWith(".svg") ? "image/svg+xml" : "image/png");
191
+ return fs.createReadStream(target);
192
+ },
193
+ );
194
+ const objectSchema = { type: "object" as const };
195
+ server.post<{
196
+ Body: {
197
+ name?: string;
198
+ title?: string;
199
+ sections?: string[];
200
+ files?: string[];
201
+ };
202
+ }>(
203
+ "/api/tasks",
204
+ {
205
+ schema: {
206
+ body: {
207
+ ...objectSchema,
208
+ properties: {
209
+ name: { type: "string" },
210
+ title: { type: "string" },
211
+ sections: { type: "array", items: { type: "string" } },
212
+ files: { type: "array", items: { type: "string" } },
213
+ },
214
+ },
215
+ },
216
+ },
217
+ (request) => {
218
+ const d = request.body;
219
+ return app.taskInfo(
220
+ app.tasks.create(d.name, d.title, d.sections, d.files).name,
221
+ );
222
+ },
223
+ );
224
+ server.post<{ Params: { name: string } }>(
225
+ "/api/tasks/:name/publish",
226
+ (request) => {
227
+ const job = app.publisher.submit(request.params.name);
228
+ return { job: job.id, status: job.status };
229
+ },
230
+ );
231
+ server.post<{
232
+ Params: { name: string };
233
+ Body: {
234
+ action: ActionKind;
235
+ id?: string;
236
+ changes: string[];
237
+ round: number;
238
+ body?: string;
239
+ };
240
+ }>(
241
+ "/api/tasks/:name/decide",
242
+ {
243
+ schema: {
244
+ body: {
245
+ ...objectSchema,
246
+ required: ["action", "changes", "round"],
247
+ properties: {
248
+ action: { enum: ["accept", "reject", "comment"] },
249
+ id: { type: "string" },
250
+ changes: { type: "array", items: { type: "string" } },
251
+ round: { type: "integer", minimum: 1 },
252
+ body: { type: "string" },
253
+ },
254
+ },
255
+ },
256
+ },
257
+ (request) => app.review.decide(request.params.name, request.body),
258
+ );
259
+ server.post<{ Params: { name: string }; Body: { id: string } }>(
260
+ "/api/tasks/:name/undo",
261
+ {
262
+ schema: {
263
+ body: {
264
+ ...objectSchema,
265
+ required: ["id"],
266
+ properties: { id: { type: "string" } },
267
+ },
268
+ },
269
+ },
270
+ (request) => app.review.undo(request.params.name, request.body.id),
271
+ );
272
+ server.post<{ Params: { name: string }; Body: { body: string } }>(
273
+ "/api/tasks/:name/note",
274
+ {
275
+ schema: {
276
+ body: {
277
+ ...objectSchema,
278
+ required: ["body"],
279
+ properties: { body: { type: "string" } },
280
+ },
281
+ },
282
+ },
283
+ (request) => app.review.note(request.params.name, request.body.body),
284
+ );
285
+ server.post<{ Params: { name: string }; Body: { force?: boolean } }>(
286
+ "/api/tasks/:name/finish",
287
+ {
288
+ schema: {
289
+ body: { ...objectSchema, properties: { force: { type: "boolean" } } },
290
+ },
291
+ },
292
+ async (request) => {
293
+ const result = await app.tasks.finish(
294
+ request.params.name,
295
+ request.body.force,
296
+ );
297
+ return {
298
+ task: request.params.name,
299
+ finished: true,
300
+ changed_files: result.changed,
301
+ discarded: Object.fromEntries(
302
+ Object.entries(result.discarded).filter(
303
+ ([k, n]) => ["pending", "commented"].includes(k) && n,
304
+ ),
305
+ ),
306
+ };
307
+ },
308
+ );
309
+ server.post<{ Body: { force?: boolean } }>(
310
+ "/api/shutdown",
311
+ {
312
+ schema: {
313
+ body: { ...objectSchema, properties: { force: { type: "boolean" } } },
314
+ },
315
+ },
316
+ (request, reply) => {
317
+ if (app.publisher.busy() && !request.body.force)
318
+ throw new Error(
319
+ "A round is being built. Stop after it finishes, or force the stop.",
320
+ );
321
+ reply.send({ ok: true });
322
+ setImmediate(() => {
323
+ app.publisher.stop();
324
+ stopAll();
325
+ void server.close();
326
+ });
327
+ },
328
+ );
329
+ if (fs.existsSync(WEB)) {
330
+ server.register(staticFiles, {
331
+ root: WEB,
332
+ prefix: "/",
333
+ cacheControl: false,
334
+ setHeaders: (response) => response.header("Cache-Control", "no-cache"),
335
+ });
336
+ } else
337
+ server.get("/", (_req, reply) =>
338
+ reply
339
+ .type("text/plain")
340
+ .send("Build the review UI with npm run build."),
341
+ );
342
+ });
343
+ return server;
344
+ }
345
+ export async function serve(root: string, port = 0, idle = 12 * 3600) {
346
+ root = new Repo(root).toplevel();
347
+ process.chdir(root);
348
+ const home = path.join(new Repo(root).gitDir(), "latex-review/server"),
349
+ release = await acquireLock(path.join(home, "server.lock"), false),
350
+ app = new App(root),
351
+ server = createServer(app);
352
+ let timer: NodeJS.Timeout | undefined;
353
+ try {
354
+ try {
355
+ await server.listen({ host: "127.0.0.1", port });
356
+ } catch (error) {
357
+ if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
358
+ await server.listen({ host: "127.0.0.1", port: 0 });
359
+ }
360
+ const address = server.server.address();
361
+ if (!address || typeof address === "string")
362
+ throw new Error("No server address");
363
+ app.url = `http://127.0.0.1:${address.port}`;
364
+ const info = {
365
+ url: app.url,
366
+ port: address.port,
367
+ pid: process.pid,
368
+ version: codeVersion(),
369
+ root,
370
+ runtime: "typescript",
371
+ };
372
+ writeJson(path.join(home, "connection.json"), info, 0o600);
373
+ writeJson(path.join(home, "last.json"), { port: address.port });
374
+ const shutdown = () => {
375
+ app.publisher.stop();
376
+ stopAll();
377
+ void server.close();
378
+ };
379
+ process.once("SIGTERM", shutdown);
380
+ process.once("SIGINT", shutdown);
381
+ timer = setInterval(
382
+ () => {
383
+ if (now() - app.activity > idle && !app.publisher.busy()) shutdown();
384
+ },
385
+ Math.min(30000, idle * 1000),
386
+ );
387
+ console.log(JSON.stringify(info));
388
+ await new Promise<void>((resolve) => server.server.once("close", resolve));
389
+ } finally {
390
+ if (timer) clearInterval(timer);
391
+ app.publisher.stop();
392
+ stopAll();
393
+ await app.close();
394
+ if (
395
+ readJson<{ pid?: number }>(path.join(home, "connection.json"), {}).pid ===
396
+ process.pid
397
+ )
398
+ fs.rmSync(path.join(home, "connection.json"), { force: true });
399
+ await release();
400
+ }
401
+ }