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.
Files changed (44) hide show
  1. package/dist/assets/index-BIc9Mnim.css +1 -0
  2. package/dist/assets/index-MvQvvv3I.js +267 -0
  3. package/dist/assets/{math-C7ENJr-a.js → math-CBzEH7Y2.js} +1 -1
  4. package/dist/assets/{pdf-view-B2xQuHmI.js → pdf-view-ByftRBhD.js} +2 -2
  5. package/dist/index.html +2 -2
  6. package/engine/dist/web/assets/{FullPaper-DlgZ1MOz.js → FullPaper-DWwiT43p.js} +2 -2
  7. package/engine/dist/web/assets/{index-BCgVChtV.js → index-DBYJK8Uy.js} +4 -4
  8. package/engine/dist/web/assets/{index-D3_ZFdT8.css → index-DGkq8xQx.css} +1 -1
  9. package/engine/dist/web/index.html +2 -2
  10. package/engine/src/cli.ts +3 -0
  11. package/engine/src/client.ts +24 -7
  12. package/engine/src/core/build.ts +357 -93
  13. package/engine/src/core/changes.ts +39 -10
  14. package/engine/src/core/config.ts +2 -2
  15. package/engine/src/core/current-commit.ts +34 -34
  16. package/engine/src/core/delivery.ts +83 -30
  17. package/engine/src/core/files.ts +19 -0
  18. package/engine/src/core/git.ts +283 -92
  19. package/engine/src/core/process.ts +124 -11
  20. package/engine/src/core/publish.ts +59 -50
  21. package/engine/src/core/reconcile.ts +23 -21
  22. package/engine/src/core/review.ts +43 -24
  23. package/engine/src/core/tasks.ts +85 -28
  24. package/engine/src/core/view.ts +8 -8
  25. package/engine/src/server.ts +62 -6
  26. package/engine/src/types.ts +8 -0
  27. package/engine/src/web/FullPaper.tsx +122 -34
  28. package/engine/src/web/review.tsx +4 -1
  29. package/engine/src/web/style.css +24 -0
  30. package/package.json +1 -1
  31. package/src/server/agents.ts +3 -0
  32. package/src/server/annotated-pdf.ts +392 -0
  33. package/src/server/annotation-placement.ts +18 -3
  34. package/src/server/app.ts +68 -10
  35. package/src/server/changes.ts +12 -7
  36. package/src/server/cursor.ts +1 -0
  37. package/src/server/manuscript.ts +218 -56
  38. package/src/server/pdf.ts +176 -23
  39. package/src/server/references.ts +10 -2
  40. package/src/server/review.ts +6 -2
  41. package/src/server/start.ts +4 -1
  42. package/src/server/workspace.ts +18 -0
  43. package/dist/assets/index-Dm7AYM9e.js +0 -267
  44. package/dist/assets/index-PFQDdu53.css +0 -1
