papilot 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +126 -2
- package/README.zh-CN.md +127 -0
- package/bin/papilot.mjs +113 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/Inconsolatazi4-Regular-Dbtq7t_r.otf +0 -0
- package/dist/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
- package/dist/assets/TeXGyrePagellaX-Italic-CSuYIstn.otf +0 -0
- package/dist/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
- package/dist/assets/cursor-editorTextHighlight-kG3uUOwL.svg +8 -0
- package/dist/assets/index-BhQFCMTx.js +131 -0
- package/dist/assets/index-CAV3-87U.css +1 -0
- package/dist/assets/pdf-view-DJo0X38w.js +598 -0
- package/dist/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
- package/dist/favicon.png +0 -0
- package/dist/index.html +4 -0
- package/examples/papilot-paper/.latex-review.json +4 -0
- package/examples/papilot-paper/appendix/proofs.tex +60 -0
- package/examples/papilot-paper/appendix/reference.tex +54 -0
- package/examples/papilot-paper/main.tex +57 -0
- package/examples/papilot-paper/preamble.tex +53 -0
- package/examples/papilot-paper/references.bib +158 -0
- package/examples/papilot-paper/sections/01-introduction.tex +66 -0
- package/examples/papilot-paper/sections/02-using.tex +134 -0
- package/examples/papilot-paper/sections/03-background.tex +97 -0
- package/examples/papilot-paper/sections/04-model.tex +185 -0
- package/examples/papilot-paper/sections/05-conflicts.tex +99 -0
- package/examples/papilot-paper/sections/06-building.tex +70 -0
- package/examples/papilot-paper/sections/07-geometry.tex +193 -0
- package/examples/papilot-paper/sections/08-notes.tex +104 -0
- package/examples/papilot-paper/sections/09-agents.tex +106 -0
- package/examples/papilot-paper/sections/10-usage.tex +56 -0
- package/examples/papilot-paper/sections/11-git.tex +62 -0
- package/examples/papilot-paper/sections/12-security.tex +41 -0
- package/examples/papilot-paper/sections/13-evaluation.tex +106 -0
- package/examples/papilot-paper/sections/14-related.tex +38 -0
- package/examples/papilot-paper/sections/15-conclusion.tex +27 -0
- package/package.json +85 -4
- package/scripts/demo.ts +21 -0
- package/scripts/service.ts +35 -0
- package/src/notes.ts +40 -0
- package/src/server/agents.ts +343 -0
- package/src/server/codex.ts +63 -0
- package/src/server/frontend.ts +15 -0
- package/src/server/main.ts +202 -0
- package/src/server/manuscript.ts +337 -0
- package/src/server/paths.ts +10 -0
- package/src/server/pdf.ts +27 -0
- package/src/server/review.ts +53 -0
- package/src/server/security.ts +15 -0
- package/src/server/store.ts +60 -0
- package/src/server/workspace.ts +529 -0
- package/src/shared.ts +28 -0
- package/vendor/latex-review/LICENSE +21 -0
- package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
- package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
- package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +1 -0
- package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +19 -0
- package/vendor/latex-review/dist/web/index.html +18 -0
- package/vendor/latex-review/package-lock.json +3114 -0
- package/vendor/latex-review/package.json +49 -0
- package/vendor/latex-review/src/cli.ts +494 -0
- package/vendor/latex-review/src/client.ts +198 -0
- package/vendor/latex-review/src/core/build.ts +296 -0
- package/vendor/latex-review/src/core/changes.ts +126 -0
- package/vendor/latex-review/src/core/config.ts +270 -0
- package/vendor/latex-review/src/core/diff.ts +54 -0
- package/vendor/latex-review/src/core/files.ts +96 -0
- package/vendor/latex-review/src/core/git.ts +317 -0
- package/vendor/latex-review/src/core/outline.ts +314 -0
- package/vendor/latex-review/src/core/process.ts +111 -0
- package/vendor/latex-review/src/core/publish.ts +339 -0
- package/vendor/latex-review/src/core/review.ts +734 -0
- package/vendor/latex-review/src/core/store.ts +66 -0
- package/vendor/latex-review/src/core/tasks.ts +237 -0
- package/vendor/latex-review/src/render/inline.ts +805 -0
- package/vendor/latex-review/src/render/locate.ts +481 -0
- package/vendor/latex-review/src/render/pdf.ts +281 -0
- package/vendor/latex-review/src/render/pdfdiff.ts +272 -0
- package/vendor/latex-review/src/render/round.ts +236 -0
- package/vendor/latex-review/src/render/worker.ts +10 -0
- package/vendor/latex-review/src/runtime.ts +23 -0
- package/vendor/latex-review/src/server.ts +401 -0
- package/vendor/latex-review/src/types.ts +283 -0
- package/vendor/latex-review/src/web/ChangeMap.tsx +212 -0
- package/vendor/latex-review/src/web/PdfStrip.tsx +77 -0
- package/vendor/latex-review/src/web/fonts/GUST-FONT-LICENSE.txt +29 -0
- package/vendor/latex-review/src/web/fonts/LPPL.txt +415 -0
- package/vendor/latex-review/src/web/fonts/MANIFEST-newpx.txt +20 -0
- package/vendor/latex-review/src/web/fonts/README-newpx.txt +473 -0
- package/vendor/latex-review/src/web/fonts/README.md +12 -0
- package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
- package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
- package/vendor/latex-review/src/web/index.html +17 -0
- package/vendor/latex-review/src/web/main.tsx +10 -0
- package/vendor/latex-review/src/web/model.ts +153 -0
- package/vendor/latex-review/src/web/overview.tsx +146 -0
- package/vendor/latex-review/src/web/review.tsx +991 -0
- package/vendor/latex-review/src/web/style.css +1249 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import { registerFrontend } from './frontend.ts';
|
|
3
|
+
import compress from '@fastify/compress';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import { Store } from './store.ts';
|
|
8
|
+
import { Workspace } from './workspace.ts';
|
|
9
|
+
import { ROOT, DATA, PORT, PUBLIC_ORIGIN, OWNER } from './paths.ts';
|
|
10
|
+
import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState } from './review.ts';
|
|
11
|
+
import { authorize } from './security.ts';
|
|
12
|
+
import { fullPaperPdf } from './pdf.ts';
|
|
13
|
+
import { Manuscripts, annotationTask, gitCommit, gitDiff, gitRemote, gitStatus } from './manuscript.ts';
|
|
14
|
+
import type { AgentId } from '../shared.ts';
|
|
15
|
+
|
|
16
|
+
const store = new Store(path.join(DATA, 'papilot.sqlite'));
|
|
17
|
+
const workspace = new Workspace(store);
|
|
18
|
+
const manuscripts = new Manuscripts();
|
|
19
|
+
const app = Fastify({ logger: false, bodyLimit: 1024 * 1024 });
|
|
20
|
+
const csrf = randomBytes(32).toString('base64url');
|
|
21
|
+
const equals = (a: string, b: string) => { const x = Buffer.from(a), y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); };
|
|
22
|
+
await app.register(compress);
|
|
23
|
+
app.addHook('onRequest', async (request, reply) => {
|
|
24
|
+
reply.header('X-Content-Type-Options', 'nosniff').header('Referrer-Policy', 'same-origin').header('X-Frame-Options', 'SAMEORIGIN').header('Cache-Control', 'no-store');
|
|
25
|
+
const denied = authorize(request.headers, request.raw.socket.remoteAddress, PORT, PUBLIC_ORIGIN, OWNER);
|
|
26
|
+
if (denied) return reply.code(403).send({ error: denied });
|
|
27
|
+
if (request.method === 'POST' && !request.url.startsWith('/review/') && !equals(String(request.headers['x-papilot-token'] || ''), csrf)) return reply.code(403).send({ error: 'This page has expired. Refresh it and try again.' });
|
|
28
|
+
});
|
|
29
|
+
app.setErrorHandler((error, _request, reply) => { reply.code((error as any).statusCode || 400).send({ error: (error as Error).message }); });
|
|
30
|
+
app.get('/api/health', () => ({ ok: true, name: 'Papilot', version: '0.1.0' }));
|
|
31
|
+
app.get('/api/state', () => ({ ...workspace.snapshot(), csrf }));
|
|
32
|
+
app.get('/api/events', (request, reply) => {
|
|
33
|
+
reply.hijack();
|
|
34
|
+
reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
|
|
35
|
+
reply.raw.write('event: change\ndata: {}\n\n');
|
|
36
|
+
let timer: NodeJS.Timeout | undefined;
|
|
37
|
+
const change = () => { timer ??= setTimeout(() => { timer = undefined; reply.raw.write('event: change\ndata: {}\n\n'); }, 400); };
|
|
38
|
+
workspace.on('change', change);
|
|
39
|
+
const heartbeat = setInterval(() => reply.raw.write(': heartbeat\n\n'), 15000);
|
|
40
|
+
reply.raw.on('close', () => { clearInterval(heartbeat); if (timer) clearTimeout(timer); workspace.off('change', change); });
|
|
41
|
+
});
|
|
42
|
+
app.post('/api/reconnect', async () => { await workspace.connect(); return workspace.runtime; });
|
|
43
|
+
// Codex names reasoning efforts with short lowercase words (low, medium, xhigh).
|
|
44
|
+
const agent = (value: unknown): AgentId => value === 'claude' || value === 'antigravity' ? value : 'codex';
|
|
45
|
+
const effort = (value: unknown) => typeof value === 'string' && /^[a-z]{1,16}$/.test(value) ? value : undefined;
|
|
46
|
+
const string = (value: unknown, name: string, max = 20000) => { if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(`${name} is required and must be no longer than ${max} characters.`); return value.trim(); };
|
|
47
|
+
app.post('/api/projects', async request => { const b = request.body as any; return workspace.addProject(string(b.path, 'Repository path', 4000), b.name ? string(b.name, 'Paper name', 120) : undefined); });
|
|
48
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/rename', request => workspace.renameProject(request.params.id, string((request.body as any).name, 'Paper name', 120)));
|
|
49
|
+
app.post('/api/tasks', async request => {
|
|
50
|
+
const b = request.body as any;
|
|
51
|
+
return workspace.createTask({ id: string(b.id, 'Task ID', 64), projectId: string(b.projectId, 'Paper ID', 64), title: string(b.title, 'Task title', 120), instructions: string(b.instructions, 'Instructions'), agent: agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined, effort: effort(b.effort) });
|
|
52
|
+
});
|
|
53
|
+
app.get<{ Params: { id: string } }>('/api/tasks/:id/items', request => { store.task(request.params.id); return { items: store.items(request.params.id) }; });
|
|
54
|
+
app.get<{ Params: { id: string }; Querystring: { round?: string; version?: string; download?: string } }>('/api/tasks/:id/pdf', async (request, reply) => {
|
|
55
|
+
const task = store.task(request.params.id), project = store.project(task.projectId);
|
|
56
|
+
const round = request.query.round === undefined ? task.review?.round : Number(request.query.round);
|
|
57
|
+
const side = request.query.version || 'after';
|
|
58
|
+
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.' });
|
|
59
|
+
const pdf = await fullPaperPdf(project.path, task.name, round, side);
|
|
60
|
+
const filename = `papilot-${task.name}-r${round}-${side}.pdf`;
|
|
61
|
+
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 });
|
|
62
|
+
});
|
|
63
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/messages', async request => {
|
|
64
|
+
const b = request.body as any;
|
|
65
|
+
return workspace.enqueue(request.params.id, string(b.text, 'Revision instructions'), string(b.requestKey, 'Request ID', 100), true);
|
|
66
|
+
});
|
|
67
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/rename', request => workspace.renameTask(request.params.id, string((request.body as any).title, 'Task title', 120)));
|
|
68
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/interrupt', request => workspace.interrupt(request.params.id).then(() => ({ ok: true })));
|
|
69
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/delete', request => {
|
|
70
|
+
if ((request.body as any)?.confirm !== true) throw new Error('Confirm permanent task deletion before continuing.');
|
|
71
|
+
return workspace.deleteTask(request.params.id);
|
|
72
|
+
});
|
|
73
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/reconcile', request => workspace.reconcile(request.params.id));
|
|
74
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/publish', request => {
|
|
75
|
+
const task = store.task(request.params.id); if (['running', 'waiting_input', 'building'].includes(task.status)) throw new Error('This task is busy. Wait for it to finish or pause it first.');
|
|
76
|
+
void workspace.publish(task.id); return { ok: true };
|
|
77
|
+
});
|
|
78
|
+
// The manuscript preview compiles each paper's working tree and reports its Git status.
|
|
79
|
+
app.get<{ Params: { id: string } }>('/api/projects/:id/manuscript', async request => ({ ...(await manuscripts.state(request.params.id, store.project(request.params.id).path)), annotations: store.annotations(request.params.id).filter(note => !note.taskId) }));
|
|
80
|
+
app.get<{ Params: { id: string } }>('/api/projects/:id/git', request => gitStatus(store.project(request.params.id).path));
|
|
81
|
+
// Git writes to one repository run one at a time; each returns the new status.
|
|
82
|
+
const gitQueue = new Map<string, Promise<unknown>>();
|
|
83
|
+
function serialGit<T>(projectId: string, work: () => Promise<T>) {
|
|
84
|
+
const run = (gitQueue.get(projectId) || Promise.resolve()).catch(() => {}).then(work);
|
|
85
|
+
gitQueue.set(projectId, run);
|
|
86
|
+
return run.finally(() => { if (gitQueue.get(projectId) === run) gitQueue.delete(projectId); });
|
|
87
|
+
}
|
|
88
|
+
app.get<{ Params: { id: string }; Querystring: { path?: string } }>('/api/projects/:id/git/diff', request => gitDiff(store.project(request.params.id).path, string(request.query.path, 'File path', 4096)));
|
|
89
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/git/commit', request => {
|
|
90
|
+
const project = store.project(request.params.id), b = request.body as any;
|
|
91
|
+
const message = string(b.message, 'Commit message', 5000), paths = Array.isArray(b.paths) ? b.paths.filter((p: unknown) => typeof p === 'string').slice(0, 5000) : [];
|
|
92
|
+
return serialGit(project.id, async () => ({ commit: await gitCommit(project.path, message, paths), git: await gitStatus(project.path) }));
|
|
93
|
+
});
|
|
94
|
+
app.post<{ Params: { id: string; action: string } }>('/api/projects/:id/git/:action', request => {
|
|
95
|
+
const project = store.project(request.params.id), action = request.params.action;
|
|
96
|
+
if (action !== 'fetch' && action !== 'pull' && action !== 'push') throw Object.assign(new Error('Unknown Git action.'), { statusCode: 404 });
|
|
97
|
+
return serialGit(project.id, async () => { await gitRemote(project.path, action); return { git: await gitStatus(project.path) }; });
|
|
98
|
+
});
|
|
99
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/manuscript', async request => ({ ...(await manuscripts.state(request.params.id, store.project(request.params.id).path, true)), annotations: store.annotations(request.params.id).filter(note => !note.taskId) }));
|
|
100
|
+
// Notes marked on the manuscript PDF become one agent task when sent.
|
|
101
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/annotations', async request => {
|
|
102
|
+
const project = store.project(request.params.id), b = request.body as any;
|
|
103
|
+
const isRect = (value: unknown): value is [number, number, number, number] => Array.isArray(value) && value.length === 4 && value.every(v => typeof v === 'number' && v >= 0 && v <= 1) && value[2] > value[0] && value[3] > value[1];
|
|
104
|
+
if (!isRect(b.rect)) throw new Error('Select text or mark a region on the page first.');
|
|
105
|
+
const rects = Array.isArray(b.rects) ? b.rects.slice(0, 80).filter(isRect) : [];
|
|
106
|
+
const comment = string(b.comment, 'Note', 4000), key = string(b.key, 'Preview key', 64), page = Number(b.page);
|
|
107
|
+
const selected = typeof b.quote === 'string' ? b.quote.slice(0, 2000) : '';
|
|
108
|
+
const { quote, source } = await manuscripts.locate(project.id, key, page, b.rect, rects, selected);
|
|
109
|
+
return store.saveAnnotation({ id: randomUUID(), projectId: project.id, key, page, rect: b.rect, ...(rects.length ? { rects } : {}), comment, quote, source, createdAt: Date.now() });
|
|
110
|
+
});
|
|
111
|
+
app.post<{ Params: { id: string; note: string } }>('/api/projects/:id/annotations/:note/delete', request => { store.deleteAnnotation(store.project(request.params.id).id, request.params.note); return { ok: true }; });
|
|
112
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/annotations/send', async request => {
|
|
113
|
+
const project = store.project(request.params.id), b = request.body as any, id = string(b.id, 'Task ID', 64);
|
|
114
|
+
const existing = store.tasks().find(task => task.id === id);
|
|
115
|
+
if (existing) return existing;
|
|
116
|
+
const notes = store.annotations(project.id).filter(note => !note.taskId);
|
|
117
|
+
if (!notes.length) throw new Error('Mark a region on the PDF and add a note first.');
|
|
118
|
+
const { title, instructions } = annotationTask(notes);
|
|
119
|
+
const task = await workspace.createTask({ id, projectId: project.id, title, instructions, agent: agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined, effort: effort(b.effort) });
|
|
120
|
+
for (const note of notes) store.saveAnnotation({ ...note, taskId: task.id });
|
|
121
|
+
return task;
|
|
122
|
+
});
|
|
123
|
+
// Exact word places for the PDF view's selection layer; immutable per build key.
|
|
124
|
+
app.get<{ Params: { id: string }; Querystring: { key?: string; page?: string } }>('/api/projects/:id/manuscript/words', async (request, reply) => {
|
|
125
|
+
const project = store.project(request.params.id);
|
|
126
|
+
const words = await manuscripts.words(project.id, String(request.query.key || ''), Number(request.query.page));
|
|
127
|
+
return reply.header('Cache-Control', 'private, max-age=31536000, immutable').send(words);
|
|
128
|
+
});
|
|
129
|
+
app.get<{ Params: { id: string }; Querystring: { key?: string; download?: string } }>('/api/projects/:id/manuscript.pdf', (request, reply) => {
|
|
130
|
+
const project = store.project(request.params.id), pdf = manuscripts.pdf(project.id, String(request.query.key || ''));
|
|
131
|
+
const name = (project.name.replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'manuscript') + '.pdf';
|
|
132
|
+
// Each key names one immutable build, so the viewer can cache and range-read it.
|
|
133
|
+
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 });
|
|
134
|
+
});
|
|
135
|
+
app.post<{ Params: { id: string } }>('/api/requests/:id', request => { workspace.answer(request.params.id, request.body as any); return { ok: true }; });
|
|
136
|
+
|
|
137
|
+
app.route<{ Params: { projectId: string; '*': string } }>({
|
|
138
|
+
method: ['GET', 'POST'], url: '/review/:projectId/*', handler: async (request, reply) => {
|
|
139
|
+
const project = store.project(request.params.projectId);
|
|
140
|
+
const route = '/' + request.params['*'];
|
|
141
|
+
if (route.includes('..') || !allowedReviewRoute(route, request.method)) return reply.code(404).send({ error: 'Review resource not found.' });
|
|
142
|
+
const client = await reviewClient(project.path);
|
|
143
|
+
if (request.method === 'POST') {
|
|
144
|
+
if (!equals(String(request.headers['x-review-token'] || ''), client.token)) return reply.code(403).send({ error: 'This review page has expired. Refresh it and try again.' });
|
|
145
|
+
}
|
|
146
|
+
const reviewTask = request.method === 'POST' ? store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]) : undefined;
|
|
147
|
+
const writesFiles = route.endsWith('/undo') || (route.endsWith('/decide') && (request.body as any)?.action !== 'comment');
|
|
148
|
+
if (request.method === 'POST' && writesFiles) string((request.body as any)?.id, 'Review action ID', 80);
|
|
149
|
+
const forward = async () => {
|
|
150
|
+
const search = new URL(request.url, 'http://localhost').search;
|
|
151
|
+
const response = await fetch(client.url + route + search, {
|
|
152
|
+
method: request.method, headers: { 'Content-Type': 'application/json', 'X-Review-Token': client.token },
|
|
153
|
+
...(request.method === 'POST' ? { body: JSON.stringify(request.body) } : {}),
|
|
154
|
+
signal: AbortSignal.timeout(120000), redirect: 'error',
|
|
155
|
+
});
|
|
156
|
+
reply.code(response.status);
|
|
157
|
+
const type = response.headers.get('content-type') || 'application/octet-stream';
|
|
158
|
+
const prefix = `/review/${project.id}`;
|
|
159
|
+
if (type.includes('application/json')) {
|
|
160
|
+
const value = await response.json();
|
|
161
|
+
if (reviewTask && writesFiles && !confirmedReviewResponse(response.status, value, reviewTask.name)) throw new Error('The review engine could not confirm this action. Select Check status before continuing.');
|
|
162
|
+
if (!response.ok && request.method === 'POST' && (value as any).error) {
|
|
163
|
+
const task = store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]);
|
|
164
|
+
if (task) store.updateTask(task.id, { error: 'Review action failed: ' + (value as any).error });
|
|
165
|
+
}
|
|
166
|
+
if (response.ok && request.method === 'POST') {
|
|
167
|
+
const task = store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]);
|
|
168
|
+
if (task) {
|
|
169
|
+
store.updateTask(task.id, { error: null });
|
|
170
|
+
await workspace.refresh(task.id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return rewriteReviewJson(value, prefix);
|
|
174
|
+
}
|
|
175
|
+
if (reviewTask && writesFiles) throw new Error('The review engine returned an unexpected response. Select Check status before continuing.');
|
|
176
|
+
reply.type(type);
|
|
177
|
+
if (type.includes('text/html')) return rewriteReviewHtml(await response.text(), prefix);
|
|
178
|
+
const cache = response.headers.get('cache-control'); if (cache && route.startsWith('/files/')) reply.header('Cache-Control', cache);
|
|
179
|
+
if (route.startsWith('/assets/')) reply.header('Cache-Control', 'private, max-age=31536000, immutable');
|
|
180
|
+
return Buffer.from(await response.arrayBuffer());
|
|
181
|
+
};
|
|
182
|
+
if (request.method === 'POST' && !reviewTask) throw new Error('This review task does not belong to this workspace.');
|
|
183
|
+
const result = await (reviewTask ? workspace.reviewWrite(reviewTask.id, writesFiles, forward, writesFiles ? { route, body: request.body as Record<string, unknown> } : undefined) : forward());
|
|
184
|
+
return annotateReviewState(result, store.tasks().filter(task => task.projectId === project.id));
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (fs.existsSync(path.join(ROOT, 'dist/index.html'))) {
|
|
189
|
+
await registerFrontend(app, path.join(ROOT, 'dist'));
|
|
190
|
+
} else app.get('/', (_request, reply) => reply.type('text/plain').send('Papilot: run npm run build, then restart the server.'));
|
|
191
|
+
|
|
192
|
+
await app.listen({ host: '127.0.0.1', port: PORT });
|
|
193
|
+
store.recover();
|
|
194
|
+
console.log(`Papilot is running at http://127.0.0.1:${PORT}${PUBLIC_ORIGIN ? `\nRemote: ${PUBLIC_ORIGIN}` : ''}`);
|
|
195
|
+
void workspace.connect();
|
|
196
|
+
for (const task of store.tasks().filter(t => t.status === 'building')) { store.updateTask(task.id, { status: 'interrupted', error: 'The server restarted during the PDF build. Check the review and rebuild it if needed.' }); void workspace.refresh(task.id); }
|
|
197
|
+
let shuttingDown = false;
|
|
198
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
|
|
199
|
+
if (shuttingDown) return; shuttingDown = true; workspace.close();
|
|
200
|
+
void app.close().finally(() => { store.close(); process.exit(0); });
|
|
201
|
+
setTimeout(() => process.exit(0), 2500).unref();
|
|
202
|
+
});
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { ENGINE } from './paths.ts';
|
|
10
|
+
import type { Annotation, GitStatus, ManuscriptBuild } from '../shared.ts';
|
|
11
|
+
import { notesInstructions } from '../notes.ts';
|
|
12
|
+
|
|
13
|
+
type Rect = [number, number, number, number];
|
|
14
|
+
|
|
15
|
+
const exec = promisify(execFile);
|
|
16
|
+
async function git(root: string, args: string[], env: NodeJS.ProcessEnv = {}) {
|
|
17
|
+
return (await exec('git', args, { cwd: root, env: { ...process.env, ...env }, maxBuffer: 64 * 1024 * 1024 })).stdout;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Porcelain v2 keeps paths with spaces intact under -z and reports branch,
|
|
21
|
+
// upstream and ahead/behind counts without a second command.
|
|
22
|
+
export function parseStatus(output: string, numstat: string): Omit<GitStatus, 'head'> {
|
|
23
|
+
const lines = new Map<string, [number, number]>();
|
|
24
|
+
const stats = numstat.split('\0');
|
|
25
|
+
for (let i = 0; i < stats.length; i++) {
|
|
26
|
+
const [added, deleted, file] = stats[i].split('\t');
|
|
27
|
+
if (!added) continue;
|
|
28
|
+
// A rename lists an empty path followed by the old and new paths.
|
|
29
|
+
const target = file || (i += 2, stats[i]);
|
|
30
|
+
if (target && added !== '-') lines.set(target, [Number(added), Number(deleted)]);
|
|
31
|
+
}
|
|
32
|
+
const status: Omit<GitStatus, 'head'> = { branch: null, upstream: null, ahead: 0, behind: 0, files: [] };
|
|
33
|
+
const fields = output.split('\0');
|
|
34
|
+
for (let i = 0; i < fields.length; i++) {
|
|
35
|
+
const field = fields[i];
|
|
36
|
+
if (field.startsWith('# branch.head ')) status.branch = field.slice(14) === '(detached)' ? null : field.slice(14);
|
|
37
|
+
else if (field.startsWith('# branch.upstream ')) status.upstream = field.slice(18);
|
|
38
|
+
else if (field.startsWith('# branch.ab ')) { const [ahead, behind] = field.slice(12).split(' '); status.ahead = Number(ahead); status.behind = -Number(behind); }
|
|
39
|
+
else if (field.startsWith('? ')) status.files.push({ path: field.slice(2), status: '?', staged: false });
|
|
40
|
+
else if (/^[12u] /.test(field)) {
|
|
41
|
+
const parts = field.split(' '), xy = parts[1], kind = field[0];
|
|
42
|
+
const file = parts.slice(kind === '1' ? 8 : kind === '2' ? 9 : 10).join(' ');
|
|
43
|
+
// A rename or copy is followed by its original path.
|
|
44
|
+
const from = kind === '2' ? fields[++i] : undefined;
|
|
45
|
+
const code = kind === 'u' ? 'U' : xy[1] !== '.' ? xy[1] : xy[0];
|
|
46
|
+
status.files.push({ path: file, status: code, staged: xy[0] !== '.', lines: lines.get(file), ...(from ? { from } : {}) });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return status;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function gitStatus(root: string): Promise<GitStatus> {
|
|
53
|
+
const [status, numstat, head, remotes] = await Promise.all([
|
|
54
|
+
git(root, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all']),
|
|
55
|
+
git(root, ['diff', '--numstat', '-z', 'HEAD']).catch(() => ''),
|
|
56
|
+
git(root, ['log', '-1', '--format=%h%x00%s%x00%ct']).catch(() => ''),
|
|
57
|
+
git(root, ['remote']).catch(() => ''),
|
|
58
|
+
]);
|
|
59
|
+
const [hash, subject, time] = head.trim().split('\0');
|
|
60
|
+
return { ...parseStatus(status, numstat), head: hash ? { hash, subject, date: Number(time) * 1000 } : null, remote: Boolean(remotes.trim()) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Paths from the browser are matched against the changed files, and Git reads
|
|
64
|
+
// them literally, so a name cannot act as a pathspec pattern.
|
|
65
|
+
const literal = { GIT_LITERAL_PATHSPECS: '1' };
|
|
66
|
+
const DIFF_LIMIT = 400_000;
|
|
67
|
+
|
|
68
|
+
// One changed file's diff against HEAD; an untracked file shows as all new.
|
|
69
|
+
export async function gitDiff(root: string, file: string) {
|
|
70
|
+
const entry = (await gitStatus(root)).files.find(item => item.path === file);
|
|
71
|
+
if (!entry) throw Object.assign(new Error('That file has no uncommitted changes.'), { statusCode: 404 });
|
|
72
|
+
const diff = entry.status === '?'
|
|
73
|
+
// `git diff --no-index` exits with 1 when the files differ.
|
|
74
|
+
? await exec('git', ['diff', '--no-index', '--no-color', '--', '/dev/null', file], { cwd: root, maxBuffer: 64 * 1024 * 1024 }).then(r => r.stdout, (e: { stdout?: string }) => e.stdout || '')
|
|
75
|
+
: await git(root, ['diff', '--no-color', '--find-renames', 'HEAD', '--', ...(entry.from ? [entry.from] : []), file], literal);
|
|
76
|
+
return { path: file, diff: diff.slice(0, DIFF_LIMIT), truncated: diff.length > DIFF_LIMIT };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Commit exactly the chosen files as they are on disk. Changes staged
|
|
80
|
+
// elsewhere for other files stay staged and out of this commit.
|
|
81
|
+
export async function gitCommit(root: string, message: string, paths: string[]) {
|
|
82
|
+
const files = (await gitStatus(root)).files.filter(item => paths.includes(item.path));
|
|
83
|
+
if (!files.length) throw new Error('Select at least one changed file to commit.');
|
|
84
|
+
if (files.some(item => item.status === 'U')) throw new Error('Resolve the merge conflicts in the selected files before committing.');
|
|
85
|
+
// A rename is already staged, so only its new path is added; the commit
|
|
86
|
+
// names the old path too, so the removal is part of it.
|
|
87
|
+
await gitAction(root, ['add', '-A', '--', ...files.map(item => item.path)]);
|
|
88
|
+
await gitAction(root, ['commit', '--only', '-m', message, '--', ...new Set(files.flatMap(item => item.from ? [item.from, item.path] : [item.path]))]);
|
|
89
|
+
return (await git(root, ['rev-parse', '--short', 'HEAD'])).trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Fetch, fast-forward pull and push use the host's Git credentials. Nothing
|
|
93
|
+
// may prompt, so a missing credential fails instead of hanging.
|
|
94
|
+
export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'push') {
|
|
95
|
+
const status = await gitStatus(root);
|
|
96
|
+
if (!status.remote) throw new Error('This repository has no remote.');
|
|
97
|
+
if (action === 'fetch') await gitAction(root, ['fetch', '--prune'], 120_000);
|
|
98
|
+
else if (action === 'pull') {
|
|
99
|
+
if (!status.upstream) throw new Error('This branch does not track a remote branch yet.');
|
|
100
|
+
await gitAction(root, ['pull', '--ff-only'], 120_000);
|
|
101
|
+
} else if (status.upstream) await gitAction(root, ['push'], 120_000);
|
|
102
|
+
else {
|
|
103
|
+
if (!status.branch) throw new Error('Check out a branch before pushing.');
|
|
104
|
+
const remotes = (await git(root, ['remote'])).split('\n').filter(Boolean);
|
|
105
|
+
await gitAction(root, ['push', '-u', remotes.includes('origin') ? 'origin' : remotes[0], status.branch], 120_000);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Git's own message, without the usage noise, becomes the error the user sees.
|
|
110
|
+
async function gitAction(root: string, args: string[], timeout = 60_000) {
|
|
111
|
+
try { return (await exec('git', args, { cwd: root, timeout, env: { ...process.env, ...literal, GIT_TERMINAL_PROMPT: '0' }, maxBuffer: 64 * 1024 * 1024 })).stdout; }
|
|
112
|
+
catch (error) {
|
|
113
|
+
const e = error as { stderr?: string; stdout?: string; killed?: boolean; message: string };
|
|
114
|
+
const detail = (e.stderr || e.stdout || '').split('\n').map(line => line.trim()).filter(line => line && !/^(hint|To |From ):?/.test(line)).slice(-4).join(' ');
|
|
115
|
+
throw new Error(e.killed ? `git ${args[0]} timed out.` : detail || e.message);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The preview shows the manuscript as it is on disk, including accepted but
|
|
120
|
+
// uncommitted review changes. A temporary index records it as a Git tree
|
|
121
|
+
// without touching the real index, branches or files.
|
|
122
|
+
export async function workingTree(root: string) {
|
|
123
|
+
const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-index-')), env = { GIT_INDEX_FILE: path.join(folder, 'index') };
|
|
124
|
+
try {
|
|
125
|
+
await git(root, ['read-tree', 'HEAD'], env);
|
|
126
|
+
await git(root, ['add', '-A'], env);
|
|
127
|
+
return (await git(root, ['write-tree'], env)).trim();
|
|
128
|
+
} finally { fs.rmSync(folder, { recursive: true, force: true }); }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function engine() {
|
|
132
|
+
const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter }] = await Promise.all([
|
|
133
|
+
import(pathToFileURL(path.join(ENGINE, 'src/core/build.ts')).href),
|
|
134
|
+
import(pathToFileURL(path.join(ENGINE, 'src/core/git.ts')).href),
|
|
135
|
+
import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href),
|
|
136
|
+
import(pathToFileURL(path.join(ENGINE, 'src/render/pdf.ts')).href),
|
|
137
|
+
]);
|
|
138
|
+
return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// `synctex edit` maps a PDF position (big points from the top-left corner) to
|
|
142
|
+
// the source file and line that produced it.
|
|
143
|
+
export function parseSynctex(output: string) {
|
|
144
|
+
const input = /^Input:(.*)$/m.exec(output)?.[1], line = Number(/^Line:(\d+)$/m.exec(output)?.[1]);
|
|
145
|
+
return input && line > 0 ? { input: path.normalize(input), line } : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// One task for every pending note, quoting the marked text and its source so
|
|
149
|
+
// the agent can find each place without guessing.
|
|
150
|
+
export function annotationTask(notes: Annotation[]) {
|
|
151
|
+
const first = notes[0].comment.split('\n').find(line => line.trim())?.trim() || 'Address a PDF note';
|
|
152
|
+
const title = notes.length === 1 ? (first.length > 80 ? first.slice(0, 79).replace(/\s+\S*$/, '') + '…' : first) : `Address ${notes.length} PDF notes`;
|
|
153
|
+
return { title, instructions: notesInstructions(notes) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The LaTeX the marked region came from, read from the exact tree that was
|
|
157
|
+
// compiled. Long ranges keep their first lines.
|
|
158
|
+
// Where one end of a selection sits in its printed line: the line's letters and
|
|
159
|
+
// the index of the marked letter among them.
|
|
160
|
+
export interface Anchor { line: string; at: number }
|
|
161
|
+
const letters = (text: string) => text.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
162
|
+
|
|
163
|
+
// The printed line around a marked character, for settleLines.
|
|
164
|
+
export function anchor(chars: { text: string }[], index: number, end: boolean): Anchor | null {
|
|
165
|
+
const line = letters(chars.map(c => c.text).join(''));
|
|
166
|
+
if (!line) return null;
|
|
167
|
+
const at = end ? letters(chars.slice(0, index + 1).map(c => c.text).join('')).length - 1 : letters(chars.slice(0, index).map(c => c.text).join('')).length;
|
|
168
|
+
return { line, at: Math.min(Math.max(at, 0), line.length - 1) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// SyncTeX records spaces and boxes rather than characters, so a word that
|
|
172
|
+
// starts a printed line is often credited to the source line before it, whose
|
|
173
|
+
// line break made the space. Settle each end of the selection by finding its
|
|
174
|
+
// printed line among the source lines around SyncTeX's answer; text that does
|
|
175
|
+
// not match, such as formulas, keeps SyncTeX's lines.
|
|
176
|
+
export function settleLines(source: string[], [start, end]: [number, number], first: Anchor | null, last: Anchor | null): [number, number] {
|
|
177
|
+
const low = Math.max(1, start - 4), high = Math.min(source.length, end + 4);
|
|
178
|
+
let text = '';
|
|
179
|
+
const owner: number[] = [];
|
|
180
|
+
for (let number = low; number <= high; number++) {
|
|
181
|
+
const plain = letters(source[number - 1].replace(/(^|[^\\])%.*$/, '$1').replace(/\\[a-zA-Z@]+\*?/g, ' '));
|
|
182
|
+
text += plain; owner.push(...Array<number>(plain.length).fill(number));
|
|
183
|
+
}
|
|
184
|
+
const distance = (line: number) => Math.max(0, start - line, line - end);
|
|
185
|
+
const locate = (mark: Anchor | null) => {
|
|
186
|
+
if (!mark) return null;
|
|
187
|
+
// The whole printed line pins the place best; shorter context around the
|
|
188
|
+
// mark still matches when part of the line does not appear in the source.
|
|
189
|
+
for (const reach of [Infinity, 24, 10]) {
|
|
190
|
+
const from = Math.max(0, mark.at - reach), needle = mark.line.slice(from, mark.at + reach + 1);
|
|
191
|
+
let best: number | null = null;
|
|
192
|
+
for (let index = text.indexOf(needle); index >= 0; index = text.indexOf(needle, index + 1)) {
|
|
193
|
+
const line = owner[index + mark.at - from];
|
|
194
|
+
if (best === null || distance(line) < distance(best)) best = line;
|
|
195
|
+
}
|
|
196
|
+
if (best !== null) return best;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
};
|
|
200
|
+
const from = locate(first) ?? start, to = locate(last) ?? end;
|
|
201
|
+
return from <= to ? [from, to] : [start, end];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function sourceExcerpt(file: string, [start, end]: [number, number], limit = 20) {
|
|
205
|
+
if (!fs.existsSync(file)) return undefined;
|
|
206
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').slice(start - 1, end).map(line => line.trimEnd());
|
|
207
|
+
return (lines.length > limit ? [...lines.slice(0, limit), '…'] : lines).join('\n');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Every word on a page with its exact place, for the PDF view's selection
|
|
211
|
+
// layer: [text, left, baseline, font size, width, ends a line], in points from
|
|
212
|
+
// the page's top left. pdf.js lays a whole line out in a stand-in font and
|
|
213
|
+
// drifts from the printed words mid-line; per-word runs cannot drift.
|
|
214
|
+
export async function pdfWords(pdf: string, page: number) {
|
|
215
|
+
const { PdfDocument } = await engine();
|
|
216
|
+
const document = new PdfDocument(pdf);
|
|
217
|
+
try {
|
|
218
|
+
if (!Number.isInteger(page) || page < 0 || page >= document.length) throw Object.assign(new Error('That page is not part of the manuscript.'), { statusCode: 400 });
|
|
219
|
+
const raw = document.raw.loadPage(page), [x0, y0, x1, y1] = raw.getBounds();
|
|
220
|
+
const words: { text: string; left: number; right: number; baseline: number; size: number; eol: boolean }[] = [];
|
|
221
|
+
let word: (typeof words)[number] | null = null;
|
|
222
|
+
const text = raw.toStructuredText('preserve-whitespace');
|
|
223
|
+
text.walk({
|
|
224
|
+
beginLine: () => { word = null; },
|
|
225
|
+
onChar: (c: string, origin: [number, number], _font: unknown, size: number, quad: number[]) => {
|
|
226
|
+
if (/\s/u.test(c)) { word = null; return; }
|
|
227
|
+
const left = Math.min(quad[0], quad[2], quad[4], quad[6]) - x0, right = Math.max(quad[0], quad[2], quad[4], quad[6]) - x0;
|
|
228
|
+
if (!word) { word = { text: '', left, right, baseline: origin[1] - y0, size, eol: false }; words.push(word); }
|
|
229
|
+
word.text += c; word.right = Math.max(word.right, right);
|
|
230
|
+
},
|
|
231
|
+
endLine: () => { if (words.length) words[words.length - 1].eol = true; },
|
|
232
|
+
});
|
|
233
|
+
text.destroy(); raw.destroy();
|
|
234
|
+
const round = (value: number) => Math.round(value * 100) / 100;
|
|
235
|
+
return { width: round(x1 - x0), height: round(y1 - y0), words: words.map(w => [w.text, round(w.left), round(w.baseline), round(w.size), round(w.right - w.left), w.eol ? 1 : 0] as [string, number, number, number, number, number]) };
|
|
236
|
+
} finally { document.close(); }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
interface Build { tree: string; status: ManuscriptBuild['status']; key?: string; pdf?: string; root?: string; sourceDir?: string; pages?: [number, number][]; builtAt?: number; error?: string }
|
|
240
|
+
|
|
241
|
+
export class Manuscripts extends EventEmitter {
|
|
242
|
+
private builds = new Map<string, Build>();
|
|
243
|
+
|
|
244
|
+
async state(projectId: string, root: string, rebuild = false): Promise<{ git: GitStatus; build: ManuscriptBuild }> {
|
|
245
|
+
const [status, tree] = await Promise.all([gitStatus(root), workingTree(root)]);
|
|
246
|
+
let build = this.builds.get(projectId);
|
|
247
|
+
if (!build || (build.status !== 'building' && (build.tree !== tree || (rebuild && build.status === 'failed')))) build = this.start(projectId, root, tree, build);
|
|
248
|
+
const { status: state, key, pages, builtAt, error } = build;
|
|
249
|
+
return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready' } };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Keep the previous pages visible while a newer working tree compiles.
|
|
253
|
+
private start(projectId: string, root: string, tree: string, previous?: Build) {
|
|
254
|
+
const build: Build = { tree, status: 'building', key: previous?.key, pdf: previous?.pdf, pages: previous?.pages, builtAt: previous?.builtAt };
|
|
255
|
+
this.builds.set(projectId, build);
|
|
256
|
+
void (async () => {
|
|
257
|
+
try {
|
|
258
|
+
const { Builder, Repo, loadProject, mupdf } = await engine();
|
|
259
|
+
const project = loadProject(root), result = await new Builder(project, new Repo(root)).build(tree, ['full']);
|
|
260
|
+
const document = mupdf.Document.openDocument(fs.readFileSync(result.pdf), 'application/pdf');
|
|
261
|
+
const pages: [number, number][] = [];
|
|
262
|
+
for (let i = 0; i < document.countPages(); i++) { const [x0, y0, x1, y1] = document.loadPage(i).getBounds(); pages.push([Math.round(x1 - x0), Math.round(y1 - y0)]); }
|
|
263
|
+
document.destroy?.();
|
|
264
|
+
const key = createHash('sha256').update(result.key + result.pdf).digest('hex').slice(0, 16);
|
|
265
|
+
if (this.builds.get(projectId) !== build) return;
|
|
266
|
+
Object.assign(build, { status: 'ready', key, pdf: result.pdf, root: result.root, sourceDir: project.source_dir || '', pages, builtAt: Date.now(), error: undefined });
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (this.builds.get(projectId) !== build) return;
|
|
269
|
+
const detail = error as { message?: string; details?: string[] };
|
|
270
|
+
Object.assign(build, { status: 'failed', error: [detail.message, ...(detail.details || [])].filter(Boolean).join('\n') || 'The manuscript could not be compiled.' });
|
|
271
|
+
}
|
|
272
|
+
this.emit('change', projectId);
|
|
273
|
+
})();
|
|
274
|
+
return build;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
pdf(projectId: string, key: string) {
|
|
278
|
+
const build = this.builds.get(projectId);
|
|
279
|
+
if (!build?.pdf || build.key !== key) throw Object.assign(new Error('This manuscript preview is out of date. Refresh it and try again.'), { statusCode: 404 });
|
|
280
|
+
return build.pdf;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// A note keeps the text under the marked region and its LaTeX source, so it
|
|
284
|
+
// stays meaningful after the manuscript changes and the preview recompiles.
|
|
285
|
+
// A text selection arrives as one rectangle per line plus the selected text;
|
|
286
|
+
// a marked region arrives as one rectangle.
|
|
287
|
+
// Word places for the PDF view's selection layer, cached per build and page.
|
|
288
|
+
private wordCache = new Map<string, Awaited<ReturnType<typeof pdfWords>>>();
|
|
289
|
+
async words(projectId: string, key: string, page: number) {
|
|
290
|
+
const cacheKey = `${projectId}:${key}:${page}`, cached = this.wordCache.get(cacheKey);
|
|
291
|
+
if (cached) return cached;
|
|
292
|
+
const result = await pdfWords(this.pdf(projectId, key), page);
|
|
293
|
+
if (this.wordCache.size > 200) this.wordCache.delete(this.wordCache.keys().next().value!);
|
|
294
|
+
this.wordCache.set(cacheKey, result);
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async locate(projectId: string, key: string, page: number, rect: Rect, rects: Rect[] = [], selected = '') {
|
|
299
|
+
const pdf = this.pdf(projectId, key), build = this.builds.get(projectId)!;
|
|
300
|
+
const { PdfDocument, containsCenter } = await engine();
|
|
301
|
+
const document = new PdfDocument(pdf);
|
|
302
|
+
try {
|
|
303
|
+
if (!Number.isInteger(page) || page < 0 || page >= document.length) throw Object.assign(new Error('That page is not part of the manuscript.'), { statusCode: 400 });
|
|
304
|
+
const current = document.page(page), [x0, y0, x1, y1] = current.bounds, width = x1 - x0, height = y1 - y0;
|
|
305
|
+
const toPage = (r: Rect) => [x0 + r[0] * width, y0 + r[1] * height, x0 + r[2] * width, y0 + r[3] * height];
|
|
306
|
+
const clips = (rects.length ? rects : [rect]).map(toPage);
|
|
307
|
+
const quote = (selected || clips.map(clip => current.text(clip)).join(' ')).replace(/\uFFFD/g, '').replace(/\s+/g, ' ').trim().slice(0, 600);
|
|
308
|
+
// Ask SyncTeX about the first and last marked character of every line, so
|
|
309
|
+
// the source range covers the whole selection rather than its first line.
|
|
310
|
+
const points: [number, number][] = [], centre = (r: number[]): [number, number] => [(r[0] + r[2]) / 2, (r[1] + r[3]) / 2];
|
|
311
|
+
let first: Anchor | null = null, last: Anchor | null = null;
|
|
312
|
+
for (const line of current.lines as { chars: { text: string; rect: number[] }[] }[]) {
|
|
313
|
+
const marked = line.chars.flatMap((c, index) => clips.some(clip => containsCenter(clip, c.rect)) ? [index] : []);
|
|
314
|
+
if (!marked.length) continue;
|
|
315
|
+
if (!first && !last) first = anchor(line.chars, marked[0], false);
|
|
316
|
+
last = anchor(line.chars, marked[marked.length - 1], true);
|
|
317
|
+
if (points.length >= 24) continue;
|
|
318
|
+
points.push(centre(line.chars[marked[0]].rect));
|
|
319
|
+
if (marked.length > 1) points.push(centre(line.chars[marked[marked.length - 1]].rect));
|
|
320
|
+
}
|
|
321
|
+
if (!points.length) points.push(...clips.slice(0, 12).map(centre));
|
|
322
|
+
const hits = (await Promise.all(points.map(([x, y]) => exec('synctex', ['edit', '-o', `${page + 1}:${(x - x0).toFixed(1)}:${(y - y0).toFixed(1)}:${pdf}`]).then(r => parseSynctex(r.stdout), () => null)))).filter(hit => hit !== null);
|
|
323
|
+
const files = new Map<string, { input: string; lines: number[] }>();
|
|
324
|
+
for (const hit of hits) {
|
|
325
|
+
const relative = path.relative(build.root || path.dirname(hit.input), hit.input);
|
|
326
|
+
const file = path.posix.join(build.sourceDir || '', (relative.startsWith('..') ? path.basename(hit.input) : relative).split(path.sep).join('/'));
|
|
327
|
+
files.set(file, { input: hit.input, lines: [...(files.get(file)?.lines || []), hit.line] });
|
|
328
|
+
}
|
|
329
|
+
const [file, found] = [...files].sort((a, b) => b[1].lines.length - a[1].lines.length)[0] || [];
|
|
330
|
+
if (!file || !found) return { quote, source: null };
|
|
331
|
+
const source = fs.existsSync(found.input) ? fs.readFileSync(found.input, 'utf8').split('\n') : [];
|
|
332
|
+
const lines = settleLines(source, [Math.min(...found.lines), Math.max(...found.lines)], first, last);
|
|
333
|
+
return { quote, source: { file, lines, excerpt: sourceExcerpt(found.input, lines) } };
|
|
334
|
+
} finally { document.close(); }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
5
|
+
export const DATA = path.resolve(process.env.PAPILOT_DATA || path.join(ROOT, '.papilot'));
|
|
6
|
+
export const ENGINE = path.resolve(process.env.PAPILOT_REVIEW_ENGINE || path.join(ROOT, 'vendor/latex-review'));
|
|
7
|
+
export const PORT = Number(process.env.PAPILOT_PORT || 4317);
|
|
8
|
+
export const PUBLIC_ORIGIN = process.env.PAPILOT_PUBLIC_ORIGIN || '';
|
|
9
|
+
export const OWNER = process.env.PAPILOT_TAILSCALE_LOGIN || '';
|
|
10
|
+
export const CODEX = process.env.PAPILOT_CODEX_BIN || (fs.existsSync(path.join(ROOT, 'node_modules/.bin/codex')) ? path.join(ROOT, 'node_modules/.bin/codex') : 'codex');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { ENGINE } from './paths.ts';
|
|
4
|
+
import { reviewClient, taskRoute } from './review.ts';
|
|
5
|
+
|
|
6
|
+
const pending = new Map<string, Promise<string>>();
|
|
7
|
+
|
|
8
|
+
export async function fullPaperPdf(root: string, name: string, round: number, side: 'before' | 'after'): Promise<string> {
|
|
9
|
+
const key = JSON.stringify([root, name, round, side]);
|
|
10
|
+
const existing = pending.get(key);
|
|
11
|
+
if (existing) return existing;
|
|
12
|
+
const build = (async () => {
|
|
13
|
+
const client = await reviewClient(root);
|
|
14
|
+
const detail = await client.get<{ before: string; after: string }>(`${taskRoute(name)}/rounds/${round}`);
|
|
15
|
+
const [{ Builder }, { Repo }, { loadProject }] = await Promise.all([
|
|
16
|
+
import(pathToFileURL(path.join(ENGINE, 'src/core/build.ts')).href),
|
|
17
|
+
import(pathToFileURL(path.join(ENGINE, 'src/core/git.ts')).href),
|
|
18
|
+
import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href),
|
|
19
|
+
]);
|
|
20
|
+
// A diff can use a partial build; explicitly compile the entire immutable
|
|
21
|
+
// round revision here. The engine reuses its normal full-build cache.
|
|
22
|
+
const result = await new Builder(loadProject(root), new Repo(root)).build(detail[side], ['full']);
|
|
23
|
+
return result.pdf as string;
|
|
24
|
+
})();
|
|
25
|
+
pending.set(key, build);
|
|
26
|
+
try { return await build; } finally { if (pending.get(key) === build) pending.delete(key); }
|
|
27
|
+
}
|