papilot 0.8.6 → 0.8.7
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/dist/assets/index-BIc9Mnim.css +1 -0
- package/dist/assets/index-MvQvvv3I.js +267 -0
- package/dist/assets/{math-C7ENJr-a.js → math-CBzEH7Y2.js} +1 -1
- package/dist/assets/{pdf-view-B2xQuHmI.js → pdf-view-ByftRBhD.js} +2 -2
- package/dist/index.html +2 -2
- package/engine/dist/web/assets/{FullPaper-DlgZ1MOz.js → FullPaper-DWwiT43p.js} +2 -2
- package/engine/dist/web/assets/{index-BCgVChtV.js → index-DBYJK8Uy.js} +4 -4
- package/engine/dist/web/assets/{index-D3_ZFdT8.css → index-DGkq8xQx.css} +1 -1
- package/engine/dist/web/index.html +2 -2
- package/engine/src/cli.ts +3 -0
- package/engine/src/client.ts +24 -7
- package/engine/src/core/build.ts +357 -93
- package/engine/src/core/changes.ts +39 -10
- package/engine/src/core/config.ts +2 -2
- package/engine/src/core/current-commit.ts +34 -34
- package/engine/src/core/delivery.ts +83 -30
- package/engine/src/core/files.ts +19 -0
- package/engine/src/core/git.ts +283 -92
- package/engine/src/core/process.ts +124 -11
- package/engine/src/core/publish.ts +59 -50
- package/engine/src/core/reconcile.ts +23 -21
- package/engine/src/core/review.ts +43 -24
- package/engine/src/core/tasks.ts +85 -28
- package/engine/src/core/view.ts +8 -8
- package/engine/src/server.ts +62 -6
- package/engine/src/types.ts +8 -0
- package/engine/src/web/FullPaper.tsx +122 -34
- package/engine/src/web/review.tsx +4 -1
- package/engine/src/web/style.css +24 -0
- package/package.json +1 -1
- package/src/server/agents.ts +3 -0
- package/src/server/annotated-pdf.ts +392 -0
- package/src/server/annotation-placement.ts +18 -3
- package/src/server/app.ts +68 -10
- package/src/server/changes.ts +12 -7
- package/src/server/cursor.ts +1 -0
- package/src/server/manuscript.ts +218 -56
- package/src/server/pdf.ts +176 -23
- package/src/server/references.ts +10 -2
- package/src/server/review.ts +6 -2
- package/src/server/start.ts +4 -1
- package/src/server/workspace.ts +18 -0
- package/dist/assets/index-Dm7AYM9e.js +0 -267
- package/dist/assets/index-PFQDdu53.css +0 -1
package/engine/src/core/tasks.ts
CHANGED
|
@@ -46,6 +46,12 @@ export function taskMarker(folder: string): [string, string | null] {
|
|
|
46
46
|
);
|
|
47
47
|
return marker ? [marker.root, marker.task] : [repo.toplevel(), null];
|
|
48
48
|
}
|
|
49
|
+
export async function revisionReaderAsync(repo: Repo, rev: string, folder: string) {
|
|
50
|
+
const files = (await repo.runAsync(["ls-tree", "-r", "-z", "--name-only", rev, ...(folder ? ["--", folder] : [])]))
|
|
51
|
+
.toString().split("\0").filter((p) => p.endsWith(".tex")),
|
|
52
|
+
blobs = await repo.blobsAsync(rev, files);
|
|
53
|
+
return (file: string) => blobs[file]?.toString() ?? null;
|
|
54
|
+
}
|
|
49
55
|
export function revisionReader(repo: Repo, rev: string, folder: string) {
|
|
50
56
|
const files = repo.files(rev, folder).filter((p) => p.endsWith(".tex")),
|
|
51
57
|
blobs = repo.blobs(rev, files);
|
|
@@ -73,6 +79,12 @@ export class Tasks {
|
|
|
73
79
|
GENERATED,
|
|
74
80
|
);
|
|
75
81
|
}
|
|
82
|
+
snapshotAsync(task: Task) {
|
|
83
|
+
return new Repo(task.worktree).snapshotAsync(
|
|
84
|
+
[this.app.project.source_dir, ...task.scope.paths],
|
|
85
|
+
GENERATED,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
76
88
|
create(
|
|
77
89
|
name?: string,
|
|
78
90
|
title?: string,
|
|
@@ -80,6 +92,43 @@ export class Tasks {
|
|
|
80
92
|
files: string[] = [],
|
|
81
93
|
deferred = false,
|
|
82
94
|
): Task {
|
|
95
|
+
const reserved = this.reserve(name, title, sections, files);
|
|
96
|
+
if ("existing" in reserved) return reserved.existing;
|
|
97
|
+
const base = this.app.repo.snapshot([this.app.project.source_dir, ...reserved.paths], GENERATED);
|
|
98
|
+
const scope = this.scopeOf(sections.length ? revisionReader(this.app.repo, base, this.app.project.doc_dir) : null, sections, reserved.paths);
|
|
99
|
+
fs.mkdirSync(path.dirname(reserved.worktree), { recursive: true });
|
|
100
|
+
this.app.repo.run(["worktree", "add", "--detach", reserved.worktree, base]);
|
|
101
|
+
return this.record(reserved, base, scope, deferred);
|
|
102
|
+
}
|
|
103
|
+
/** `create` for the server: the snapshot and the checkout of the new
|
|
104
|
+
* worktree, seconds for a large paper, run while it answers requests. */
|
|
105
|
+
async createAsync(
|
|
106
|
+
name?: string,
|
|
107
|
+
title?: string,
|
|
108
|
+
sections: string[] = [],
|
|
109
|
+
files: string[] = [],
|
|
110
|
+
deferred = false,
|
|
111
|
+
): Promise<Task> {
|
|
112
|
+
const reserved = this.reserve(name, title, sections, files);
|
|
113
|
+
if ("existing" in reserved) return reserved.existing;
|
|
114
|
+
this.creating.add(reserved.name);
|
|
115
|
+
try {
|
|
116
|
+
// The manuscript is read between writes, which may be changing it; the
|
|
117
|
+
// much longer checkout of the worktree is not.
|
|
118
|
+
const base = await this.app.exclusive(() => this.app.repo.snapshotAsync([this.app.project.source_dir, ...reserved.paths], GENERATED));
|
|
119
|
+
const scope = this.scopeOf(sections.length ? await revisionReaderAsync(this.app.repo, base, this.app.project.doc_dir) : null, sections, reserved.paths);
|
|
120
|
+
await fs.promises.mkdir(path.dirname(reserved.worktree), { recursive: true });
|
|
121
|
+
await this.app.repo.runAsync(["worktree", "add", "--detach", reserved.worktree, base]);
|
|
122
|
+
return this.record(reserved, base, scope, deferred);
|
|
123
|
+
} finally {
|
|
124
|
+
this.creating.delete(reserved.name);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Names of tasks whose worktree is being created.
|
|
128
|
+
private creating = new Set<string>();
|
|
129
|
+
// The task's name, title, worktree and scope paths, or the open task that
|
|
130
|
+
// already covers this request.
|
|
131
|
+
private reserve(name: string | undefined, title: string | undefined, sections: string[], files: string[]): { existing: Task } | { name: string; title: string; worktree: string; paths: string[] } {
|
|
83
132
|
const { app } = this,
|
|
84
133
|
project = app.project;
|
|
85
134
|
if (sections.length && files.length)
|
|
@@ -93,18 +142,21 @@ export class Tasks {
|
|
|
93
142
|
);
|
|
94
143
|
let candidate = name,
|
|
95
144
|
suffix = 1;
|
|
96
|
-
for (;;) {
|
|
145
|
+
for (;; candidate = `${name.slice(0, 60)}-${++suffix}`) {
|
|
146
|
+
if (this.creating.has(candidate)) {
|
|
147
|
+
if (explicit) throw new Error(`Task ${JSON.stringify(name)} is being created. Try again in a moment.`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
97
150
|
const existing = app.store.one<Task>(
|
|
98
151
|
"SELECT * FROM tasks WHERE name=?",
|
|
99
152
|
candidate,
|
|
100
153
|
);
|
|
101
154
|
if (!existing || existing.closed) break;
|
|
102
|
-
if (this.matches(existing, sections, files)) return existing;
|
|
155
|
+
if (this.matches(existing, sections, files)) return { existing };
|
|
103
156
|
if (explicit)
|
|
104
157
|
throw new Error(
|
|
105
158
|
`Task ${JSON.stringify(name)} already exists with a different scope. Finish it first or choose another name.`,
|
|
106
159
|
);
|
|
107
|
-
candidate = `${name.slice(0, 60)}-${++suffix}`;
|
|
108
160
|
}
|
|
109
161
|
name = candidate;
|
|
110
162
|
if (!app.repo.hasHead())
|
|
@@ -122,31 +174,30 @@ export class Tasks {
|
|
|
122
174
|
throw new Error(
|
|
123
175
|
`The folder ${worktree} already exists. Remove it or choose another name.`,
|
|
124
176
|
);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
project.main,
|
|
132
|
-
);
|
|
133
|
-
const [ranges, chosen] = selectedRanges(
|
|
134
|
-
outline.nodes,
|
|
135
|
-
resolveSections(outline.nodes, sections),
|
|
136
|
-
);
|
|
137
|
-
scope = { sections: chosen, ranges, paths: Object.keys(ranges).sort() };
|
|
138
|
-
} else
|
|
139
|
-
scope = {
|
|
177
|
+
return { name, title, worktree, paths: files.map(cleanPath) };
|
|
178
|
+
}
|
|
179
|
+
private scopeOf(read: ((file: string) => string | null) | null, sections: string[], paths: string[]): Scope {
|
|
180
|
+
const project = this.app.project;
|
|
181
|
+
if (!sections.length || !read)
|
|
182
|
+
return {
|
|
140
183
|
sections: [],
|
|
141
184
|
ranges: null,
|
|
142
185
|
paths: paths.length ? paths : [project.source_dir],
|
|
143
186
|
};
|
|
144
|
-
|
|
145
|
-
|
|
187
|
+
const outline = buildOutline(read, project.main);
|
|
188
|
+
const [ranges, chosen] = selectedRanges(
|
|
189
|
+
outline.nodes,
|
|
190
|
+
resolveSections(outline.nodes, sections),
|
|
191
|
+
);
|
|
192
|
+
return { sections: chosen, ranges, paths: Object.keys(ranges).sort() };
|
|
193
|
+
}
|
|
194
|
+
// Records a task whose worktree was just checked out at `base`.
|
|
195
|
+
private record({ name, title, worktree }: { name: string; title: string; worktree: string }, base: string, scope: Scope, deferred: boolean) {
|
|
196
|
+
const { app } = this;
|
|
146
197
|
try {
|
|
147
198
|
writeJson(path.join(new Repo(worktree).gitDir(), "latex-review.json"), {
|
|
148
199
|
task: name,
|
|
149
|
-
root: project.root,
|
|
200
|
+
root: app.project.root,
|
|
150
201
|
});
|
|
151
202
|
app.repo.updateRef(`refs/latex-review/${name}/base`, base);
|
|
152
203
|
app.store.transaction(() => {
|
|
@@ -197,7 +248,14 @@ export class Tasks {
|
|
|
197
248
|
JSON.stringify([...task.scope.paths].sort())
|
|
198
249
|
);
|
|
199
250
|
}
|
|
251
|
+
/** Closes the task and removes its worktree, after every other write of
|
|
252
|
+
* the review engine; removing a large worktree takes a while. */
|
|
200
253
|
async finish(name: string, force = false) {
|
|
254
|
+
const result = await this.app.exclusive(() => this.finishNow(name, force));
|
|
255
|
+
await this.app.publisher.pruneCache();
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
private async finishNow(name: string, force: boolean) {
|
|
201
259
|
this.app.delivery.assertReady(name);
|
|
202
260
|
const app = this.app,
|
|
203
261
|
task = this.get(name);
|
|
@@ -212,7 +270,7 @@ export class Tasks {
|
|
|
212
270
|
`${counts.pending} pending and ${counts.commented} commented changes`,
|
|
213
271
|
);
|
|
214
272
|
const leftover = fs.existsSync(task.worktree)
|
|
215
|
-
? app.repo.
|
|
273
|
+
? await app.repo.changedFilesAsync(task.accepted, await this.snapshotAsync(task))
|
|
216
274
|
: [];
|
|
217
275
|
if (leftover.length)
|
|
218
276
|
problems.push(
|
|
@@ -223,17 +281,16 @@ export class Tasks {
|
|
|
223
281
|
`The task still has ${problems.join("; ")}. Publish and finish the review first, or use --force to discard them.`,
|
|
224
282
|
);
|
|
225
283
|
if (fs.existsSync(task.worktree))
|
|
226
|
-
app.repo.
|
|
227
|
-
app.repo.
|
|
228
|
-
app.repo.
|
|
229
|
-
fs.
|
|
284
|
+
await app.repo.runAsync(["worktree", "remove", "--force", task.worktree]);
|
|
285
|
+
await app.repo.runAsync(["worktree", "prune"], { check: false });
|
|
286
|
+
await app.repo.deleteRefsAsync(`refs/latex-review/${name}/`);
|
|
287
|
+
await fs.promises.rm(path.join(app.state, "rounds", name), {
|
|
230
288
|
recursive: true,
|
|
231
289
|
force: true,
|
|
232
290
|
});
|
|
233
291
|
app.store.execute("UPDATE tasks SET closed=? WHERE name=?", now(), name);
|
|
234
|
-
await app.publisher.pruneCache();
|
|
235
292
|
return {
|
|
236
|
-
changed: app.repo.
|
|
293
|
+
changed: await app.repo.changedFilesAsync(task.base, task.accepted),
|
|
237
294
|
discarded: counts,
|
|
238
295
|
};
|
|
239
296
|
}
|
package/engine/src/core/view.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { Builder, BuildError } from "./build.ts";
|
|
5
5
|
import { cleanPath, within } from "./git.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { capture } from "./process.ts";
|
|
7
7
|
import { mupdf } from "../render/pdf.ts";
|
|
8
8
|
import type { App } from "../server.ts";
|
|
9
9
|
import type { BuildInfo } from "../types.ts";
|
|
@@ -72,15 +72,15 @@ function location(app: App, worktree: string, value: string): Location {
|
|
|
72
72
|
}
|
|
73
73
|
// The pages SyncTeX gives for a source line, or for the nearest line that
|
|
74
74
|
// printed something.
|
|
75
|
-
function pagesOf(pdf: string, file: string, line: number) {
|
|
75
|
+
async function pagesOf(pdf: string, file: string, line: number) {
|
|
76
76
|
for (let distance = 0; distance <= NEARBY; distance++)
|
|
77
77
|
for (const candidate of distance ? [line - distance, line + distance] : [line]) {
|
|
78
78
|
if (candidate < 1) continue;
|
|
79
|
-
const output =
|
|
79
|
+
const output = (await capture(
|
|
80
80
|
"synctex",
|
|
81
81
|
["view", "-i", `${candidate}:0:${file}`, "-o", pdf],
|
|
82
82
|
{ check: false, timeout: 20000 },
|
|
83
|
-
).stdout.toString();
|
|
83
|
+
).catch(() => ({ stdout: Buffer.alloc(0) }))).stdout.toString();
|
|
84
84
|
const pages = [
|
|
85
85
|
...new Set(
|
|
86
86
|
[...output.matchAll(/^Page:(\d+)$/gm)].map((m) => Number(m[1])),
|
|
@@ -105,12 +105,12 @@ export async function view(
|
|
|
105
105
|
labels = (request.pages ?? []).map((p) => p.trim()).filter(Boolean);
|
|
106
106
|
if (!locations.length && !labels.length)
|
|
107
107
|
throw new Error("Ask for at least one source location or page number.");
|
|
108
|
-
const after = app.tasks.
|
|
108
|
+
const after = await app.tasks.snapshotAsync(task),
|
|
109
109
|
before = task.accepted,
|
|
110
110
|
builder = new Builder(project, app.repo),
|
|
111
|
-
targets = builder.
|
|
111
|
+
targets = await builder.planAsync(before, after, [
|
|
112
112
|
...new Set([
|
|
113
|
-
...app.repo.
|
|
113
|
+
...(await app.repo.changedFilesAsync(before, after)),
|
|
114
114
|
...locations.map((l) => l.file),
|
|
115
115
|
]),
|
|
116
116
|
]);
|
|
@@ -150,7 +150,7 @@ export async function view(
|
|
|
150
150
|
build.root,
|
|
151
151
|
path.posix.relative(project.source_dir || ".", l.file),
|
|
152
152
|
),
|
|
153
|
-
found = fs.existsSync(file) ? pagesOf(pdf, file, l.line) : [];
|
|
153
|
+
found = fs.existsSync(file) ? await pagesOf(pdf, file, l.line) : [];
|
|
154
154
|
if (found.length) found.forEach((page) => ask(page, l.asked));
|
|
155
155
|
else missing.push(`${l.asked}: SyncTeX places no typeset text at or near this line.`);
|
|
156
156
|
}
|
package/engine/src/server.ts
CHANGED
|
@@ -52,9 +52,23 @@ export class App {
|
|
|
52
52
|
this.confirmations = new Confirmations(
|
|
53
53
|
path.join(this.state, "references-confirmed.json"),
|
|
54
54
|
);
|
|
55
|
-
|
|
56
|
-
this.
|
|
57
|
-
|
|
55
|
+
// Writes interrupted by a restart are finished first, before any other.
|
|
56
|
+
this.ready = this.exclusive(async () => {
|
|
57
|
+
await this.review.recover();
|
|
58
|
+
await this.delivery.recover();
|
|
59
|
+
await this.reconciliation.recover();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Settles once writes interrupted by a restart are finished. */
|
|
63
|
+
ready: Promise<void>;
|
|
64
|
+
private writing: Promise<unknown> = Promise.resolve();
|
|
65
|
+
/** Runs `work` after every write that started before it. Decisions,
|
|
66
|
+
* deliveries and task changes run Git without blocking the server, so this
|
|
67
|
+
* keeps them from interleaving, as when they ran in one piece. */
|
|
68
|
+
exclusive<T>(work: () => T | Promise<T>): Promise<T> {
|
|
69
|
+
const result = this.writing.then(() => work());
|
|
70
|
+
this.writing = result.then(() => undefined, () => undefined);
|
|
71
|
+
return result;
|
|
58
72
|
}
|
|
59
73
|
get project() {
|
|
60
74
|
const config = path.join(this.root, CONFIG_NAME),
|
|
@@ -153,12 +167,14 @@ export function createServer(app: App) {
|
|
|
153
167
|
csrf: app.csrf,
|
|
154
168
|
}));
|
|
155
169
|
server.get<{ Params: { id: string } }>("/api/jobs/:id", (request) => {
|
|
156
|
-
const j = app.publisher.job(request.params.id)
|
|
170
|
+
const j = app.publisher.job(request.params.id),
|
|
171
|
+
revision = app.publisher.revision(j.id);
|
|
157
172
|
return {
|
|
158
173
|
id: j.id,
|
|
159
174
|
task: j.task,
|
|
160
175
|
status: j.status,
|
|
161
176
|
phase: j.phase,
|
|
177
|
+
...(revision ? { revision } : {}),
|
|
162
178
|
...j.result,
|
|
163
179
|
};
|
|
164
180
|
});
|
|
@@ -324,10 +340,10 @@ export function createServer(app: App) {
|
|
|
324
340
|
},
|
|
325
341
|
},
|
|
326
342
|
},
|
|
327
|
-
(request) => {
|
|
343
|
+
async (request) => {
|
|
328
344
|
const d = request.body;
|
|
329
345
|
return app.taskInfo(
|
|
330
|
-
app.tasks.
|
|
346
|
+
(await app.tasks.createAsync(d.name, d.title, d.sections, d.files, d.deferred)).name,
|
|
331
347
|
);
|
|
332
348
|
},
|
|
333
349
|
);
|
|
@@ -351,6 +367,12 @@ export function createServer(app: App) {
|
|
|
351
367
|
{ schema: { body: { ...objectSchema, additionalProperties: false, properties: { confirmation: { type: "string", maxLength: 100 }, keepCurrent: { type: "boolean" }, mode: { enum: ["workspace", "commit"] }, message: { type: "string", maxLength: 5000 } } } } },
|
|
352
368
|
request => app.delivery.apply(request.params.name, request.body.keepCurrent === true, request.body.mode, request.body.message, request.body.confirmation),
|
|
353
369
|
);
|
|
370
|
+
// A speculative build of the manuscript compiles what applying the task
|
|
371
|
+
// would write, so its PDF is ready when the author applies it.
|
|
372
|
+
server.get<{ Params: { name: string } }>("/api/tasks/:name/preview", async (request) => {
|
|
373
|
+
const writes = await app.delivery.preview(request.params.name);
|
|
374
|
+
return { files: writes && Object.fromEntries(Object.entries(writes).map(([file, data]) => [file, data?.toString("base64") ?? null])) };
|
|
375
|
+
});
|
|
354
376
|
server.post<{ Params: { name: string }; Body: { mode: "workspace" | "branch"; message?: string } }>(
|
|
355
377
|
"/api/tasks/:name/deliver",
|
|
356
378
|
{ schema: { body: { ...objectSchema, required: ["mode"], properties: { mode: { enum: ["workspace", "branch"] }, message: { type: "string", maxLength: 5000 } } } } },
|
|
@@ -470,7 +492,31 @@ export function createServer(app: App) {
|
|
|
470
492
|
});
|
|
471
493
|
return server;
|
|
472
494
|
}
|
|
495
|
+
interface Owner {
|
|
496
|
+
left: boolean;
|
|
497
|
+
stop: () => void;
|
|
498
|
+
}
|
|
473
499
|
export async function serve(root: string, port = 0, idle = 12 * 3600) {
|
|
500
|
+
// A server started with an IPC channel belongs to the app that started it
|
|
501
|
+
// (see launch in client.ts) and stops when that app ends, even while it is
|
|
502
|
+
// still starting. Closing the channel on the way out keeps it from holding
|
|
503
|
+
// this process open.
|
|
504
|
+
if (!process.send) return run(root, port, idle);
|
|
505
|
+
if (!process.connected) return;
|
|
506
|
+
const owner: Owner = { left: false, stop: () => {} };
|
|
507
|
+
const left = () => {
|
|
508
|
+
owner.left = true;
|
|
509
|
+
owner.stop();
|
|
510
|
+
};
|
|
511
|
+
process.once("disconnect", left);
|
|
512
|
+
try {
|
|
513
|
+
await run(root, port, idle, owner);
|
|
514
|
+
} finally {
|
|
515
|
+
process.off("disconnect", left);
|
|
516
|
+
if (process.connected) process.disconnect();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async function run(root: string, port: number, idle: number, owner?: Owner) {
|
|
474
520
|
root = new Repo(root).toplevel();
|
|
475
521
|
process.chdir(root);
|
|
476
522
|
const home = path.join(new Repo(root).gitDir(), "latex-review/server"),
|
|
@@ -479,6 +525,8 @@ export async function serve(root: string, port = 0, idle = 12 * 3600) {
|
|
|
479
525
|
server = createServer(app);
|
|
480
526
|
let timer: NodeJS.Timeout | undefined;
|
|
481
527
|
try {
|
|
528
|
+
// Requests see the state that interrupted writes leave once finished.
|
|
529
|
+
await app.ready.catch(() => undefined);
|
|
482
530
|
try {
|
|
483
531
|
await server.listen({ host: "127.0.0.1", port });
|
|
484
532
|
} catch (error) {
|
|
@@ -506,6 +554,14 @@ export async function serve(root: string, port = 0, idle = 12 * 3600) {
|
|
|
506
554
|
};
|
|
507
555
|
process.once("SIGTERM", shutdown);
|
|
508
556
|
process.once("SIGINT", shutdown);
|
|
557
|
+
// Requests still open, such as a CLI waiting on feedback, end with it.
|
|
558
|
+
if (owner) {
|
|
559
|
+
owner.stop = () => {
|
|
560
|
+
shutdown();
|
|
561
|
+
server.server.closeAllConnections();
|
|
562
|
+
};
|
|
563
|
+
if (owner.left) owner.stop();
|
|
564
|
+
}
|
|
509
565
|
timer = setInterval(
|
|
510
566
|
() => {
|
|
511
567
|
if (now() - app.activity > idle && !app.publisher.busy()) shutdown();
|
package/engine/src/types.ts
CHANGED
|
@@ -413,6 +413,14 @@ export interface RoundDetail extends Omit<RenderedRound, "units"> {
|
|
|
413
413
|
/** The new version's complete PDF, when the host serves one, so the review
|
|
414
414
|
* can show the changes in the whole paper. */
|
|
415
415
|
pdf?: string;
|
|
416
|
+
/** The build whose PDF the strips come from, and the chapters it compiled
|
|
417
|
+
* (["full"] when it is the whole paper). */
|
|
418
|
+
build?: { key: string; targets: string[] };
|
|
419
|
+
/** While that PDF compiles, a host may show each TeX pass of it: this URL
|
|
420
|
+
* answers `?after=N` with `{ pass }`, a later pass whose PDF is `pdf?pass=`
|
|
421
|
+
* that number, `{ pass: null }` once `pdf` is complete, or `{}` to ask
|
|
422
|
+
* again. */
|
|
423
|
+
passes?: string;
|
|
416
424
|
}
|
|
417
425
|
export type TaskState =
|
|
418
426
|
"editing" | "building" | "build_failed" | "reviewing" | "revise" | "done";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Fragment,
|
|
3
3
|
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
4
5
|
useMemo,
|
|
5
6
|
useRef,
|
|
6
7
|
useState,
|
|
@@ -89,8 +90,18 @@ const clock = (ms: number) => {
|
|
|
89
90
|
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
90
91
|
};
|
|
91
92
|
|
|
93
|
+
interface Loaded {
|
|
94
|
+
pdf: PDFDocumentProxy;
|
|
95
|
+
width: number;
|
|
96
|
+
height: number;
|
|
97
|
+
labels: string[] | null;
|
|
98
|
+
// An earlier version, shown while the complete PDF compiles.
|
|
99
|
+
partial: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
92
102
|
export function FullPaper({
|
|
93
103
|
url,
|
|
104
|
+
passes,
|
|
94
105
|
changes,
|
|
95
106
|
render,
|
|
96
107
|
scrollRoot,
|
|
@@ -98,38 +109,77 @@ export function FullPaper({
|
|
|
98
109
|
onUnavailable,
|
|
99
110
|
}: {
|
|
100
111
|
url: string;
|
|
112
|
+
passes?: string;
|
|
101
113
|
changes: PlacedChange[];
|
|
102
114
|
render: (block: ChangeBlock, index: number) => ReactNode;
|
|
103
115
|
scrollRoot: RefObject<HTMLElement | null>;
|
|
104
116
|
onReady: () => void;
|
|
105
117
|
onUnavailable: () => void;
|
|
106
118
|
}) {
|
|
107
|
-
const [loaded, setLoaded] = useState<
|
|
119
|
+
const [loaded, setLoaded] = useState<Loaded | null>(null),
|
|
108
120
|
[error, setError] = useState(""),
|
|
109
|
-
[started] = useState(() => Date.now()),
|
|
110
|
-
[now, setNow] = useState(() => Date.now())
|
|
121
|
+
[started, setStarted] = useState(() => Date.now()),
|
|
122
|
+
[now, setNow] = useState(() => Date.now()),
|
|
123
|
+
shown = useRef<PDFDocumentProxy | null>(null),
|
|
124
|
+
announced = useRef(false),
|
|
125
|
+
// Where the change being reviewed was on screen before a newer version
|
|
126
|
+
// replaced the pages, so that it stays there.
|
|
127
|
+
anchor = useRef<{ stop: string; offset: number } | null>(null);
|
|
111
128
|
useEffect(() => {
|
|
112
129
|
let live = true;
|
|
130
|
+
const stop = new AbortController(),
|
|
131
|
+
tasks: ReturnType<typeof pdfjs.getDocument>[] = [];
|
|
113
132
|
setLoaded(null);
|
|
114
133
|
setError("");
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
134
|
+
setStarted(Date.now());
|
|
135
|
+
announced.current = false;
|
|
136
|
+
const open = async (source: string, partial: boolean) => {
|
|
137
|
+
const task = pdfjs.getDocument({ url: source, enableXfa: false });
|
|
138
|
+
tasks.push(task);
|
|
139
|
+
const pdf = await task.promise,
|
|
140
|
+
first = (await pdf.getPage(1)).getViewport({ scale: 1 }),
|
|
141
|
+
labels = await pdf.getPageLabels().catch(() => null);
|
|
142
|
+
if (!live) return;
|
|
143
|
+
const root = scrollRoot.current,
|
|
144
|
+
current = root?.querySelector<HTMLElement>(".paper .change.current");
|
|
145
|
+
anchor.current = root && current?.dataset.stop
|
|
146
|
+
? { stop: current.dataset.stop, offset: current.getBoundingClientRect().top - root.getBoundingClientRect().top }
|
|
147
|
+
: null;
|
|
148
|
+
setLoaded({ pdf, width: first.width, height: first.height, labels, partial });
|
|
149
|
+
};
|
|
150
|
+
void (async () => {
|
|
151
|
+
// Earlier versions while the complete PDF compiles, such as the
|
|
152
|
+
// chapters the round compiled and each TeX pass of the whole paper, so
|
|
153
|
+
// the changes show long before every page number has settled.
|
|
154
|
+
for (let after = 0; passes && live; ) {
|
|
155
|
+
const response = await fetch(`${passes}?after=${after}`, { signal: stop.signal }),
|
|
156
|
+
next = (await response.json()) as { pass?: number | null; error?: string };
|
|
157
|
+
if (!response.ok) throw new Error(next.error || `HTTP ${response.status}`);
|
|
158
|
+
if (next.pass === null) break;
|
|
159
|
+
if (typeof next.pass === "number" && next.pass > after) {
|
|
160
|
+
after = next.pass;
|
|
161
|
+
// A version that cannot be shown is skipped for the next one.
|
|
162
|
+
await open(`${url}${url.includes("?") ? "&" : "?"}pass=${after}`, true).catch(() => undefined);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (live) await open(url, false);
|
|
166
|
+
})().catch((failure: Error) => {
|
|
167
|
+
if (live) setError(failure.message);
|
|
168
|
+
});
|
|
126
169
|
return () => {
|
|
127
170
|
live = false;
|
|
128
|
-
|
|
171
|
+
stop.abort();
|
|
172
|
+
for (const task of tasks) void task.destroy();
|
|
129
173
|
};
|
|
130
|
-
}, [url]);
|
|
174
|
+
}, [url, passes]);
|
|
175
|
+
// A newer version replaces the one on screen once its pages are in place.
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
const previous = shown.current;
|
|
178
|
+
shown.current = loaded?.pdf ?? null;
|
|
179
|
+
if (previous && previous !== shown.current) void previous.loadingTask.destroy();
|
|
180
|
+
}, [loaded]);
|
|
131
181
|
useEffect(() => {
|
|
132
|
-
if (loaded || error) return;
|
|
182
|
+
if ((loaded && !loaded.partial) || error) return;
|
|
133
183
|
const timer = setInterval(() => setNow(Date.now()), 1000);
|
|
134
184
|
return () => clearInterval(timer);
|
|
135
185
|
}, [loaded, error]);
|
|
@@ -143,6 +193,11 @@ export function FullPaper({
|
|
|
143
193
|
},
|
|
144
194
|
[loaded, changes],
|
|
145
195
|
);
|
|
196
|
+
const preparing = (
|
|
197
|
+
<>
|
|
198
|
+
{t("Preparing the full PDF…")} <span className="paper-clock">{clock(now - started)}</span>
|
|
199
|
+
</>
|
|
200
|
+
);
|
|
146
201
|
if (error)
|
|
147
202
|
return (
|
|
148
203
|
<div className="paper-status" role="alert">
|
|
@@ -156,13 +211,36 @@ export function FullPaper({
|
|
|
156
211
|
return (
|
|
157
212
|
<div className="paper-status" role="status">
|
|
158
213
|
<div className="pulse" />
|
|
159
|
-
<p>
|
|
160
|
-
{t("Preparing the full PDF…")} <span className="paper-clock">{clock(now - started)}</span>
|
|
161
|
-
</p>
|
|
214
|
+
<p>{preparing}</p>
|
|
162
215
|
</div>
|
|
163
216
|
);
|
|
164
217
|
return (
|
|
165
|
-
|
|
218
|
+
<>
|
|
219
|
+
{loaded?.partial && (
|
|
220
|
+
<div className="paper-updating" role="status">
|
|
221
|
+
<span className="pulse" />
|
|
222
|
+
<span>{preparing}</span>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
<Pages
|
|
226
|
+
paper={paper}
|
|
227
|
+
render={render}
|
|
228
|
+
scrollRoot={scrollRoot}
|
|
229
|
+
onReady={() => {
|
|
230
|
+
if (!announced.current) {
|
|
231
|
+
announced.current = true;
|
|
232
|
+
onReady();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// Later versions keep the change being reviewed where it was.
|
|
236
|
+
const root = scrollRoot.current,
|
|
237
|
+
kept = anchor.current,
|
|
238
|
+
current = kept && root?.querySelector<HTMLElement>(`.paper [data-stop="${kept.stop}"]`);
|
|
239
|
+
if (root && kept && current)
|
|
240
|
+
root.scrollTop += current.getBoundingClientRect().top - root.getBoundingClientRect().top - kept.offset;
|
|
241
|
+
}}
|
|
242
|
+
/>
|
|
243
|
+
</>
|
|
166
244
|
);
|
|
167
245
|
}
|
|
168
246
|
|
|
@@ -181,8 +259,9 @@ function Pages({
|
|
|
181
259
|
ready = useRef(onReady);
|
|
182
260
|
ready.current = onReady;
|
|
183
261
|
useEffect(() => paint(box.current!, paper, scrollRoot.current), [paper, scrollRoot]);
|
|
184
|
-
// The pages exist now: the selected change can be scrolled to
|
|
185
|
-
|
|
262
|
+
// The pages exist now: the selected change can be scrolled to, before
|
|
263
|
+
// they are painted.
|
|
264
|
+
useLayoutEffect(() => ready.current(), [paper]);
|
|
186
265
|
const slice = (page: number, top: number, bottom: number) =>
|
|
187
266
|
bottom - top > 0.5 && (
|
|
188
267
|
<div className="paper-context" key={`${page}:${top}`}>
|
|
@@ -227,15 +306,18 @@ function paint(container: HTMLElement, paper: Paper, root: HTMLElement | null) {
|
|
|
227
306
|
width = container.clientWidth,
|
|
228
307
|
stopped = false;
|
|
229
308
|
const pages = () => Array.from(container.querySelectorAll<HTMLElement>(".paper-page"));
|
|
309
|
+
const free = (canvas: HTMLCanvasElement) => {
|
|
310
|
+
canvas.remove();
|
|
311
|
+
canvas.width = canvas.height = 0;
|
|
312
|
+
};
|
|
313
|
+
// Pages painted from an earlier version of the PDF stay until this one's
|
|
314
|
+
// replace them, so a newer version comes in without the pages blanking.
|
|
230
315
|
const release = (index: number) => {
|
|
231
316
|
const entry = alive.get(index);
|
|
232
|
-
if (!entry) return;
|
|
233
317
|
alive.delete(index);
|
|
234
|
-
entry
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
canvas.width = canvas.height = 0;
|
|
238
|
-
}
|
|
318
|
+
entry?.task?.cancel();
|
|
319
|
+
entry?.canvases.forEach(free);
|
|
320
|
+
pages()[index]?.querySelectorAll("canvas").forEach(free);
|
|
239
321
|
};
|
|
240
322
|
const pump = () => {
|
|
241
323
|
while (!stopped && running < 2) {
|
|
@@ -254,7 +336,7 @@ function paint(container: HTMLElement, paper: Paper, root: HTMLElement | null) {
|
|
|
254
336
|
alive.set(index, entry);
|
|
255
337
|
try {
|
|
256
338
|
const page = await paper.pdf.getPage(index + 1);
|
|
257
|
-
if (alive.get(index) !== entry) return;
|
|
339
|
+
if (stopped || alive.get(index) !== entry) return;
|
|
258
340
|
const slices = Array.from(element.querySelectorAll<HTMLElement>(".paper-slice")),
|
|
259
341
|
cssWidth = slices[0]?.clientWidth || element.clientWidth,
|
|
260
342
|
size = page.getViewport({ scale: 1 }),
|
|
@@ -265,7 +347,7 @@ function paint(container: HTMLElement, paper: Paper, root: HTMLElement | null) {
|
|
|
265
347
|
source.height = Math.floor(viewport.height);
|
|
266
348
|
entry.task = page.render({ canvas: source, viewport });
|
|
267
349
|
await entry.task.promise;
|
|
268
|
-
if (alive.get(index) !== entry) return;
|
|
350
|
+
if (stopped || alive.get(index) !== entry) return;
|
|
269
351
|
for (const slice of slices) {
|
|
270
352
|
const top = Math.round(Number(slice.dataset.top) * scale),
|
|
271
353
|
bottom = Math.min(source.height, Math.round(Number(slice.dataset.bottom) * scale));
|
|
@@ -274,12 +356,13 @@ function paint(container: HTMLElement, paper: Paper, root: HTMLElement | null) {
|
|
|
274
356
|
canvas.width = source.width;
|
|
275
357
|
canvas.height = bottom - top;
|
|
276
358
|
canvas.getContext("2d")!.drawImage(source, 0, top, source.width, bottom - top, 0, 0, source.width, bottom - top);
|
|
359
|
+
slice.querySelectorAll("canvas").forEach(free);
|
|
277
360
|
slice.append(canvas);
|
|
278
361
|
entry.canvases.push(canvas);
|
|
279
362
|
}
|
|
280
363
|
source.width = source.height = 0;
|
|
281
364
|
} catch (failure) {
|
|
282
|
-
if ((failure as Error)?.name !== "RenderingCancelledException") console.error(failure);
|
|
365
|
+
if (!stopped && (failure as Error)?.name !== "RenderingCancelledException") console.error(failure);
|
|
283
366
|
if (alive.get(index) === entry) alive.delete(index);
|
|
284
367
|
} finally {
|
|
285
368
|
running--;
|
|
@@ -305,7 +388,11 @@ function paint(container: HTMLElement, paper: Paper, root: HTMLElement | null) {
|
|
|
305
388
|
const resized = new ResizeObserver(() => {
|
|
306
389
|
if (Math.abs(container.clientWidth - width) < 1) return;
|
|
307
390
|
width = container.clientWidth;
|
|
308
|
-
|
|
391
|
+
// Repainted at the new width over the current pages.
|
|
392
|
+
for (const [index, entry] of [...alive]) {
|
|
393
|
+
entry.task?.cancel();
|
|
394
|
+
alive.delete(index);
|
|
395
|
+
}
|
|
309
396
|
pump();
|
|
310
397
|
});
|
|
311
398
|
resized.observe(container);
|
|
@@ -313,6 +400,7 @@ function paint(container: HTMLElement, paper: Paper, root: HTMLElement | null) {
|
|
|
313
400
|
stopped = true;
|
|
314
401
|
observer.disconnect();
|
|
315
402
|
resized.disconnect();
|
|
316
|
-
for
|
|
403
|
+
// The canvases stay for the next version, or go with the page.
|
|
404
|
+
for (const entry of alive.values()) entry.task?.cancel();
|
|
317
405
|
};
|
|
318
406
|
}
|
|
@@ -282,9 +282,11 @@ export function ReviewApp({
|
|
|
282
282
|
? ["pending", "accepted"].includes(decisions[id])
|
|
283
283
|
: decisions[id] === "pending",
|
|
284
284
|
);
|
|
285
|
+
// Only undecided changes can be decided: a decided one is changed back
|
|
286
|
+
// with Undo while that is possible.
|
|
285
287
|
const kinds = (units: string[]): Kind[] =>
|
|
286
288
|
!changes
|
|
287
|
-
? ["accept", "reject", "comment"]
|
|
289
|
+
? targets(units, "accept").length ? ["accept", "reject", "comment"] : []
|
|
288
290
|
: targets(units, "accept").length
|
|
289
291
|
? ["accept", "reject"]
|
|
290
292
|
: targets(units, "unstage").length
|
|
@@ -1569,6 +1571,7 @@ export function ReviewApp({
|
|
|
1569
1571
|
<Suspense fallback={<div className="paper-status" role="status"><div className="pulse" /></div>}>
|
|
1570
1572
|
<FullPaper
|
|
1571
1573
|
url={detail.pdf!}
|
|
1574
|
+
passes={detail.passes}
|
|
1572
1575
|
changes={placed}
|
|
1573
1576
|
render={(block, at) => change(block, at, true)}
|
|
1574
1577
|
scrollRoot={paper}
|