@@ -968,6 +968,30 @@ kbd {
968
968
  .paper-clock {
969
969
  font-variant-numeric: tabular-nums;
970
970
  }
971
+ /* Earlier pages are on screen while the complete PDF compiles; the notice
972
+ sits over the page's right margin rather than its text. */
973
+ .paper-updating {
974
+ position: sticky;
975
+ top: 8px;
976
+ z-index: 3;
977
+ display: flex;
978
+ align-items: center;
979
+ gap: 8px;
980
+ width: fit-content;
981
+ margin: 0 8px 8px auto;
982
+ padding: 4px 12px;
983
+ border: 1px solid var(--line);
984
+ border-radius: 999px;
985
+ background: var(--bar);
986
+ box-shadow: var(--shadow);
987
+ color: var(--muted);
988
+ font-size: 13px;
989
+ }
990
+ .paper-updating .pulse {
991
+ width: 8px;
992
+ height: 8px;
993
+ margin: 0;
994
+ }
971
995
  .waiting p {
972
996
  margin: 6px 0;
973
997
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
4
4
  "description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
5
5
  "keywords": [
6
6
  "latex",
@@ -24,6 +24,8 @@ export interface AgentRuntime extends EventEmitter {
24
24
  // Whether the agent is present on this host (its CLI, SDK credentials or
25
25
  // key), checked without starting it. Agents that are not found are hidden.
26
26
  detect?(): boolean;
27
+ /** Loads an SDK this host already has, without starting the agent. */
28
+ preload?(): Promise<void>;
27
29
  start(): Promise<void>;
28
30
  request(method: string, params: any, timeout?: number): Promise<any>;
29
31
  respond(id: string | number, result: unknown): void;
@@ -192,6 +194,7 @@ export class ClaudeAgent extends Adapter {
192
194
  private findCli: () => string | null = () => findExecutable(process.env.PAPILOT_CLAUDE_BIN || 'claude'),
193
195
  private launch: Launch = (command, args, options) => spawn(command, args, { ...options, stdio: ['pipe', 'pipe', 'pipe'] })) { super(); }
194
196
  detect() { return Boolean(this.findCli()) || claudeConfigured(); }
197
+ async preload() { if (packageFile('@anthropic-ai/claude-agent-sdk')) await this.load(); }
195
198
  // The environment Claude Code runs with; undefined keeps Papilot's own.
196
199
  protected environment(): Record<string, string> | undefined { return undefined; }
197
200
  async start() {
@@ -0,0 +1,392 @@
1
+ import type * as Mupdf from 'mupdf';
2
+ import { GROUP_COLORS, canonicalGroup, type NoteGroup } from '../note-groups.ts';
3
+
4
+ type Rect = [number, number, number, number];
5
+ // A note as the export draws it. Rectangles are fractions of the page, as the
6
+ // PDF view stores them; a note without rects marks a region.
7
+ export interface ExportNote { page: number; rect: Rect; rects?: Rect[]; comment: string; group?: NoteGroup; review?: boolean; author?: string }
8
+
9
+ type Rgb = [number, number, number];
10
+ const rgb = (hex: string): Rgb => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16) / 255) as Rgb;
11
+ const PANEL = rgb('#f7f6f3'), DIVIDER = rgb('#e4e1da'), CARD = rgb('#ffffff'), CARD_LINE = rgb('#e2dfd8');
12
+ const TEXT = rgb('#1c1c1c'), AUTHOR = rgb('#6b6b6b');
13
+ // The PDF view's colours: blue for a personal note, amber for an AI finding,
14
+ // and each colour group's own swatch, with dark ink on yellow.
15
+ function colors(note: ExportNote) {
16
+ if (note.group) {
17
+ const group = canonicalGroup(note.group), color = rgb(GROUP_COLORS[group]);
18
+ return { mark: color, tag: color, ink: rgb(group === 'yellow' ? '#422f00' : '#ffffff') };
19
+ }
20
+ if (note.review) return { mark: rgb('#d97706'), tag: rgb('#92400e'), ink: rgb('#ffffff') };
21
+ return { mark: rgb('#2563eb'), tag: rgb('#2563eb'), ink: rgb('#ffffff') };
22
+ }
23
+
24
+ // Latin, Greek and Cyrillic set in Nimbus Sans; anything it lacks (Chinese,
25
+ // Japanese, Korean, most symbols) falls back to MuPDF's Droid Sans Fallback.
26
+ type Style = 'regular' | 'bold';
27
+ const FONT_NAMES: Record<Style | 'cjk', string> = { regular: 'Helvetica', bold: 'Helvetica-Bold', cjk: 'zh-Hans' };
28
+ const RESOURCE: Record<Style | 'cjk', string> = { regular: 'PplR', bold: 'PplB', cjk: 'PplC' };
29
+ interface Run { font: Style | 'cjk'; gids: number[]; width: number }
30
+
31
+ class Type {
32
+ private fonts = new Map<Style | 'cjk', Mupdf.Font>();
33
+ readonly used = new Map<Style | 'cjk', Set<number>>();
34
+ private advances = new Map<string, { font: Style | 'cjk'; gid: number; advance: number } | null>();
35
+ constructor(private mupdf: typeof Mupdf) {}
36
+ font(kind: Style | 'cjk') {
37
+ let font = this.fonts.get(kind);
38
+ if (!font) { font = new this.mupdf.Font(FONT_NAMES[kind]); this.fonts.set(kind, font); }
39
+ return font;
40
+ }
41
+ private glyph(char: string, style: Style) {
42
+ const key = style + char;
43
+ if (!this.advances.has(key)) {
44
+ const code = char.codePointAt(0)!;
45
+ let found: { font: Style | 'cjk'; gid: number; advance: number } | null = null;
46
+ for (const kind of [style, 'cjk'] as const) {
47
+ const font = this.font(kind), gid = font.encodeCharacter(code);
48
+ if (gid > 0) { found = { font: kind, gid, advance: font.advanceGlyph(gid) }; break; }
49
+ }
50
+ this.advances.set(key, found);
51
+ }
52
+ return this.advances.get(key)!;
53
+ }
54
+ // The glyph runs for a line, one per font, with widths in points.
55
+ runs(text: string, style: Style, size: number): Run[] {
56
+ const runs: Run[] = [];
57
+ for (const char of text) {
58
+ const glyph = this.glyph(char, style) || this.glyph('?', style);
59
+ if (!glyph) continue;
60
+ let run = runs[runs.length - 1];
61
+ if (!run || run.font !== glyph.font) { run = { font: glyph.font, gids: [], width: 0 }; runs.push(run); }
62
+ run.gids.push(glyph.gid); run.width += glyph.advance * size;
63
+ }
64
+ return runs;
65
+ }
66
+ width(text: string, style: Style, size: number) { return this.runs(text, style, size).reduce((sum, run) => sum + run.width, 0); }
67
+ // Wraps at spaces, and between any two CJK characters, keeping closing
68
+ // punctuation on its line. Text past maxLines ends with an ellipsis.
69
+ wrap(text: string, style: Style, size: number, width: number, maxLines = Infinity) {
70
+ const lines: string[] = [];
71
+ for (const paragraph of text.replace(/\r\n?/g, '\n').split('\n')) {
72
+ const tokens: string[] = [];
73
+ for (const char of paragraph) {
74
+ const last = tokens[tokens.length - 1];
75
+ if (/\s/.test(char)) { if (last !== ' ') tokens.push(' '); }
76
+ else if (WIDE.test(char)) tokens.push(char);
77
+ else if (CLOSING.test(char) && last && last !== ' ') tokens[tokens.length - 1] += char;
78
+ else if (last && last !== ' ' && !WIDE.test(last.slice(-1))) tokens[tokens.length - 1] += char;
79
+ else tokens.push(char);
80
+ }
81
+ let line = '';
82
+ const push = () => { lines.push(line.trimEnd()); line = ''; };
83
+ for (const token of tokens) {
84
+ if (token === ' ') { if (line) line += ' '; continue; }
85
+ if (this.width(line + token, style, size) <= width) { line += token; continue; }
86
+ if (line.trim()) push();
87
+ // A word wider than the card breaks between letters.
88
+ for (const char of token) {
89
+ if (line && this.width(line + char, style, size) > width) push();
90
+ line += char;
91
+ }
92
+ }
93
+ if (line.trim() || !lines.length || paragraph.trim()) push();
94
+ }
95
+ while (lines.length > 1 && !lines[lines.length - 1]) lines.pop();
96
+ if (lines.length <= maxLines) return lines;
97
+ const kept = lines.slice(0, maxLines);
98
+ let last = kept[maxLines - 1].trimEnd();
99
+ while (last && this.width(last + '…', style, size) > width) last = [...last].slice(0, -1).join('').trimEnd();
100
+ kept[maxLines - 1] = last + '…';
101
+ return kept;
102
+ }
103
+ record(runs: Run[]) {
104
+ for (const run of runs) {
105
+ let set = this.used.get(run.font);
106
+ if (!set) { set = new Set(); this.used.set(run.font, set); }
107
+ for (const gid of run.gids) set.add(gid);
108
+ }
109
+ }
110
+ }
111
+ const WIDE = /[ᄀ-ᇿ⺀-⿿ -ヿ㄀-ㇿ㈀-鿿가-힯豈-﫿︰-﹏＀-￯]/u;
112
+ const CLOSING = /[,。、!?;:)」』】〉》”’,.!?;:)\]}%]/u;
113
+
114
+ // Content stream drawing in MuPDF's page space: points, origin at the page's
115
+ // top-left corner, y growing downward.
116
+ class Canvas {
117
+ private ops: string[] = [];
118
+ private state = '';
119
+ constructor(private type: Type) {}
120
+ private n = (value: number) => (Math.round(value * 1000) / 1000).toString();
121
+ private fill(color: Rgb) { this.ops.push(`${color.map(this.n).join(' ')} rg`); }
122
+ private stroke(color: Rgb) { this.ops.push(`${color.map(this.n).join(' ')} RG`); }
123
+ gs(name: string) { if (this.state !== name) { this.ops.push(`/${name} gs`); this.state = name; } }
124
+ rect(x: number, y: number, w: number, h: number, color: Rgb) { this.fill(color); this.ops.push(`${this.n(x)} ${this.n(y)} ${this.n(w)} ${this.n(h)} re f`); }
125
+ private rounded(x: number, y: number, w: number, h: number, r: number) {
126
+ r = Math.min(r, w / 2, h / 2);
127
+ const k = r * 0.4477, n = this.n;
128
+ this.ops.push(`${n(x + r)} ${n(y)} m ${n(x + w - r)} ${n(y)} l ${n(x + w - k)} ${n(y)} ${n(x + w)} ${n(y + k)} ${n(x + w)} ${n(y + r)} c`
129
+ + ` ${n(x + w)} ${n(y + h - r)} l ${n(x + w)} ${n(y + h - k)} ${n(x + w - k)} ${n(y + h)} ${n(x + w - r)} ${n(y + h)} c`
130
+ + ` ${n(x + r)} ${n(y + h)} l ${n(x + k)} ${n(y + h)} ${n(x)} ${n(y + h - k)} ${n(x)} ${n(y + h - r)} c`
131
+ + ` ${n(x)} ${n(y + r)} l ${n(x)} ${n(y + k)} ${n(x + k)} ${n(y)} ${n(x + r)} ${n(y)} c h`);
132
+ }
133
+ roundRect(x: number, y: number, w: number, h: number, r: number, fill: Rgb | null, line?: { color: Rgb; width: number }) {
134
+ this.rounded(x, y, w, h, r);
135
+ if (fill) this.fill(fill);
136
+ if (line) { this.stroke(line.color); this.ops.push(`${this.n(line.width)} w`); }
137
+ this.ops.push(fill && line ? 'B' : fill ? 'f' : 'S');
138
+ }
139
+ line(points: [number, number][], color: Rgb, width: number, curve = false) {
140
+ this.stroke(color); this.ops.push(`${this.n(width)} w 1 J`);
141
+ const [[x0, y0], ...rest] = points;
142
+ let path = `${this.n(x0)} ${this.n(y0)} m`;
143
+ if (curve && rest.length === 3) path += ` ${rest.flat().map(this.n).join(' ')} c`;
144
+ else for (const [x, y] of rest) path += ` ${this.n(x)} ${this.n(y)} l`;
145
+ this.ops.push(path + ' S');
146
+ }
147
+ text(text: string, x: number, baseline: number, style: Style, size: number, color: Rgb) {
148
+ const runs = this.type.runs(text, style, size);
149
+ this.type.record(runs);
150
+ this.fill(color);
151
+ this.ops.push(`BT 1 0 0 -1 ${this.n(x)} ${this.n(baseline)} Tm`);
152
+ for (const run of runs) this.ops.push(`/${RESOURCE[run.font]} ${this.n(size)} Tf <${run.gids.map(gid => gid.toString(16).padStart(4, '0')).join('')}> Tj`);
153
+ this.ops.push('ET');
154
+ }
155
+ // A number in a pill, centred on (x, y).
156
+ badge(label: string, x: number, y: number, size: number, fill: Rgb, ink: Rgb) {
157
+ const font = size * 0.6, width = Math.max(size, this.type.width(label, 'bold', font) + size * 0.62);
158
+ this.roundRect(x - width / 2, y - size / 2, width, size, size / 2, fill);
159
+ this.text(label, x - this.type.width(label, 'bold', font) / 2, y + font * 0.355, 'bold', font, ink);
160
+ return width;
161
+ }
162
+ toString() { return this.ops.join('\n'); }
163
+ }
164
+
165
+ interface Card { note: ExportNote; number: number; anchor: number; colors: ReturnType<typeof colors>; comment: string[]; author: string; top: number; height: number; cut: boolean }
166
+
167
+ // Card typography, in points. The badge sits on the card's first row, the
168
+ // author's name when there is one.
169
+ const PAD = 7, COMMENT = 8.6, LEADING = 11.6, AUTHOR_SIZE = 7.4, GAP = 6, BADGE = 12, MAX_LINES = 24;
170
+ const cardHeight = (lines: number, author: boolean) => PAD * 2 + ((author ? 1 : 0) + lines) * LEADING;
171
+
172
+ // Cards sit beside their marks, pushed apart where they would overlap and
173
+ // shortened, longest first, when a page has more comments than its margin can
174
+ // hold. Shortened comments are printed in full at the end of the document.
175
+ function layout(cards: Card[], top: number, bottom: number, type: Type, textWidth: number) {
176
+ const fits = () => cards.reduce((sum, card) => sum + card.height, 0) + GAP * Math.max(0, cards.length - 1) <= bottom - top;
177
+ while (!fits()) {
178
+ const longest = cards.reduce((a, b) => b.comment.length > a.comment.length ? b : a);
179
+ if (longest.comment.length <= 1) break;
180
+ longest.comment = type.wrap(longest.note.comment.trim(), 'regular', COMMENT, textWidth, longest.comment.length - 1);
181
+ longest.height = cardHeight(longest.comment.length, Boolean(longest.author));
182
+ longest.cut = true;
183
+ }
184
+ let next = top;
185
+ for (const card of cards) { card.top = Math.max(card.anchor - PAD - LEADING / 2, next); next = card.top + card.height + GAP; }
186
+ let limit = bottom;
187
+ for (const card of [...cards].reverse()) { card.top = Math.max(top, Math.min(card.top, limit - card.height)); limit = card.top - GAP; }
188
+ }
189
+
190
+ type Matrix = [number, number, number, number, number, number];
191
+ const apply = (m: Matrix | number[], x: number, y: number) => [x * m[0] + y * m[2] + m[4], x * m[1] + y * m[3] + m[5]];
192
+ const bounds = (m: Matrix | number[], [x0, y0, x1, y1]: number[]) => {
193
+ const points = [apply(m, x0, y0), apply(m, x1, y0), apply(m, x0, y1), apply(m, x1, y1)];
194
+ return [Math.min(...points.map(p => p[0])), Math.min(...points.map(p => p[1])), Math.max(...points.map(p => p[0])), Math.max(...points.map(p => p[1]))] as Rect;
195
+ };
196
+ const multiply = (a: number[], b: number[]): Matrix => [a[0] * b[0] + a[1] * b[2], a[0] * b[1] + a[1] * b[3], a[2] * b[0] + a[3] * b[2], a[2] * b[1] + a[3] * b[3], a[4] * b[0] + a[5] * b[2] + b[4], a[4] * b[1] + a[5] * b[3] + b[5]];
197
+
198
+ // Returns a copy of the PDF with every note drawn on its page: marks over the
199
+ // text, and the comment in a numbered card in a panel added to the right of
200
+ // the page, joined to its mark's height. Links, bookmarks and text stay as
201
+ // they were; notes without a comment are drawn as marks only. The marks show
202
+ // what each comment is about, so cards do not repeat the quote.
203
+ export function annotatePdf(mupdf: typeof Mupdf, input: Uint8Array, notes: ExportNote[]) {
204
+ const pdf = new mupdf.PDFDocument(input), type = new Type(mupdf);
205
+ try {
206
+ const count = pdf.countPages();
207
+ const placed = notes.filter(note => Number.isInteger(note.page) && note.page >= 0 && note.page < count);
208
+ if (!placed.length) return input;
209
+ const first = (note: ExportNote) => (note.rects?.length ? note.rects : [note.rect])[0];
210
+ const middle = (note: ExportNote) => (first(note)[1] + first(note)[3]) / 2;
211
+ // Numbers follow reading order: page, then line, then left to right.
212
+ const commented = placed.filter(note => note.comment.trim()).sort((a, b) => a.page - b.page || (Math.abs(middle(a) - middle(b)) > 0.006 ? middle(a) - middle(b) : first(a)[0] - first(b)[0]));
213
+ const numbers = new Map(commented.map((note, index) => [note, index + 1]));
214
+ const pages = new Map<number, ExportNote[]>();
215
+ for (const note of placed) pages.set(note.page, [...(pages.get(note.page) || []), note]);
216
+ // Every page gets the same panel so the pages stay one width in a viewer.
217
+ const firstPage = pdf.loadPage(0), [fx0, fy0, fx1, fy1] = firstPage.getBounds();
218
+ firstPage.destroy();
219
+ const panel = Math.round(Math.min(300, Math.max(210, (fx1 - fx0) * 0.4)));
220
+ const states = { mark: 'PplMark', box: 'PplBox', link: 'PplLink', solid: 'PplSolid' };
221
+
222
+ // Draw every page's marks and cards.
223
+ const drawn: { index: number; canvas: Canvas; toPdf: Matrix; box: Rect; cut: { card: Card; rect: Rect }[] }[] = [];
224
+ for (let index = 0; index < count; index++) {
225
+ const page = pdf.loadPage(index), [x0, y0, x1, y1] = page.getBounds(), w = x1 - x0, h = y1 - y0;
226
+ const ctm = mupdf.Matrix.invert(page.getTransform());
227
+ page.destroy();
228
+ const canvas = new Canvas(type);
229
+ canvas.gs(states.solid);
230
+ canvas.rect(w, 0, panel, h, PANEL);
231
+ canvas.rect(w, 0, 0.6, h, DIVIDER);
232
+ const list = pages.get(index) || [];
233
+ // Marks first, under every tag and line.
234
+ for (const note of list) {
235
+ const color = colors(note).mark;
236
+ if (note.rects?.length) {
237
+ canvas.gs(states.mark);
238
+ for (const [a, b, c, d] of note.rects) canvas.rect(a * w, b * h - 0.4, (c - a) * w, (d - b) * h + 0.8, color);
239
+ } else {
240
+ const [a, b, c, d] = note.rect;
241
+ canvas.gs(states.box); canvas.roundRect(a * w, b * h, (c - a) * w, (d - b) * h, 2.5, color);
242
+ canvas.gs(states.solid); canvas.roundRect(a * w, b * h, (c - a) * w, (d - b) * h, 2.5, null, { color, width: 1.1 });
243
+ }
244
+ }
245
+ const cardX = w + 20, cardWidth = panel - 32, textX = cardX + PAD + BADGE + 5, textWidth = cardX + cardWidth - PAD - textX;
246
+ const cards: Card[] = list.filter(note => numbers.has(note)).sort((a, b) => numbers.get(a)! - numbers.get(b)!).map(note => {
247
+ // A text mark's card meets its first line; a region's meets its top edge.
248
+ const [, top, , bottom] = first(note), anchor = note.rects?.length ? (top + bottom) / 2 * h : Math.min((top + bottom) / 2 * h, top * h + 9);
249
+ const all = type.wrap(note.comment.trim(), 'regular', COMMENT, textWidth), comment = all.length > MAX_LINES ? type.wrap(note.comment.trim(), 'regular', COMMENT, textWidth, MAX_LINES) : all;
250
+ const author = note.author?.trim() || '';
251
+ return { note, number: numbers.get(note)!, anchor, colors: colors(note), author, comment, top: 0, height: cardHeight(comment.length, Boolean(author)), cut: all.length > MAX_LINES };
252
+ });
253
+ layout(cards, 16, h - 16, type, textWidth);
254
+ // A dot on the margin's edge at the mark's height, joined to its card;
255
+ // the number appears once, on the card.
256
+ for (const card of cards) {
257
+ const badgeY = card.top + PAD + LEADING / 2, to = cardX;
258
+ canvas.gs(states.link);
259
+ canvas.line([[w, card.anchor], [w + (to - w) * 0.55, card.anchor], [to - (to - w) * 0.45, badgeY], [to, badgeY]], card.colors.tag, 0.7, true);
260
+ canvas.gs(states.solid);
261
+ canvas.roundRect(w - 2.2, card.anchor - 2.2, 4.4, 4.4, 2.2, card.colors.tag);
262
+ }
263
+ for (const card of cards) {
264
+ canvas.roundRect(cardX, card.top, cardWidth, card.height, 5, CARD, { color: CARD_LINE, width: 0.6 });
265
+ let y = card.top + PAD;
266
+ canvas.badge(String(card.number), cardX + PAD + BADGE / 2, y + LEADING / 2, BADGE, card.colors.tag, card.colors.ink);
267
+ if (card.author) { canvas.text(card.author, textX, y + LEADING / 2 + AUTHOR_SIZE * 0.355, 'bold', AUTHOR_SIZE, AUTHOR); y += LEADING; }
268
+ for (const line of card.comment) { canvas.text(line, textX, y + LEADING / 2 + COMMENT * 0.355, 'regular', COMMENT, TEXT); y += LEADING; }
269
+ }
270
+ // Drawing happens from the page's top-left corner; toPdf takes it to PDF
271
+ // space, whatever the page's rotation and origin.
272
+ const toPdf = multiply([1, 0, 0, 1, x0, y0], ctm);
273
+ drawn.push({ index, canvas, toPdf, box: bounds(toPdf, [0, 0, w + panel, h]), cut: cards.filter(card => card.cut).map(card => ({ card, rect: [cardX, card.top, cardX + cardWidth, card.top + card.height] as Rect })) });
274
+ }
275
+
276
+ // Comments shortened in the margin, in full, on pages after the paper.
277
+ const cut = drawn.flatMap(page => page.cut.map(entry => ({ ...entry, page: page.index })));
278
+ const extra: { canvas: Canvas; links: { rect: Rect; page: number; y: number }[] }[] = [];
279
+ const targets = new Map<Card, { page: number; y: number }>();
280
+ if (cut.length) {
281
+ const pageWidth = fx1 - fx0 + panel, pageHeight = fy1 - fy0, width = Math.min(440, pageWidth - 96), left = (pageWidth - width) / 2;
282
+ const textX = left + PAD + BADGE + 5;
283
+ let sheet: (typeof extra)[number] | undefined, y = 0;
284
+ const open = () => { sheet = { canvas: new Canvas(type), links: [] }; sheet.canvas.gs(states.solid); sheet.canvas.rect(0, 0, pageWidth, pageHeight, PANEL); extra.push(sheet); y = 56; };
285
+ for (const { card, page } of cut) {
286
+ // Where the note is, at the end of the first row: a page glyph and the
287
+ // page's number. The whole row links back to the mark.
288
+ const label = String(page + 1), chip = 9 + type.width(label, 'bold', AUTHOR_SIZE), textWidth = left + width - PAD - chip - 10 - textX;
289
+ let lines = type.wrap(card.note.comment.trim(), 'regular', COMMENT, textWidth), head = true;
290
+ while (lines.length) {
291
+ const author = head && Boolean(card.author);
292
+ if (!sheet || y + cardHeight(Math.min(lines.length, 3), author) > pageHeight - 56) open();
293
+ const room = Math.floor((pageHeight - 56 - y - PAD * 2) / LEADING) - (author ? 1 : 0);
294
+ const part = lines.slice(0, Math.max(1, room)), height = cardHeight(part.length, author);
295
+ const canvas = sheet!.canvas;
296
+ canvas.roundRect(left, y, width, height, 5, CARD, { color: CARD_LINE, width: 0.6 });
297
+ let row = y + PAD;
298
+ if (head) {
299
+ targets.set(card, { page: count + extra.length - 1, y });
300
+ canvas.badge(String(card.number), left + PAD + BADGE / 2, row + LEADING / 2, BADGE, card.colors.tag, card.colors.ink);
301
+ const end = left + width - PAD;
302
+ canvas.roundRect(end - chip, row + LEADING / 2 - 4, 6, 8, 1, null, { color: AUTHOR, width: 0.7 });
303
+ canvas.text(label, end - chip + 9, row + LEADING / 2 + AUTHOR_SIZE * 0.355, 'bold', AUTHOR_SIZE, AUTHOR);
304
+ sheet!.links.push({ rect: [left, y, left + width, y + height], page, y: card.anchor });
305
+ if (card.author) { canvas.text(card.author, textX, row + LEADING / 2 + AUTHOR_SIZE * 0.355, 'bold', AUTHOR_SIZE, AUTHOR); row += LEADING; }
306
+ }
307
+ for (const line of part) { canvas.text(line, textX, row + LEADING / 2 + COMMENT * 0.355, 'regular', COMMENT, TEXT); row += LEADING; }
308
+ y += height + GAP; lines = lines.slice(part.length); head = false;
309
+ }
310
+ }
311
+ }
312
+
313
+ // Our fonts, cut down to the glyphs we used in a scratch document so the
314
+ // paper's own fonts are never rewritten.
315
+ const scratch = new mupdf.PDFDocument(), fonts = new Map<Style | 'cjk', Mupdf.PDFObject>();
316
+ try {
317
+ const resources = scratch.newDictionary(), fontDict = scratch.newDictionary();
318
+ resources.put('Font', fontDict);
319
+ let content = 'BT\n';
320
+ for (const [kind, gids] of type.used) {
321
+ fontDict.put(RESOURCE[kind], scratch.addFont(type.font(kind)));
322
+ content += `/${RESOURCE[kind]} 10 Tf <${[...gids].map(gid => gid.toString(16).padStart(4, '0')).join('')}> Tj\n`;
323
+ }
324
+ scratch.insertPage(-1, scratch.addPage([0, 0, 100, 100], 0, resources, content + 'ET\n'));
325
+ scratch.subsetFonts();
326
+ for (const kind of type.used.keys()) fonts.set(kind, pdf.graftObject(fontDict.get(RESOURCE[kind])));
327
+ } finally { scratch.destroy(); }
328
+ const graphics = new Map<string, Mupdf.PDFObject>();
329
+ const state = (name: string, entries: Record<string, number | string>) => {
330
+ const dict = pdf.newDictionary(); dict.put('Type', pdf.newName('ExtGState'));
331
+ for (const [key, value] of Object.entries(entries)) dict.put(key, typeof value === 'string' ? pdf.newName(value) : value);
332
+ graphics.set(name, pdf.addObject(dict));
333
+ };
334
+ state(states.mark, { ca: 0.3, BM: 'Multiply' });
335
+ state(states.box, { ca: 0.1, BM: 'Multiply' });
336
+ state(states.link, { CA: 0.65, ca: 1 });
337
+ state(states.solid, { CA: 1, ca: 1, BM: 'Normal' });
338
+ const addResources = (resources: Mupdf.PDFObject) => {
339
+ const entry = (key: string) => {
340
+ let dict = resources.get(key);
341
+ if (!dict.isDictionary()) { dict = pdf.newDictionary(); resources.put(key, dict); }
342
+ return dict;
343
+ };
344
+ const fontDict = entry('Font'), stateDict = entry('ExtGState');
345
+ for (const [kind, ref] of fonts) fontDict.put(RESOURCE[kind], ref);
346
+ for (const [name, ref] of graphics) stateDict.put(name, ref);
347
+ };
348
+ const matrix = (m: number[]) => m.map(n => Math.round(n * 10000) / 10000).join(' ');
349
+
350
+ for (const { index, canvas, toPdf, box } of drawn) {
351
+ const page = pdf.loadPage(index), object = page.getObject();
352
+ // Grow the page to the right, in whichever PDF direction that is after rotation.
353
+ const array = () => { const value = pdf.newArray(); for (const n of box) value.push(n); return value; };
354
+ object.put('MediaBox', array());
355
+ object.put('CropBox', array());
356
+ for (const key of ['TrimBox', 'BleedBox', 'ArtBox']) object.delete(key);
357
+ let resources = object.getInheritable('Resources');
358
+ if (resources.isNull()) { resources = pdf.newDictionary(); object.put('Resources', resources); }
359
+ addResources(resources);
360
+ // Isolate the original page in q … Q, whatever state it leaves behind.
361
+ const contents = pdf.newArray(), old = object.get('Contents');
362
+ contents.push(pdf.addStream('q\n', pdf.newDictionary()));
363
+ if (old.isArray()) old.forEach(item => contents.push(item)); else if (!old.isNull()) contents.push(old);
364
+ contents.push(pdf.addStream(`Q\nq ${matrix(toPdf)} cm\n${canvas}\nQ\n`, pdf.newDictionary()));
365
+ object.put('Contents', contents);
366
+ page.destroy();
367
+ }
368
+ for (const sheet of extra) {
369
+ const resources = pdf.newDictionary();
370
+ addResources(resources);
371
+ const height = fy1 - fy0;
372
+ pdf.insertPage(-1, pdf.addPage([0, 0, fx1 - fx0 + panel, height], 0, resources, `q 1 0 0 -1 0 ${height} cm\n${sheet.canvas}\nQ\n`));
373
+ }
374
+ // Links between a shortened card and its full text, both ways. A link's
375
+ // area and destination are in the page's own space, after any rotation.
376
+ const drawing = (index: number) => index < count ? drawn[index].toPdf : [1, 0, 0, -1, 0, fy1 - fy0];
377
+ const link = (from: number, rect: Rect, to: number, y: number) => {
378
+ const source = pdf.loadPage(from), destination = pdf.loadPage(to);
379
+ try {
380
+ const area = bounds(source.getTransform(), bounds(drawing(from), rect));
381
+ const [, top] = apply(destination.getTransform(), ...apply(drawing(to), 0, Math.max(0, y - 24)) as [number, number]);
382
+ source.createLink(area, pdf.formatLinkURI({ type: 'XYZ', page: to, x: NaN, y: top, width: 0, height: 0, zoom: NaN, chapter: 0 }));
383
+ } finally { source.destroy(); destination.destroy(); }
384
+ };
385
+ for (const { card, page, rect } of cut) {
386
+ const full = targets.get(card);
387
+ if (full) link(page, rect, full.page, full.y);
388
+ }
389
+ extra.forEach((sheet, offset) => { for (const entry of sheet.links) link(count + offset, entry.rect, entry.page, entry.y); });
390
+ return pdf.saveToBuffer('compress,garbage=compact,objstms').asUint8Array();
391
+ } finally { pdf.destroy(); }
392
+ }
@@ -48,15 +48,29 @@ export class AnnotationPages {
48
48
  }
