papilot 0.8.5 → 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-DHvVou9A.js → math-CBzEH7Y2.js} +1 -1
- package/dist/assets/{pdf-view-AYTS7kdA.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 +367 -92
- 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/update-install.ts +5 -1
- package/src/server/updates.ts +24 -49
- package/src/server/workspace.ts +18 -0
- package/dist/assets/index-DSWizoaI.js +0 -267
- package/dist/assets/index-PFQDdu53.css +0 -1
package/engine/src/web/style.css
CHANGED
|
@@ -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
package/src/server/agents.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
|
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.');
|