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/src/server/changes.ts
CHANGED
|
@@ -28,11 +28,12 @@ const conflict = (message: string) => Object.assign(new Error(message), { status
|
|
|
28
28
|
|
|
29
29
|
async function engine() {
|
|
30
30
|
const load = (file: string) => import(pathToFileURL(path.join(ENGINE, file)).href);
|
|
31
|
-
const [{ Builder, BuildError }, { Repo }, { loadProject, isFigure }, { diffUnits, revertUnits, withUnits, splitLines, textOf }, { opcodes }, { render, more }] = await Promise.all(
|
|
31
|
+
const [{ Builder, BuildError }, { Repo }, { loadProject, isFigure }, { diffUnits, diffUnitsAsync, revertUnits, withUnits, splitLines, textOf }, { opcodes }, { render, more }] = await Promise.all(
|
|
32
32
|
['src/core/build.ts', 'src/core/git.ts', 'src/core/config.ts', 'src/core/changes.ts', 'src/core/diff.ts', 'src/core/publish.ts'].map(load));
|
|
33
|
-
return { Builder, BuildError, Repo, loadProject, isFigure, diffUnits, revertUnits, withUnits, splitLines, textOf, opcodes, render, more } as {
|
|
33
|
+
return { Builder, BuildError, Repo, loadProject, isFigure, diffUnits, diffUnitsAsync, revertUnits, withUnits, splitLines, textOf, opcodes, render, more } as {
|
|
34
34
|
Builder: any; BuildError: any; Repo: any; loadProject: (root: string) => any; isFigure: (project: any, file: string) => boolean;
|
|
35
35
|
diffUnits: (repo: any, before: string, after: string, isFigure: (file: string) => boolean) => Unit[];
|
|
36
|
+
diffUnitsAsync: (repo: any, before: string, after: string, isFigure: (file: string) => boolean) => Promise<Unit[]>;
|
|
36
37
|
revertUnits: (old: Buffer | null, next: Buffer | null, units: Unit[]) => Buffer | null;
|
|
37
38
|
withUnits: (repo: any, before: string, after: string, units: Unit[]) => string;
|
|
38
39
|
splitLines: (text: string) => string[]; textOf: (data: Buffer | null) => string | null;
|
|
@@ -96,7 +97,8 @@ export class Changes {
|
|
|
96
97
|
if (!fresh && comparison.snapshot && Date.now() - comparison.snapshot.at < 1000) return comparison.snapshot.value;
|
|
97
98
|
const value = (async () => {
|
|
98
99
|
const { Repo } = await engine(), repo = new Repo(root);
|
|
99
|
-
|
|
100
|
+
const [head, after] = await Promise.all([repo.textAsync(['rev-parse', '--verify', '--quiet', 'HEAD^{tree}'], { check: false }), workingTree(root)]);
|
|
101
|
+
return { before: head || null, after };
|
|
100
102
|
})();
|
|
101
103
|
comparison.snapshot = { at: Date.now(), value };
|
|
102
104
|
value.catch(() => { if (comparison.snapshot?.value === value) comparison.snapshot = undefined; });
|
|
@@ -108,8 +110,8 @@ export class Changes {
|
|
|
108
110
|
private async units(projectId: string, root: string, snapshot: Snapshot) {
|
|
109
111
|
const comparison = this.comparison(projectId), key = `${snapshot.before}:${snapshot.after}`;
|
|
110
112
|
if (comparison.current?.key === key) return comparison.current.units;
|
|
111
|
-
const { Repo, loadProject, isFigure,
|
|
112
|
-
const units =
|
|
113
|
+
const { Repo, loadProject, isFigure, diffUnitsAsync } = await engine(), project = loadProject(root);
|
|
114
|
+
const units = (await diffUnitsAsync(new Repo(root), snapshot.before!, snapshot.after, file => isFigure(project, file))).filter(unit => !(unit.status === 'added' && ignorePattern(unit.file)));
|
|
113
115
|
comparison.current = { key, units };
|
|
114
116
|
return units;
|
|
115
117
|
}
|
|
@@ -118,7 +120,7 @@ export class Changes {
|
|
|
118
120
|
private async staged(root: string, snapshot: Snapshot, units: Unit[]) {
|
|
119
121
|
const { Repo, splitLines, textOf, opcodes } = await engine(), repo = new Repo(root);
|
|
120
122
|
const files = [...new Set(units.map(unit => unit.file))];
|
|
121
|
-
const work = repo.
|
|
123
|
+
const [work, index] = await Promise.all([repo.blobsAsync(snapshot.after, files), repo.blobsAsync('', files)]);
|
|
122
124
|
const staged = new Set<string>();
|
|
123
125
|
for (const unit of units) {
|
|
124
126
|
const ready = unit.whole_file ? same(index[unit.file], work[unit.file])
|
|
@@ -147,7 +149,10 @@ export class Changes {
|
|
|
147
149
|
try { return await builder.build(tree, ['full']); }
|
|
148
150
|
catch (error) { throw error instanceof BuildError ? Object.assign(new Error(what), { details: (error as any).details, log: (error as any).log }) : error; }
|
|
149
151
|
};
|
|
150
|
-
|
|
152
|
+
// The two versions compile at once; a failure is reported once both settle.
|
|
153
|
+
const builds = [build(snapshot.before!, 'The last commit could not be compiled.'), build(snapshot.after, 'The working tree could not be compiled.')];
|
|
154
|
+
await Promise.allSettled(builds);
|
|
155
|
+
const old = await builds[0], next = await builds[1];
|
|
151
156
|
job.phase = 'render';
|
|
152
157
|
// The engine compiles the commit with some of the changes when it must
|
|
153
158
|
// tell which of them changes the PDF elsewhere, such as a macro's uses.
|
package/src/server/cursor.ts
CHANGED
|
@@ -68,6 +68,7 @@ export class CursorAgent extends Adapter {
|
|
|
68
68
|
try { this.ids = JSON.parse(fs.readFileSync(this.file, 'utf8')); } catch { /* None yet. */ }
|
|
69
69
|
}
|
|
70
70
|
detect() { return this.configured(); }
|
|
71
|
+
async preload() { if (packageFile('@cursor/sdk')) await this.load(); }
|
|
71
72
|
async start() {
|
|
72
73
|
if (this.ready) return;
|
|
73
74
|
const sdk = await this.load();
|
package/src/server/manuscript.ts
CHANGED
|
@@ -7,11 +7,12 @@ import os from 'node:os';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
9
|
import { pathToFileURL } from 'node:url';
|
|
10
|
-
import { ENGINE } from './paths.ts';
|
|
10
|
+
import { ENGINE, ROOT } from './paths.ts';
|
|
11
11
|
import type { Annotation, BuildProblem, BuildProgress, GitFile, GitStatus, ManuscriptBuild, ManuscriptProblems } from '../shared.ts';
|
|
12
12
|
import { notesInstructions } from '../notes.ts';
|
|
13
13
|
import { ignorePattern } from '../commit.ts';
|
|
14
14
|
import { AnnotationPages, placeAnnotation } from './annotation-placement.ts';
|
|
15
|
+
import { annotatePdf, type ExportNote } from './annotated-pdf.ts';
|
|
15
16
|
import { collectProblems, countProblems, locateProblems, scrubLog, sortProblems, type StoredProblem } from './build-problems.ts';
|
|
16
17
|
|
|
17
18
|
type Rect = [number, number, number, number];
|
|
@@ -248,10 +249,9 @@ async function gitAction(root: string, args: string[], timeout = 60_000) {
|
|
|
248
249
|
// whose size or modification time changed.
|
|
249
250
|
export async function workingTree(root: string) {
|
|
250
251
|
const dir = gitDir(root), saved = dir && path.join(dir, 'latex-review', 'working-index');
|
|
251
|
-
const folder = fs.
|
|
252
|
+
const folder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'papilot-index-')), index = path.join(folder, 'index'), env = { GIT_INDEX_FILE: index };
|
|
252
253
|
const snapshot = async (seed: string | null | undefined) => {
|
|
253
|
-
if (seed
|
|
254
|
-
else { fs.rmSync(index, { force: true }); await git(root, ['read-tree', 'HEAD'], env).catch(() => git(root, ['read-tree', '--empty'], env)); }
|
|
254
|
+
if (!seed || !await copyIndex(seed, index)) { await fs.promises.rm(index, { force: true }); await git(root, ['read-tree', 'HEAD'], env).catch(() => git(root, ['read-tree', '--empty'], env)); }
|
|
255
255
|
await git(root, ['add', '-A'], env);
|
|
256
256
|
return (await git(root, ['write-tree'], env)).trim();
|
|
257
257
|
};
|
|
@@ -259,23 +259,52 @@ export async function workingTree(root: string) {
|
|
|
259
259
|
let tree: string;
|
|
260
260
|
try { tree = await snapshot(saved && fs.existsSync(saved) ? saved : dir && path.join(dir, 'index')); }
|
|
261
261
|
catch { tree = await snapshot(null); }
|
|
262
|
-
if (saved)
|
|
263
|
-
fs.mkdirSync(path.dirname(saved), { recursive: true });
|
|
262
|
+
if (saved) {
|
|
264
263
|
const temporary = `${saved}.${randomUUID()}.tmp`;
|
|
265
|
-
|
|
266
|
-
|
|
264
|
+
try {
|
|
265
|
+
await fs.promises.mkdir(path.dirname(saved), { recursive: true });
|
|
266
|
+
if (await copyIndex(index, temporary)) await fs.promises.rename(temporary, saved);
|
|
267
|
+
} catch { /* The next snapshot rehashes more files. */ }
|
|
268
|
+
finally { await fs.promises.rm(temporary, { force: true }); }
|
|
269
|
+
}
|
|
267
270
|
return tree;
|
|
268
|
-
} finally { fs.
|
|
271
|
+
} finally { await fs.promises.rm(folder, { recursive: true, force: true }); }
|
|
272
|
+
}
|
|
273
|
+
// Copies an index and keeps its modification time, which Git compares with
|
|
274
|
+
// each file's to catch edits made in the instant the index was written.
|
|
275
|
+
async function copyIndex(from: string, to: string) {
|
|
276
|
+
try {
|
|
277
|
+
const stat = await fs.promises.stat(from);
|
|
278
|
+
await fs.promises.copyFile(from, to);
|
|
279
|
+
await fs.promises.utimes(to, stat.atime, stat.mtime);
|
|
280
|
+
return true;
|
|
281
|
+
} catch { return false; }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
// Each page's size in points. A long paper takes tens of milliseconds, so
|
|
286
|
+
// the event loop answers other requests every few pages.
|
|
287
|
+
async function pageSizes(mupdf: any, pdf: string) {
|
|
288
|
+
const document = mupdf.Document.openDocument(await fs.promises.readFile(pdf), 'application/pdf'), pages: [number, number][] = [];
|
|
289
|
+
try {
|
|
290
|
+
for (let i = 0; i < document.countPages(); i++) {
|
|
291
|
+
if (i && i % 32 === 0) await new Promise(resolve => setImmediate(resolve));
|
|
292
|
+
const page = document.loadPage(i);
|
|
293
|
+
try { const [x0, y0, x1, y1] = page.getBounds(); pages.push([Math.round(x1 - x0), Math.round(y1 - y0)]); }
|
|
294
|
+
finally { page.destroy(); }
|
|
295
|
+
}
|
|
296
|
+
} finally { document.destroy(); }
|
|
297
|
+
return pages;
|
|
269
298
|
}
|
|
270
299
|
|
|
271
300
|
async function engine() {
|
|
272
|
-
const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter, walkText }] = await Promise.all([
|
|
301
|
+
const [{ Builder }, { Repo, gitBinary }, { loadProject }, { mupdf, PdfDocument, containsCenter, walkText }] = await Promise.all([
|
|
273
302
|
import(pathToFileURL(path.join(ENGINE, 'src/core/build.ts')).href),
|
|
274
303
|
import(pathToFileURL(path.join(ENGINE, 'src/core/git.ts')).href),
|
|
275
304
|
import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href),
|
|
276
305
|
import(pathToFileURL(path.join(ENGINE, 'src/render/pdf.ts')).href),
|
|
277
306
|
]);
|
|
278
|
-
return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter, walkText };
|
|
307
|
+
return { Builder, Repo, gitBinary, loadProject, mupdf, PdfDocument, containsCenter, walkText };
|
|
279
308
|
}
|
|
280
309
|
|
|
281
310
|
// `synctex edit` maps a PDF position (big points from the top-left corner) to
|
|
@@ -391,60 +420,157 @@ export class Manuscripts extends EventEmitter {
|
|
|
391
420
|
private placements = new Map<string, (Pick<Annotation, 'key' | 'page' | 'rect' | 'rects' | 'source'>) | null>();
|
|
392
421
|
// Placed problems per problem list; a rebuild in progress shares its predecessor's list.
|
|
393
422
|
private located = new WeakMap<StoredProblem[], Promise<BuildProblem[]>>();
|
|
423
|
+
// Papers whose working tree changed while their manuscript compiled: they
|
|
424
|
+
// compile again as soon as that build ends.
|
|
425
|
+
private stale = new Set<string>();
|
|
426
|
+
// One working-tree snapshot per paper at a time; a request made meanwhile
|
|
427
|
+
// waits for the next one, which sees every edit made before it was asked.
|
|
428
|
+
private snapshots = new Map<string, { current: Promise<string>; next?: Promise<string> }>();
|
|
429
|
+
// Per paper, the speculative build that is running and the one wanted next.
|
|
430
|
+
private speculation = new Map<string, { running: boolean; wanted?: { root: string; files: Record<string, Buffer | null> } }>();
|
|
431
|
+
// Every open event stream listens for builds.
|
|
432
|
+
constructor() { super(); this.setMaxListeners(0); }
|
|
433
|
+
/** Stops every compiler this process started, also paused ones, so that
|
|
434
|
+
* nothing keeps running after Papilot quits. */
|
|
435
|
+
async stop() {
|
|
436
|
+
const { stopAll } = await import(pathToFileURL(path.join(ENGINE, 'src/core/process.ts')).href);
|
|
437
|
+
stopAll();
|
|
438
|
+
}
|
|
439
|
+
/** Loads the PDF engine and reads Papilot's example paper with it once,
|
|
440
|
+
* before the server answers requests: compiling its WebAssembly and
|
|
441
|
+
* setting up color conversion take a few hundred milliseconds the first
|
|
442
|
+
* time, which would otherwise hold up the first PDF someone opens. */
|
|
443
|
+
async warm() {
|
|
444
|
+
try {
|
|
445
|
+
const { PdfDocument, gitBinary } = await engine(), sample = path.join(ROOT, 'examples/papilot-paper/preview/main.pdf');
|
|
446
|
+
gitBinary();
|
|
447
|
+
if (!fs.existsSync(sample)) return;
|
|
448
|
+
const document = new PdfDocument(sample);
|
|
449
|
+
try { document.page(0); } finally { document.close(); }
|
|
450
|
+
} catch { /* The first build pays for it instead. */ }
|
|
451
|
+
}
|
|
394
452
|
get busyForUpdate() { return [...this.builds.values()].some(build => build.status === 'building'); }
|
|
395
453
|
busyForProject(id: string) { return this.builds.get(id)?.status === 'building'; }
|
|
396
|
-
forgetProject(id: string) { this.builds.delete(id); }
|
|
454
|
+
forgetProject(id: string) { this.builds.delete(id); this.stale.delete(id); this.speculation.delete(id); }
|
|
397
455
|
|
|
398
456
|
// The caller verifies the example's source tree. An edit or explicit rebuild
|
|
399
457
|
// uses Builder; the original PDF can be read before TeX is installed.
|
|
400
458
|
async seedPreview(projectId: string, root: string, tree: string, pdf: string) {
|
|
401
|
-
const { mupdf } = await engine(), bytes = fs.
|
|
402
|
-
const document = mupdf.Document.openDocument(bytes, 'application/pdf');
|
|
403
|
-
const pages: [number, number][] = [];
|
|
404
|
-
try {
|
|
405
|
-
for (let i = 0; i < document.countPages(); i++) {
|
|
406
|
-
const page = document.loadPage(i);
|
|
407
|
-
try { const [x0, y0, x1, y1] = page.getBounds(); pages.push([Math.round(x1 - x0), Math.round(y1 - y0)]); }
|
|
408
|
-
finally { page.destroy(); }
|
|
409
|
-
}
|
|
410
|
-
} finally { document.destroy(); }
|
|
459
|
+
const { mupdf } = await engine(), bytes = await fs.promises.readFile(pdf), pages = await pageSizes(mupdf, pdf);
|
|
411
460
|
const key = createHash('sha256').update(tree).update(bytes).digest('hex').slice(0, 16);
|
|
412
461
|
this.builds.set(projectId, { tree, status: 'ready', key, pdf, root, sourceDir: '', pages,
|
|
413
|
-
builtAt: fs.
|
|
462
|
+
builtAt: (await fs.promises.stat(pdf)).mtimeMs, problems: [], problemsKey: key });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// The working tree as a Git tree. Requests made while a snapshot runs share
|
|
466
|
+
// the next one, which starts when it ends and so sees every edit made
|
|
467
|
+
// before they asked.
|
|
468
|
+
private snapshot(root: string): Promise<string> {
|
|
469
|
+
const entry = this.snapshots.get(root);
|
|
470
|
+
if (entry) return entry.next ??= entry.current.then(() => undefined, () => undefined).then(() => this.takeSnapshot(root));
|
|
471
|
+
return this.takeSnapshot(root);
|
|
472
|
+
}
|
|
473
|
+
private takeSnapshot(root: string) {
|
|
474
|
+
const current = workingTree(root);
|
|
475
|
+
this.snapshots.set(root, { current });
|
|
476
|
+
void current.then(() => undefined, () => undefined).then(() => {
|
|
477
|
+
const entry = this.snapshots.get(root);
|
|
478
|
+
if (entry?.current === current && !entry.next) this.snapshots.delete(root);
|
|
479
|
+
});
|
|
480
|
+
return current;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// The view polls every second and a half while a build runs; Git's status
|
|
484
|
+
// costs a walk of the working tree, which competes with TeX, so it is
|
|
485
|
+
// read again at most every few seconds then, and after Papilot's own writes.
|
|
486
|
+
private statuses = new Map<string, { at: number; value: Promise<GitStatus> }>();
|
|
487
|
+
private gitStatus(root: string, building: boolean) {
|
|
488
|
+
const cached = this.statuses.get(root);
|
|
489
|
+
if (building && cached && Date.now() - cached.at < 5000) return cached.value;
|
|
490
|
+
const value = gitStatus(root);
|
|
491
|
+
this.statuses.set(root, { at: Date.now(), value });
|
|
492
|
+
value.catch(() => { if (this.statuses.get(root)?.value === value) this.statuses.delete(root); });
|
|
493
|
+
return value;
|
|
414
494
|
}
|
|
415
495
|
|
|
416
496
|
async state(projectId: string, root: string, rebuild = false): Promise<{ git: GitStatus; build: ManuscriptBuild }> {
|
|
417
|
-
const current = this.builds.get(projectId);
|
|
497
|
+
const current = this.builds.get(projectId), building = current?.status === 'building';
|
|
418
498
|
// Polling an in-flight build needs its status, not another source snapshot.
|
|
419
499
|
// Once it finishes, the next read checks for edits made during compilation.
|
|
420
|
-
const [status, tree] = await Promise.all([gitStatus(root),
|
|
500
|
+
const [status, tree] = await Promise.all([this.gitStatus(root, building), building ? current.tree : this.snapshot(root)]);
|
|
421
501
|
let build = this.builds.get(projectId);
|
|
422
502
|
if (!build || (build.status !== 'building' && (build.tree !== tree || rebuild))) build = this.start(projectId, root, tree, build, rebuild);
|
|
423
503
|
const { status: state, key, pages, builtAt, error } = build;
|
|
424
504
|
return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready', problems: build.problems && countProblems(build.problems), ...(state === 'building' && build.progress ? { progress: build.progress } : {}) } };
|
|
425
505
|
}
|
|
426
506
|
|
|
507
|
+
/** Compiles the working tree if it changed, without waiting for anyone to
|
|
508
|
+
* look: after a commit, an applied task or a Git action, the PDF is on its
|
|
509
|
+
* way before the manuscript is opened. */
|
|
510
|
+
async refresh(projectId: string, root: string) {
|
|
511
|
+
this.statuses.delete(root);
|
|
512
|
+
if (this.builds.get(projectId)?.status === 'building') { this.stale.add(projectId); return; }
|
|
513
|
+
const tree = await this.snapshot(root), build = this.builds.get(projectId);
|
|
514
|
+
if (build?.status === 'building') { if (build.tree !== tree) this.stale.add(projectId); return; }
|
|
515
|
+
if (!build || build.tree !== tree) this.start(projectId, root, tree, build);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Compiles in the background what the manuscript will be once the given
|
|
519
|
+
* files are written into the working tree, such as a reviewed task that is
|
|
520
|
+
* about to be applied. When that happens, the build of the working tree is
|
|
521
|
+
* the same build, so its PDF is ready or already on its way. A build
|
|
522
|
+
* someone waits for always runs first. */
|
|
523
|
+
async predict(projectId: string, root: string, files: Record<string, Buffer | null>) {
|
|
524
|
+
let entry = this.speculation.get(projectId);
|
|
525
|
+
if (!entry) this.speculation.set(projectId, entry = { running: false });
|
|
526
|
+
entry.wanted = { root, files };
|
|
527
|
+
if (entry.running) return;
|
|
528
|
+
entry.running = true;
|
|
529
|
+
try {
|
|
530
|
+
while (entry.wanted && this.speculation.get(projectId) === entry) {
|
|
531
|
+
const wanted: { root: string; files: Record<string, Buffer | null> } = entry.wanted;
|
|
532
|
+
entry.wanted = undefined;
|
|
533
|
+
try {
|
|
534
|
+
const { Builder, Repo, loadProject } = await engine(), repo = new Repo(wanted.root);
|
|
535
|
+
const tree = await this.snapshot(wanted.root), predicted = await repo.treeWithAsync(tree, wanted.files);
|
|
536
|
+
if (predicted === tree) continue;
|
|
537
|
+
await new Builder(loadProject(wanted.root), repo).build(predicted, ['full'], null, { priority: 'background' });
|
|
538
|
+
} catch { /* A build that fails here fails again, and is reported, when it is needed. */ }
|
|
539
|
+
}
|
|
540
|
+
} finally { entry.running = false; }
|
|
541
|
+
}
|
|
542
|
+
|
|
427
543
|
// Keep the previous pages visible while a newer working tree compiles.
|
|
428
544
|
private start(projectId: string, root: string, tree: string, previous?: Build, force = false) {
|
|
429
545
|
if (previous?.key && previous.pdf) this.oldPdfs.set(`${root}:${previous.key}`, previous.pdf);
|
|
430
546
|
const build: Build = { tree, status: 'building', key: previous?.key, pdf: previous?.pdf, root: previous?.root, sourceDir: previous?.sourceDir, pages: previous?.pages, builtAt: previous?.builtAt, problems: previous?.problems, problemsKey: previous?.problemsKey, log: previous?.log, logFolder: previous?.logFolder };
|
|
431
547
|
this.builds.set(projectId, build);
|
|
548
|
+
this.stale.delete(projectId);
|
|
549
|
+
this.emit('change', projectId);
|
|
432
550
|
void (async () => {
|
|
433
551
|
let project: { source_dir?: string; doc_dir?: string; state: string } | undefined;
|
|
434
552
|
try {
|
|
435
553
|
const { Builder, Repo, loadProject, mupdf } = await engine();
|
|
436
554
|
project = loadProject(root);
|
|
437
555
|
const startedAt = Date.now();
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
556
|
+
const sizes = (pdf: string) => pageSizes(mupdf, pdf);
|
|
557
|
+
// A pass that latexmk follows with more work already shows the edit;
|
|
558
|
+
// its pages replace the previous PDF while references settle.
|
|
559
|
+
const pass = async (preview: { pdf: string; root: string }) => {
|
|
560
|
+
if (this.builds.get(projectId) !== build || build.status !== 'building') return;
|
|
561
|
+
const pages = await sizes(preview.pdf), key = createHash('sha256').update(preview.pdf).digest('hex').slice(0, 16);
|
|
562
|
+
if (this.builds.get(projectId) !== build || build.status !== 'building') return;
|
|
563
|
+
this.oldPdfs.set(`${root}:${key}`, preview.pdf);
|
|
564
|
+
Object.assign(build, { key, pdf: preview.pdf, root: preview.root, sourceDir: project!.source_dir || '', pages, builtAt: Date.now() });
|
|
565
|
+
this.emit('change', projectId);
|
|
566
|
+
};
|
|
567
|
+
const result = await new Builder(project, new Repo(root)).build(tree, ['full'], null, { force, priority: 'foreground', pass: (preview: { pdf: string; root: string }) => void pass(preview).catch(() => undefined), progress: (progress: Omit<BuildProgress, 'startedAt'>) => { if (this.builds.get(projectId) === build) build.progress = { ...progress, startedAt }; } });
|
|
568
|
+
const pages = await sizes(result.pdf);
|
|
443
569
|
const key = createHash('sha256').update(result.key + result.pdf).digest('hex').slice(0, 16);
|
|
444
570
|
if (this.builds.get(projectId) !== build) return;
|
|
445
571
|
const log = String(result.pdf).replace(/\.pdf$/, '.log');
|
|
446
572
|
let problems: StoredProblem[] = [];
|
|
447
|
-
try { problems = collectProblems(fs.
|
|
573
|
+
try { problems = collectProblems(await fs.promises.readFile(log), { cwd: result.cwd, root: result.root, sourceDir: project!.source_dir || '', outDir: path.dirname(result.pdf) }); } catch { /* A build without a readable log reports no problems. */ }
|
|
448
574
|
Object.assign(build, { status: 'ready', key, pdf: result.pdf, root: result.root, sourceDir: project!.source_dir || '', pages, builtAt: Date.now(), error: undefined, problems, problemsKey: key, log: fs.existsSync(log) ? log : undefined, logFolder: path.dirname(result.root) });
|
|
449
575
|
// Place the problems now, so the list is ready when someone opens it.
|
|
450
576
|
if (problems.length) void this.problems(projectId).catch(() => undefined);
|
|
@@ -456,7 +582,7 @@ export class Manuscripts extends EventEmitter {
|
|
|
456
582
|
// build; its sources are gone, so files resolve in the working tree.
|
|
457
583
|
let problems: StoredProblem[] = [];
|
|
458
584
|
if (detail.log && project) try {
|
|
459
|
-
const kept = fs.
|
|
585
|
+
const kept = await fs.promises.readFile(detail.log), marker = Buffer.from('\n\n--- TeX log ---\n'), at = kept.indexOf(marker);
|
|
460
586
|
// TeX stopped in its first pass, before references resolve, so only
|
|
461
587
|
// the errors of a failed run mean anything.
|
|
462
588
|
if (at >= 0) problems = collectProblems(kept.subarray(at + marker.length), { cwd: path.join(root, project.doc_dir || '.'), root: path.join(root, project.source_dir || '.'), sourceDir: project.source_dir || '' })
|
|
@@ -468,11 +594,15 @@ export class Manuscripts extends EventEmitter {
|
|
|
468
594
|
const unexplained = (detail.details || []).filter(line => line.trim() && !/==> Fatal error occurred|^Emergency stop/.test(line) && !problems.some(problem => plain(line).includes(plain(problem.message).replace(/^[^:]+:\d+: /, ''))));
|
|
469
595
|
const logFolder = detail.log && project ? path.join(project.state, 'cache/builds', path.basename(detail.log, '.log')) : undefined;
|
|
470
596
|
if (!problems.length || unexplained.length) problems.push({ kind: 'error', severity: 'error', message: scrubLog([...(problems.length ? [] : [detail.message || 'The manuscript could not be compiled.']), ...unexplained].join('\n'), logFolder) });
|
|
471
|
-
// The previous PDF stays on screen, and so do its warnings and places
|
|
597
|
+
// The previous PDF stays on screen, and so do its warnings and places;
|
|
598
|
+
// a pass of this build that was shown went with its folder.
|
|
599
|
+
Object.assign(build, { key: previous?.key, pdf: previous?.pdf, root: previous?.root, sourceDir: previous?.sourceDir, pages: previous?.pages, builtAt: previous?.builtAt });
|
|
472
600
|
const shown = build.problemsKey && build.problemsKey === build.key ? (build.problems || []).filter(problem => problem.severity !== 'error') : [];
|
|
473
601
|
Object.assign(build, { status: 'failed', error: message, problems: [...problems, ...shown], problemsKey: shown.length ? build.problemsKey : undefined, log: detail.log && fs.existsSync(detail.log) ? detail.log : undefined, logFolder });
|
|
474
602
|
}
|
|
475
603
|
this.emit('change', projectId);
|
|
604
|
+
// Edits made while this build ran are compiled next, right away.
|
|
605
|
+
if (this.stale.delete(projectId)) void this.refresh(projectId, root).catch(() => undefined);
|
|
476
606
|
})();
|
|
477
607
|
return build;
|
|
478
608
|
}
|
|
@@ -482,35 +612,52 @@ export class Manuscripts extends EventEmitter {
|
|
|
482
612
|
async reanchor(projectId: string, root: string, notes: Annotation[]): Promise<Annotation[]> {
|
|
483
613
|
const build = this.builds.get(projectId);
|
|
484
614
|
if (!build?.pdf || !build.key || build.status !== 'ready' || !notes.some(note => note.key !== build.key)) return notes;
|
|
615
|
+
// Every poll asks again; placements already checked need no PDF opened.
|
|
616
|
+
const cacheKey = (note: Annotation) => JSON.stringify([projectId, build.key, note.key, note.page, note.rect, note.rects, note.quote]);
|
|
617
|
+
if (notes.every(note => note.key === build.key || this.placements.has(cacheKey(note))))
|
|
618
|
+
return notes.map(note => { const placement = note.key === build.key ? null : this.placements.get(cacheKey(note)); return placement ? { ...note, ...placement } : note; });
|
|
619
|
+
// Placing reads every page of a long PDF between other requests; a poll
|
|
620
|
+
// that arrives meanwhile shares the work instead of repeating it.
|
|
621
|
+
const batch = JSON.stringify([projectId, build.key, notes.map(note => cacheKey(note))]);
|
|
622
|
+
let placing = this.reanchoring.get(batch);
|
|
623
|
+
if (!placing) {
|
|
624
|
+
placing = this.place(projectId, root, notes, build.pdf, build.key, cacheKey);
|
|
625
|
+
this.reanchoring.set(batch, placing);
|
|
626
|
+
void placing.then(() => undefined, () => undefined).then(() => this.reanchoring.delete(batch));
|
|
627
|
+
}
|
|
628
|
+
return placing;
|
|
629
|
+
}
|
|
630
|
+
private reanchoring = new Map<string, Promise<Annotation[]>>();
|
|
631
|
+
private async place(projectId: string, root: string, notes: Annotation[], pdfFile: string, buildKey: string, cacheKey: (note: Annotation) => string): Promise<Annotation[]> {
|
|
485
632
|
const { PdfDocument } = await engine();
|
|
486
|
-
const current = new PdfDocument(
|
|
633
|
+
const current = new PdfDocument(pdfFile), target = new AnnotationPages(current);
|
|
487
634
|
const old = new Map<string, { document: InstanceType<typeof PdfDocument>; pages: AnnotationPages }>();
|
|
488
635
|
try {
|
|
489
636
|
const output: Annotation[] = [];
|
|
490
637
|
for (const note of notes) {
|
|
491
|
-
if (note.key ===
|
|
492
|
-
const
|
|
493
|
-
if (!this.placements.has(
|
|
638
|
+
if (note.key === buildKey) { output.push(note); continue; }
|
|
639
|
+
const key = cacheKey(note);
|
|
640
|
+
if (!this.placements.has(key)) {
|
|
494
641
|
let placement: Pick<Annotation, 'key' | 'page' | 'rect' | 'rects' | 'source'> | null = null;
|
|
495
642
|
try {
|
|
496
643
|
const pdf = await this.cachedPdf(root, note.key);
|
|
497
644
|
if (pdf) {
|
|
498
645
|
if (!old.has(pdf)) { const document = new PdfDocument(pdf); old.set(pdf, { document, pages: new AnnotationPages(document) }); }
|
|
499
|
-
const match = placeAnnotation(note, old.get(pdf)!.pages, target);
|
|
646
|
+
const match = await placeAnnotation(note, old.get(pdf)!.pages, target);
|
|
500
647
|
if (match) {
|
|
501
|
-
const { source } = await this.locate(projectId,
|
|
502
|
-
placement = { ...match, key:
|
|
648
|
+
const { source } = await this.locate(projectId, buildKey, match.page, match.rect, match.rects, note.quote);
|
|
649
|
+
placement = { ...match, key: buildKey, source };
|
|
503
650
|
}
|
|
504
651
|
}
|
|
505
652
|
} catch { /* Keep the annotation intact when its position cannot be verified. */ }
|
|
506
|
-
this.placements.set(
|
|
653
|
+
this.placements.set(key, placement);
|
|
507
654
|
}
|
|
508
|
-
const placement = this.placements.get(
|
|
655
|
+
const placement = this.placements.get(key);
|
|
509
656
|
output.push(placement ? { ...note, ...placement } : note);
|
|
510
657
|
}
|
|
511
658
|
if (this.placements.size > 2000) this.placements.clear();
|
|
512
659
|
if (this.oldPdfs.size > 500) this.oldPdfs.clear();
|
|
513
|
-
return this.builds.get(projectId)?.key ===
|
|
660
|
+
return this.builds.get(projectId)?.key === buildKey ? output : notes;
|
|
514
661
|
} finally { current.close(); for (const entry of old.values()) entry.document.close(); }
|
|
515
662
|
}
|
|
516
663
|
|
|
@@ -525,14 +672,21 @@ export class Manuscripts extends EventEmitter {
|
|
|
525
672
|
if (!placed) {
|
|
526
673
|
placed = (async () => {
|
|
527
674
|
const { PdfDocument } = await engine();
|
|
528
|
-
|
|
675
|
+
// Most problems need only a page's size; its text, which takes up to
|
|
676
|
+
// a hundred milliseconds to read on a busy page, is read when a
|
|
677
|
+
// missing reference or citation is searched for on it.
|
|
678
|
+
const document = new PdfDocument(pdf), pages = new Map<number, { destroy(): void; getBounds(): number[]; search(needle: string, options: object): number[][][] }>();
|
|
529
679
|
try {
|
|
530
680
|
return sortProblems(await locateProblems(problems, pdf, page => {
|
|
531
681
|
if (!Number.isInteger(page) || page < 0 || page >= document.length) return null;
|
|
532
|
-
if (!pages.has(page)) pages.set(page, document.
|
|
533
|
-
|
|
682
|
+
if (!pages.has(page)) pages.set(page, document.raw.loadPage(page));
|
|
683
|
+
const raw = pages.get(page)!, bounds = raw.getBounds();
|
|
684
|
+
return {
|
|
685
|
+
bounds, width: bounds[2] - bounds[0], height: bounds[3] - bounds[1],
|
|
686
|
+
search: (needle: string) => raw.search(needle, {}).flat().map(q => [Math.min(q[0], q[2], q[4], q[6]), Math.min(q[1], q[3], q[5], q[7]), Math.max(q[0], q[2], q[4], q[6]), Math.max(q[1], q[3], q[5], q[7])]),
|
|
687
|
+
};
|
|
534
688
|
}));
|
|
535
|
-
} finally { for (const page of pages.values()) page.
|
|
689
|
+
} finally { for (const page of pages.values()) page.destroy(); document.close(); }
|
|
536
690
|
})();
|
|
537
691
|
placed.catch(() => this.located.delete(problems));
|
|
538
692
|
this.located.set(problems, placed);
|
|
@@ -580,14 +734,16 @@ export class Manuscripts extends EventEmitter {
|
|
|
580
734
|
const { loadProject } = await engine();
|
|
581
735
|
const cache = path.join(loadProject(root).state, 'cache/builds');
|
|
582
736
|
let found: string | null = null;
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
737
|
+
const entries = await fs.promises.readdir(cache, { withFileTypes: true }).catch(() => []);
|
|
738
|
+
for (let at = 0; at < entries.length && !found; at += 32)
|
|
739
|
+
await Promise.all(entries.slice(at, at + 32).map(async entry => {
|
|
740
|
+
if (!entry.isDirectory() || entry.name.endsWith('.lock')) return;
|
|
741
|
+
try {
|
|
742
|
+
const folder = path.join(cache, entry.name), saved = JSON.parse(await fs.promises.readFile(path.join(folder, 'result.json'), 'utf8'));
|
|
743
|
+
if (typeof saved.pdf !== 'string' || !path.resolve(saved.pdf).startsWith(path.resolve(folder) + path.sep)) return;
|
|
744
|
+
if (createHash('sha256').update(saved.key + saved.pdf).digest('hex').slice(0, 16) === key && fs.existsSync(saved.pdf)) found = saved.pdf;
|
|
745
|
+
} catch { /* Incomplete builds have no usable PDF. */ }
|
|
746
|
+
}));
|
|
591
747
|
this.oldPdfs.set(lookup, found); return found;
|
|
592
748
|
}
|
|
593
749
|
|
|
@@ -597,7 +753,7 @@ export class Manuscripts extends EventEmitter {
|
|
|
597
753
|
const key = state.build.key, { PdfDocument } = await engine();
|
|
598
754
|
const original = new PdfDocument(pdf), current = new PdfDocument(this.pdf(projectId, key));
|
|
599
755
|
try {
|
|
600
|
-
const match = placeAnnotation(note, new AnnotationPages(original), new AnnotationPages(current));
|
|
756
|
+
const match = await placeAnnotation(note, new AnnotationPages(original), new AnnotationPages(current));
|
|
601
757
|
if (!match) throw new Error('This passage could not be located reliably in the current manuscript. The suggestion remains in the review task.');
|
|
602
758
|
const { source } = await this.locate(projectId, key, match.page, match.rect, match.rects, note.quote);
|
|
603
759
|
const latest = await this.state(projectId, root);
|
|
@@ -639,6 +795,12 @@ export class Manuscripts extends EventEmitter {
|
|
|
639
795
|
return build.pdf;
|
|
640
796
|
}
|
|
641
797
|
|
|
798
|
+
// The PDF with the paper's notes drawn on it, to share with people outside Papilot.
|
|
799
|
+
async annotatedPdf(projectId: string, key: string, notes: ExportNote[]) {
|
|
800
|
+
const pdf = this.pdf(projectId, key), { mupdf } = await engine();
|
|
801
|
+
return annotatePdf(mupdf, fs.readFileSync(pdf), notes);
|
|
802
|
+
}
|
|
803
|
+
|
|
642
804
|
// A note keeps the text under the marked region and its LaTeX source, so it
|
|
643
805
|
// stays meaningful after the manuscript changes and the preview recompiles.
|
|
644
806
|
// A text selection arrives as one rectangle per line plus the selected text;
|