49
49
  }
50
50
 
51
- export function placeAnnotation(note: Annotation, previous: AnnotationPages, current: AnnotationPages): AnnotationPlacement | null {
51
+ // Reading a page's text or pixels takes milliseconds, and a search may read
52
+ // every page of a long PDF, so the server answers other requests between pages.
53
+ const breather = () => new Promise<void>(resolve => setImmediate(resolve));
54
+
55
+ export async function placeAnnotation(note: Annotation, previous: AnnotationPages, current: AnnotationPages): Promise<AnnotationPlacement | null> {
52
56
  if (note.page < 0 || note.page >= previous.document.length) return null;
53
57
  const samePage = (page: number) => previous.hash(note.page) === current.hash(page);
54
- if (note.page < current.document.length && samePage(note.page)) return { page: note.page, rect: note.rect, rects: note.rects };
58
+ if (note.page < current.document.length) {
59
+ // Each fingerprint renders a page, so they take turns with other requests.
60
+ previous.hash(note.page);
61
+ await breather();
62
+ if (samePage(note.page)) return { page: note.page, rect: note.rect, rects: note.rects };
63
+ await breather();
64
+ }
55
65
 
56
66
  // A box may contain a figure rather than text. Only move it automatically
57
67
  // when the entire original page still exists, exactly once, in the new PDF.
58
68
  if (!note.rects?.length) {
59
- const pages = Array.from({ length: current.document.length }, (_, index) => index).filter(samePage);
69
+ const pages: number[] = [];
70
+ for (let index = 0; index < current.document.length; index++) {
71
+ if (index % 2 === 1) await breather();
72
+ if (samePage(index)) pages.push(index);
73
+ }
60
74
  return pages.length === 1 ? { page: pages[0], rect: note.rect, rects: note.rects } : null;
61
75
  }
62
76
 
@@ -71,6 +85,7 @@ export function placeAnnotation(note: Annotation, previous: AnnotationPages, cur
71
85
  const before = old.text.slice(Math.max(0, start - 32), start), after = old.text.slice(end, end + 32);
72
86
  const matches: { page: number; start: number }[] = [];
73
87
  for (let page = 0; page < current.document.length; page++) {
88
+ if (page % 2 === 1) await breather();
74
89
  const text = current.text(page).text;
75
90
  for (let from = 0; from <= text.length - needle.length;) {
76
91
  const found = text.indexOf(needle, from);
package/src/server/app.ts CHANGED
@@ -8,8 +8,8 @@ import { randomUUID } from 'node:crypto';
8
8
  import { registerFrontend } from './frontend.ts';
9
9
  import type { Store } from './store.ts';
10
10
  import type { Workspace } from './workspace.ts';
11
- import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState } from './review.ts';
12
- import { fullPaperPdf } from './pdf.ts';
11
+ import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState, taskRoute } from './review.ts';
12
+ import { fullPaperPdf, nextPaperPass, paperPassPdf, preparePaper } from './pdf.ts';
13
13
  import { type ChangeAction, Changes } from './changes.ts';
14
14
  import { bibliography, confirmEntry } from './references.ts';
15
15
  import { type Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitDiscard, gitFinishMerge, gitIgnore, gitRemote, gitStage, gitStatus } from './manuscript.ts';
@@ -31,6 +31,8 @@ import { registerAiReviewRoutes } from './ai-review-routes.ts';
31
31
  import { detectLatex } from './latex-installation.ts';
32
32
 
33
33
  const importsByWorkspace = new WeakMap<Workspace, PaperImports>();
34
+ // Each workspace compiles predicted manuscripts once, however many listeners serve it.
35
+ const predicting = new WeakSet<Workspace>();
34
36
 
35
37
  // Git writes to one repository run one at a time; each returns the new status.
36
38
  // Both listeners share one queue, so the owner and a collaborator take turns.
@@ -81,6 +83,39 @@ export async function buildApp(options: AppOptions) {
81
83
  if (id) projectRequests.set(request, workspace.enterProjectRequest(id));
82
84
  });
83
85
  app.addHook('onSend', async (request, _reply, payload) => { projectRequests.get(request)?.(); return payload; });
86
+ // Whatever may have written a paper's files compiles its manuscript right
87
+ // away: committing or applying a task, a Git action, a review decision. The
88
+ // PDF is on its way before anyone opens it, and a tree that did not change
89
+ // costs one snapshot.
90
+ const WRITES = /^\/api\/projects\/:id\/git\/|^\/api\/tasks\/:id\/(?:commit|apply|reconcile)$|^\/review\/:projectId\/(?:api\/changes\/(?:decide|undo)|\*)$/;
91
+ app.addHook('onResponse', async (request, reply) => {
92
+ if (request.method !== 'POST' || reply.statusCode >= 400 || !WRITES.test(request.routeOptions.url || '')) return;
93
+ const params = (request.params || {}) as Record<string, string>;
94
+ try {
95
+ const project = store.project(params.projectId ?? (request.routeOptions.url!.startsWith('/api/tasks/') ? store.task(params.id).projectId : params.id));
96
+ void manuscripts.refresh(project.id, project.path).catch(() => undefined);
97
+ } catch { /* The paper or task is gone. */ }
98
+ });
99
+ // A deferred task is applied as a whole once it is reviewed, so after each
100
+ // new round and each decision the manuscript it will produce compiles in
101
+ // the background.
102
+ const predict = async (taskId: string) => {
103
+ const task = store.task(taskId), project = store.project(task.projectId);
104
+ if (!task.deferred || task.mode === 'review' || task.delivery?.pending || task.status === 'deleting') return;
105
+ const client = await connectReview(project.path);
106
+ const preview = await client.get<{ files: Record<string, string | null> | null }>(`${taskRoute(task.name)}/preview`);
107
+ if (preview.files) await manuscripts.predict(project.id, project.path, Object.fromEntries(Object.entries(preview.files).map(([file, data]) => [file, data === null ? null : Buffer.from(data, 'base64')])));
108
+ };
109
+ if (!predicting.has(workspace)) {
110
+ predicting.add(workspace);
111
+ workspace.on('reviewed', (taskId: string) => void predict(taskId).catch(() => undefined));
112
+ // While a round compiles its changed chapters, the whole new version
113
+ // compiles too, for the review's full paper and often for the commit.
114
+ workspace.on('publishing', (taskId: string, revision: string) => {
115
+ try { preparePaper(store.project(store.task(taskId).projectId).path, revision, 'background'); }
116
+ catch { /* The task is gone. */ }
117
+ });
118
+ }
84
119
  app.addHook('onError', async request => { projectRequests.get(request)?.(); });
85
120
  await app.register(compress);
86
121
  app.setErrorHandler((error, _request, reply) => { reply.code((error as any).statusCode || 400).send({ error: (error as Error).message }); });
@@ -159,11 +194,16 @@ export async function buildApp(options: AppOptions) {
159
194
  if (viewer.listener !== 'account' || (event.userId !== viewer.user.id && event.sessionId !== viewer.session.id)) return;
160
195
  if (!accounts.session(cookie(request.headers.cookie, SESSION_COOKIE))) { reply.raw.write('event: change\ndata: {}\n\n'); reply.raw.end(); } else change();
161
196
  };
197
+ // A manuscript build started or finished: the open PDF view reloads now
198
+ // instead of at its next poll. The event names no paper.
199
+ let paperTimer: NodeJS.Timeout | undefined;
200
+ const paper = () => { paperTimer ??= setTimeout(() => { paperTimer = undefined; reply.raw.write('event: manuscript\ndata: {}\n\n'); }, 100); };
162
201
  workspace.on('change', change);
202
+ manuscripts.on('change', paper);
163
203
  accounts.on('revoked', revoked);
164
204
  const heartbeat = setInterval(() => reply.raw.write(': heartbeat\n\n'), 15000);
165
205
  reply.raw.on('close', () => {
166
- clearInterval(heartbeat); if (timer) clearTimeout(timer); workspace.off('change', change); accounts.off('revoked', revoked);
206
+ clearInterval(heartbeat); if (timer) clearTimeout(timer); if (paperTimer) clearTimeout(paperTimer); workspace.off('change', change); manuscripts.off('change', paper); accounts.off('revoked', revoked);
167
207
  if (session) { const open = (streams.get(session) || 1) - 1; if (open) streams.set(session, open); else streams.delete(session); }
168
208
  });
169
209
  });
@@ -223,7 +263,7 @@ export async function buildApp(options: AppOptions) {
223
263
  const round = request.query.round === undefined ? task.review?.round : Number(request.query.round);
224
264
  const side = request.query.version || 'after';
225
265
  if (!round || !Number.isSafeInteger(round) || round < 1 || (side !== 'before' && side !== 'after')) return reply.code(400).send({ error: 'Select an existing review round and either the before or after PDF.' });
226
- const pdf = await fullPaperPdf(project.path, task.name, round, side);
266
+ const pdf = await fullPaperPdf(connectReview, project.path, task.name, round, side);
227
267
  const filename = `papilot-${task.name}-r${round}-${side}.pdf`;
228
268
  return reply.type('application/pdf').header('Content-Disposition', `${request.query.download === '1' ? 'attachment' : 'inline'}; filename="${filename}"`).sendFile(path.basename(pdf), path.dirname(pdf), { cacheControl: false });
229
269
  });
@@ -407,11 +447,19 @@ export async function buildApp(options: AppOptions) {
407
447
  const words = await manuscripts.words(project.id, String(request.query.key || ''), Number(request.query.page));
408
448
  return reply.header('Cache-Control', 'private, max-age=31536000, immutable').send(words);
409
449
  });
450
+ const pdfName = (name: string, suffix = '') => (name.replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'manuscript') + suffix + '.pdf';
410
451
  app.get<{ Params: { id: string }; Querystring: { key?: string; download?: string } }>('/api/projects/:id/manuscript.pdf', project(), (request, reply) => {
411
452
  const project = store.project(request.params.id), pdf = manuscripts.pdf(project.id, String(request.query.key || ''));
412
- const name = (project.name.replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'manuscript') + '.pdf';
413
453
  // Each key names one immutable build, so the viewer can cache and range-read it.
414
- return reply.type('application/pdf').header('Content-Disposition', `${request.query.download === '1' ? 'attachment' : 'inline'}; filename="${name}"`).header('Cache-Control', 'private, max-age=31536000, immutable').sendFile(path.basename(pdf), path.dirname(pdf), { cacheControl: false });
454
+ return reply.type('application/pdf').header('Content-Disposition', `${request.query.download === '1' ? 'attachment' : 'inline'}; filename="${pdfName(project.name)}"`).header('Cache-Control', 'private, max-age=31536000, immutable').sendFile(path.basename(pdf), path.dirname(pdf), { cacheControl: false });
455
+ });
456
+ // The same build with the notes on it, as they are now.
457
+ app.get<{ Params: { id: string }; Querystring: { key?: string } }>('/api/projects/:id/manuscript/annotated.pdf', project(), async (request, reply) => {
458
+ const project = store.project(request.params.id), key = String(request.query.key || '');
459
+ const notes = store.annotations(project.id).filter(note => !note.taskId && !isTaskReviewNote(note) && note.key === key)
460
+ .map(note => ({ page: note.page, rect: note.rect, rects: note.rects, comment: note.comment, group: note.group, review: Boolean(note.aiReview), author: note.author?.name }));
461
+ const pdf = await manuscripts.annotatedPdf(project.id, key, notes);
462
+ return reply.type('application/pdf').header('Content-Disposition', `attachment; filename="${pdfName(project.name, '-annotated')}"`).header('Cache-Control', 'no-store').send(Buffer.from(pdf.buffer, pdf.byteOffset, pdf.byteLength));
415
463
  });
416
464
  app.post<{ Params: { id: string } }>('/api/requests/:id', { config: { access: { request: 'id' } } }, request => { workspace.answer(request.params.id, request.body as any); return { ok: true }; });
417
465
 
@@ -503,12 +551,20 @@ export async function buildApp(options: AppOptions) {
503
551
 
504
552
  // The complete PDF of a round's new version, for the review's full paper.
505
553
  // A round may compile only the changed chapters; this compiles them all once.
506
- app.get<{ Params: { projectId: string; name: string; round: string } }>('/review/:projectId/pdf/:name/:round', { config: { access: { project: 'projectId' } } }, async (request, reply) => {
554
+ // While it compiles, `passes` names each earlier version that `?pass=`
555
+ // serves: the chapters the round compiled, then each TeX pass of the whole.
556
+ app.get<{ Params: { projectId: string; name: string; round: string }; Querystring: { pass?: string } }>('/review/:projectId/pdf/:name/:round', { config: { access: { project: 'projectId' } } }, async (request, reply) => {
507
557
  const project = store.project(request.params.projectId), name = request.params.name, round = Number(request.params.round);
508
- if (!/^[a-z0-9-]+$/.test(name) || !Number.isSafeInteger(round) || round < 1) return reply.code(404).send({ error: 'Review resource not found.' });
509
- const pdf = await fullPaperPdf(project.path, name, round, 'after');
558
+ const pass = request.query.pass === undefined ? null : Number(request.query.pass);
559
+ if (!/^[a-z0-9-]+$/.test(name) || !Number.isSafeInteger(round) || round < 1 || (pass !== null && (!Number.isSafeInteger(pass) || pass < 1))) return reply.code(404).send({ error: 'Review resource not found.' });
560
+ const pdf = pass === null ? await fullPaperPdf(connectReview, project.path, name, round, 'after') : await paperPassPdf(connectReview, project.path, name, round, pass);
510
561
  return reply.type('application/pdf').sendFile(path.basename(pdf), path.dirname(pdf), { cacheControl: false });
511
562
  });
563
+ app.get<{ Params: { projectId: string; name: string; round: string }; Querystring: { after?: string } }>('/review/:projectId/pdf/:name/:round/passes', { config: { access: { project: 'projectId' } } }, async (request, reply) => {
564
+ const project = store.project(request.params.projectId), name = request.params.name, round = Number(request.params.round), after = Number(request.query.after ?? 0);
565
+ if (!/^[a-z0-9-]+$/.test(name) || !Number.isSafeInteger(round) || round < 1 || !Number.isSafeInteger(after) || after < 0) return reply.code(404).send({ error: 'Review resource not found.' });
566
+ return nextPaperPass(connectReview, project.path, name, round, after);
567
+ });
512
568
  app.route<{ Params: { projectId: string; '*': string } }>({
513
569
  // The review page posts with the review engine's token rather than Papilot's.
514
570
  method: ['GET', 'POST'], url: '/review/:projectId/*', config: { access: { project: 'projectId' }, csrf: false }, handler: async (request, reply) => {
@@ -544,12 +600,14 @@ export async function buildApp(options: AppOptions) {
544
600
  if (task) {
545
601
  store.updateTask(task.id, { error: null });
546
602
  await workspace.refresh(task.id);
603
+ // A decision changes what applying the task will write.
604
+ workspace.emit('reviewed', task.id);
547
605
  }
548
606
  }
549
607
  // A round's new version as one complete PDF, which Papilot compiles,
550
608
  // lets the review show the changes in the whole paper.
551
609
  const round = request.method === 'GET' && response.ok ? /^\/api\/tasks\/([a-z0-9-]+)\/rounds\/(\d+)$/.exec(route) : null;
552
- if (round) (value as { pdf?: string }).pdf = `${prefix}/pdf/${round[1]}/${round[2]}`;
610
+ if (round) Object.assign(value as object, { pdf: `${prefix}/pdf/${round[1]}/${round[2]}`, passes: `${prefix}/pdf/${round[1]}/${round[2]}/passes` });
553
611
  return rewriteReviewJson(value, prefix);
554
612
  }
555
613
  if (reviewTask && writesFiles) throw new Error('The review engine returned an unexpected response. Select Check status before continuing.');