papilot 0.7.0 → 0.8.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/README.md +52 -30
- package/README.zh-CN.md +54 -30
- package/bin/doctor.mjs +53 -6
- package/bin/installation.d.mts +2 -0
- package/bin/installation.mjs +7 -1
- package/dist/assets/{_virtual_papilot-licenses-BWIsfrQh.js → _virtual_papilot-licenses-D7ro6ecN.js} +54 -3
- package/dist/assets/index-Cc-awdNn.css +1 -0
- package/dist/assets/index-D_VX51no.js +265 -0
- package/dist/assets/latex-BarqJFtQ.svg +1 -0
- package/dist/assets/{math-BsEl3u6P.js → math-V9uUaUzA.js} +1 -1
- package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
- package/dist/index.html +2 -2
- package/examples/papilot-paper/main.tex +1 -1
- package/examples/papilot-paper/sections/02-using.tex +3 -1
- package/examples/papilot-paper/sections/09-agents.tex +8 -5
- package/package.json +10 -6
- package/skills/ai-review.md +18 -0
- package/skills/latex-warnings.md +1 -1
- package/src/conversation.ts +6 -0
- package/src/server/acp.ts +354 -0
- package/src/server/agents.ts +220 -43
- package/src/server/ai-review-routes.ts +72 -0
- package/src/server/ai-review.ts +38 -0
- package/src/server/app.ts +53 -11
- package/src/server/build-problems.ts +329 -0
- package/src/server/changes.ts +25 -2
- package/src/server/codex.ts +2 -1
- package/src/server/cursor.ts +153 -0
- package/src/server/latex-installation.ts +43 -0
- package/src/server/manuscript.ts +165 -23
- package/src/server/paths.ts +1 -1
- package/src/server/references.ts +70 -0
- package/src/server/review.ts +3 -3
- package/src/server/skills.ts +5 -8
- package/src/server/store.ts +20 -2
- package/src/server/update-install.ts +53 -2
- package/src/server/update-worker.ts +6 -6
- package/src/server/updates.ts +43 -14
- package/src/server/workspace.ts +183 -29
- package/src/shared.ts +78 -7
- package/src/skill-icons.ts +18 -0
- package/src/skill-metadata.ts +45 -0
- package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
- package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
- package/vendor/latex-review/dist/web/index.html +2 -2
- package/vendor/latex-review/src/core/bib.ts +692 -0
- package/vendor/latex-review/src/core/changes.ts +39 -0
- package/vendor/latex-review/src/core/review.ts +1 -0
- package/vendor/latex-review/src/core/verify.ts +784 -0
- package/vendor/latex-review/src/core/view.ts +204 -0
- package/vendor/latex-review/src/render/round.ts +11 -0
- package/vendor/latex-review/src/server.ts +65 -0
- package/vendor/latex-review/src/types.ts +125 -0
- package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
- package/vendor/latex-review/src/web/references.tsx +499 -0
- package/vendor/latex-review/src/web/review.tsx +259 -131
- package/vendor/latex-review/src/web/style.css +285 -0
- package/vendor/latex-review/src/web/zh-CN.ts +75 -0
- package/dist/assets/index-CIJEYlP7.js +0 -252
- package/dist/assets/index-hOJEruFc.css +0 -1
- package/dist/assets/pdf-view-Cz9_vK5U.js +0 -598
- package/vendor/latex-review/dist/web/assets/index-Be9CtgcZ.css +0 -1
- package/vendor/latex-review/dist/web/assets/index-TvMWVSaw.js +0 -19
package/src/server/app.ts
CHANGED
|
@@ -11,13 +11,14 @@ import type { Workspace } from './workspace.ts';
|
|
|
11
11
|
import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState } from './review.ts';
|
|
12
12
|
import { fullPaperPdf } from './pdf.ts';
|
|
13
13
|
import { type ChangeAction, Changes } from './changes.ts';
|
|
14
|
+
import { bibliography, confirmEntry } from './references.ts';
|
|
14
15
|
import { type Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitFinishMerge, gitIgnore, gitRemote, gitStage, gitStatus } from './manuscript.ts';
|
|
15
16
|
import { gitOverview } from './git-overview.ts';
|
|
16
17
|
import { findSkill, listedSkill, loadSkills } from './skills.ts';
|
|
17
18
|
import { registerSkillRoutes } from './skill-routes.ts';
|
|
18
19
|
import { type Accounts, deviceLabel } from './accounts.ts';
|
|
19
20
|
import { type Listener, Limiter, SESSION_COOKIE, author, clientAddress, cookie, equals, guard, viewerInfo, visibleSnapshot } from './access.ts';
|
|
20
|
-
import type
|
|
21
|
+
import { isTaskReviewNote, isAgentId, type AgentId, type NoteSummary } from '../shared.ts';
|
|
21
22
|
import { parseGroupInstructions } from '../note-groups.ts';
|
|
22
23
|
import { taskNoteResults } from './note-results.ts';
|
|
23
24
|
import type { Updates } from './updates.ts';
|
|
@@ -26,6 +27,8 @@ import { attachmentIds } from './attachments.ts';
|
|
|
26
27
|
import { MAX_ATTACHMENT_BYTES } from '../shared.ts';
|
|
27
28
|
import { PaperImports } from './imports.ts';
|
|
28
29
|
import { DATA } from './paths.ts';
|
|
30
|
+
import { registerAiReviewRoutes } from './ai-review-routes.ts';
|
|
31
|
+
import { detectLatex } from './latex-installation.ts';
|
|
29
32
|
|
|
30
33
|
const importsByWorkspace = new WeakMap<Workspace, PaperImports>();
|
|
31
34
|
|
|
@@ -46,6 +49,7 @@ export interface AppOptions {
|
|
|
46
49
|
connectReview?: typeof reviewClient;
|
|
47
50
|
updates?: Updates; updateInstance?: string;
|
|
48
51
|
diagnostics?: Diagnostics;
|
|
52
|
+
latexInstallation?: typeof detectLatex;
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
// One Fastify instance per listener: the local one for this host and Tailscale,
|
|
@@ -82,6 +86,7 @@ export async function buildApp(options: AppOptions) {
|
|
|
82
86
|
app.setErrorHandler((error, _request, reply) => { reply.code((error as any).statusCode || 400).send({ error: (error as Error).message }); });
|
|
83
87
|
const any = { config: { access: 'any' as const } }, owner = { config: { access: 'owner' as const } };
|
|
84
88
|
registerSkillRoutes(app, store);
|
|
89
|
+
registerAiReviewRoutes(app, store, manuscripts);
|
|
85
90
|
const project = (param = 'id') => ({ config: { access: { project: param } } }), task = { config: { access: { task: 'id' } } };
|
|
86
91
|
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body));
|
|
87
92
|
app.post<{ Params: { id: string } }>('/api/projects/:id/attachments', { ...project(), bodyLimit: MAX_ATTACHMENT_BYTES }, async request => {
|
|
@@ -104,7 +109,7 @@ export async function buildApp(options: AppOptions) {
|
|
|
104
109
|
});
|
|
105
110
|
async function manuscript(id: string, rebuild = false) {
|
|
106
111
|
const root = store.project(id).path, state = await manuscripts.state(id, root, rebuild);
|
|
107
|
-
const notes = store.annotations(id).filter(note => !note.taskId);
|
|
112
|
+
const notes = store.annotations(id).filter(note => !note.taskId && !isTaskReviewNote(note));
|
|
108
113
|
const placed = await manuscripts.reanchor(id, root, notes).catch(() => notes);
|
|
109
114
|
for (let index = 0; index < notes.length; index++) {
|
|
110
115
|
const next = placed[index], previous = notes[index];
|
|
@@ -115,11 +120,12 @@ export async function buildApp(options: AppOptions) {
|
|
|
115
120
|
if (current.deletedAt !== undefined || current.taskId || workspace.annotationSending(current.id) || current.key !== previous.key) continue;
|
|
116
121
|
store.saveAnnotation({ ...current, key: next.key, page: next.page, rect: next.rect, rects: next.rects, source: next.source });
|
|
117
122
|
}
|
|
118
|
-
return { ...state, annotations: store.annotations(id).filter(note => !note.taskId) };
|
|
123
|
+
return { ...state, annotations: store.annotations(id).filter(note => !note.taskId && !isTaskReviewNote(note)) };
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
app.get('/api/health', { config: { access: 'public' } }, () => listener.kind === 'local' ? { ok: true, name: 'Papilot', version: options.version, ...(options.updateInstance ? { updateInstance: options.updateInstance } : {}) } : { ok: true });
|
|
122
127
|
// Environment reports include host paths, so they are available only to the owner.
|
|
128
|
+
app.get('/api/runtime/latex', owner, () => (options.latexInstallation || detectLatex)());
|
|
123
129
|
app.post('/api/diagnostics', owner, request => {
|
|
124
130
|
const projectId = (request.body as { projectId?: unknown } | null)?.projectId;
|
|
125
131
|
if (projectId !== undefined && (typeof projectId !== 'string' || !projectId || projectId.length > 64)) throw new Error('Select a valid paper to check.');
|
|
@@ -129,6 +135,7 @@ export async function buildApp(options: AppOptions) {
|
|
|
129
135
|
if (options.updates) {
|
|
130
136
|
app.get('/api/updates', owner, () => options.updates!.snapshot());
|
|
131
137
|
app.post('/api/updates/check', owner, () => options.updates!.check());
|
|
138
|
+
app.post('/api/updates/channel', owner, request => options.updates!.setChannel((request.body as any)?.channel));
|
|
132
139
|
app.post('/api/updates/install', owner, request => options.updates!.install((request.body as any)?.version));
|
|
133
140
|
}
|
|
134
141
|
app.get('/api/state', any, request => {
|
|
@@ -162,7 +169,7 @@ export async function buildApp(options: AppOptions) {
|
|
|
162
169
|
});
|
|
163
170
|
app.post('/api/reconnect', any, async request => { await workspace.connect(); return visibleSnapshot(workspace.snapshot(), request.viewer!, accounts).runtime; });
|
|
164
171
|
// Codex names reasoning efforts with short lowercase words (low, medium, xhigh).
|
|
165
|
-
const agent = (value: unknown): AgentId => value
|
|
172
|
+
const agent = (value: unknown): AgentId => isAgentId(value) ? value : 'codex';
|
|
166
173
|
const effort = (value: unknown) => typeof value === 'string' && /^[a-z]{1,16}$/.test(value) ? value : undefined;
|
|
167
174
|
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(); };
|
|
168
175
|
// Adding a paper names a folder on this host, so only the owner can.
|
|
@@ -193,7 +200,17 @@ export async function buildApp(options: AppOptions) {
|
|
|
193
200
|
const b = request.body as any, skill = skillName(b.skill), attachments = attachmentIds(b.attachments);
|
|
194
201
|
if (b.annotations !== undefined && (!Array.isArray(b.annotations) || b.annotations.length > 500 || b.annotations.some((id: unknown) => typeof id !== 'string' || !id || id.length > 64))) throw new Error('Select valid PDF notes for this task.');
|
|
195
202
|
const annotations: string[] | undefined = b.annotations, groupInstructions = parseGroupInstructions(b.groupInstructions);
|
|
196
|
-
|
|
203
|
+
const id = string(b.id, 'Task ID', 64), projectId = string(b.projectId, 'Paper ID', 64);
|
|
204
|
+
// A skill submission uses the same task endpoint and retry identity as editing.
|
|
205
|
+
const existing = store.tasks().find(task => task.id === id);
|
|
206
|
+
if (existing) { if (existing.projectId !== projectId) throw new Error('Invalid task ID.'); return existing; }
|
|
207
|
+
let reviewInput;
|
|
208
|
+
if (skill && findSkill(store.project(projectId).path, skill).output === 'annotations') {
|
|
209
|
+
const state = await manuscripts.state(projectId, store.project(projectId).path);
|
|
210
|
+
if (!state.build.current || !state.build.key) throw new Error('Wait for the current PDF to finish compiling, then run the AI review skill.');
|
|
211
|
+
reviewInput = await manuscripts.reviewSnapshot(projectId, state.build.key);
|
|
212
|
+
}
|
|
213
|
+
return workspace.createTask({ id, projectId, reviewInput, ...(reviewInput ? { prepareReview: (worktree: string) => manuscripts.captureReview(store.project(projectId).path, worktree, id) } : {}), title: string(b.title, 'Task title', 120), instructions: skill || annotations?.length || attachments.length ? optional(b.instructions, 'Instructions') : string(b.instructions, 'Instructions'), skill, annotations, groupInstructions, attachments, agent: agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined, effort: effort(b.effort), serviceTier: b.serviceTier === undefined ? undefined : string(b.serviceTier, 'Service tier', 64), author: author(request.viewer) });
|
|
197
214
|
});
|
|
198
215
|
app.get<{ Params: { id: string } }>('/api/tasks/:id/items', task, request => { store.task(request.params.id); return { items: store.items(request.params.id) }; });
|
|
199
216
|
app.get<{ Params: { id: string } }>('/api/tasks/:id/note-results', task, async request => {
|
|
@@ -247,7 +264,7 @@ export async function buildApp(options: AppOptions) {
|
|
|
247
264
|
});
|
|
248
265
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/rename', task, request => workspace.renameTask(request.params.id, string((request.body as any).title, 'Task title', 120)));
|
|
249
266
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/title', task, request => workspace.requestTaskTitle(request.params.id));
|
|
250
|
-
app.post<{ Params: { id: string } }>('/api/tasks/:id/archive', task, request => workspace.archiveTask(request.params.id, (request.body as any)?.archived));
|
|
267
|
+
app.post<{ Params: { id: string } }>('/api/tasks/:id/archive', task, request => workspace.archiveTask(request.params.id, (request.body as any)?.archived, (request.body as any)?.finished === true));
|
|
251
268
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/interrupt', task, request => workspace.interrupt(request.params.id).then(() => ({ ok: true })));
|
|
252
269
|
// Deleting a task that is already gone succeeds, so a lost response can be retried; a hidden one answers the same.
|
|
253
270
|
app.post<{ Params: { id: string } }>('/api/tasks/:id/delete', { config: { access: { task: 'id' }, hidden: 'ok' } }, request => {
|
|
@@ -262,6 +279,14 @@ export async function buildApp(options: AppOptions) {
|
|
|
262
279
|
// The manuscript preview compiles each paper's working tree and reports its Git status.
|
|
263
280
|
app.get<{ Params: { id: string } }>('/api/projects/:id/manuscript', project(), request => manuscript(request.params.id));
|
|
264
281
|
app.get<{ Params: { id: string } }>('/api/projects/:id/git', project(), request => gitStatus(store.project(request.params.id).path));
|
|
282
|
+
// The paper's bibliography: every entry, its problems and its online check.
|
|
283
|
+
app.get<{ Params: { id: string }; Querystring: { check?: string; retry?: string } }>('/api/projects/:id/references', project(), request =>
|
|
284
|
+
bibliography(store.project(request.params.id).path, request.query.check === '1', request.query.retry === '1'));
|
|
285
|
+
app.post<{ Params: { id: string } }>('/api/projects/:id/references/confirm', project(), request => {
|
|
286
|
+
const b = request.body as any;
|
|
287
|
+
if (typeof b?.id !== 'string' || typeof b?.confirmed !== 'boolean') throw new Error('Choose an entry to confirm.');
|
|
288
|
+
return confirmEntry(store.project(request.params.id).path, b.id, b.confirmed);
|
|
289
|
+
});
|
|
265
290
|
app.get<{ Params: { id: string } }>('/api/projects/:id/git/overview', project(), request => gitOverview(store.project(request.params.id).path, store.tasks().filter(task => task.projectId === request.params.id)));
|
|
266
291
|
app.get<{ Params: { id: string }; Querystring: { path?: string } }>('/api/projects/:id/git/diff', project(), request => gitDiff(store.project(request.params.id).path, string(request.query.path, 'File path', 4096)));
|
|
267
292
|
const paths = (value: unknown): string[] => Array.isArray(value) ? value.filter((p: unknown): p is string => typeof p === 'string').slice(0, 5000) : [];
|
|
@@ -311,7 +336,7 @@ export async function buildApp(options: AppOptions) {
|
|
|
311
336
|
return serialGit(project.id, async () => { await gitRemote(project.path, action); return { git: await gitStatus(project.path) }; });
|
|
312
337
|
});
|
|
313
338
|
app.post<{ Params: { id: string } }>('/api/projects/:id/manuscript', project(), request => manuscript(request.params.id, true));
|
|
314
|
-
// Notes
|
|
339
|
+
// Notes are saved for reading; only explicitly attached IDs reach an agent task.
|
|
315
340
|
app.post<{ Params: { id: string } }>('/api/projects/:id/annotations', project(), async request => {
|
|
316
341
|
const project = store.project(request.params.id), b = request.body as any;
|
|
317
342
|
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];
|
|
@@ -326,6 +351,7 @@ export async function buildApp(options: AppOptions) {
|
|
|
326
351
|
});
|
|
327
352
|
app.post<{ Params: { id: string; note: string } }>('/api/projects/:id/annotations/:note', project(), request => {
|
|
328
353
|
const note = store.annotation(request.params.id, request.params.note);
|
|
354
|
+
if (isTaskReviewNote(note)) throw new Error('Manage this suggestion in its AI review task, or copy it to the manuscript first.');
|
|
329
355
|
if (workspace.annotationSending(note.id)) throw new Error('This note is being sent. Wait for the task to finish creating.');
|
|
330
356
|
if (note.taskId) throw new Error('This note has already been sent.');
|
|
331
357
|
if (note.deletedAt !== undefined) throw new Error('This note has been deleted. Undo the deletion first.');
|
|
@@ -333,28 +359,34 @@ export async function buildApp(options: AppOptions) {
|
|
|
333
359
|
});
|
|
334
360
|
app.post<{ Params: { id: string; note: string } }>('/api/projects/:id/annotations/:note/delete', project(), request => {
|
|
335
361
|
const note = store.annotation(request.params.id, request.params.note);
|
|
362
|
+
if (isTaskReviewNote(note)) throw new Error('Manage this suggestion in its AI review task, or copy it to the manuscript first.');
|
|
336
363
|
if (workspace.annotationSending(note.id)) throw new Error('This note is being sent. Wait for the task to finish creating.');
|
|
337
364
|
store.deleteAnnotation(request.params.id, request.params.note); return { ok: true };
|
|
338
365
|
});
|
|
339
366
|
app.post<{ Params: { id: string; note: string } }>('/api/projects/:id/annotations/:note/restore', project(), request => {
|
|
340
367
|
const note = store.annotation(request.params.id, request.params.note);
|
|
368
|
+
if (isTaskReviewNote(note)) throw new Error('Manage this suggestion in its AI review task, or copy it to the manuscript first.');
|
|
341
369
|
if (workspace.annotationSending(note.id)) throw new Error('This note is being sent. Wait for the task to finish creating.');
|
|
342
370
|
if (note.taskId) throw new Error('This note has already been sent.');
|
|
343
371
|
const { deletedAt: _, ...restored } = note;
|
|
344
372
|
store.saveAnnotation(restored);
|
|
345
373
|
// Return the original order, including any notes added since the deletion.
|
|
346
|
-
return { annotations: store.annotations(request.params.id).filter(note => !note.taskId) };
|
|
374
|
+
return { annotations: store.annotations(request.params.id).filter(note => !note.taskId && !isTaskReviewNote(note)) };
|
|
347
375
|
});
|
|
348
376
|
app.post<{ Params: { id: string } }>('/api/projects/:id/annotations/send', project(), async request => {
|
|
349
377
|
const project = store.project(request.params.id), b = request.body as any, id = string(b.id, 'Task ID', 64);
|
|
350
378
|
// A retried send returns its task; an ID from another paper is refused.
|
|
351
379
|
const existing = store.tasks().find(task => task.id === id);
|
|
352
380
|
if (existing) { if (existing.projectId !== project.id) throw new Error('Invalid task ID.'); return existing; }
|
|
353
|
-
|
|
354
|
-
|
|
381
|
+
if (!Array.isArray(b.annotations) || !b.annotations.length || b.annotations.length > 500 || b.annotations.some((id: unknown) => typeof id !== 'string' || !id || id.length > 64)) throw new Error('Select valid PDF notes for this task.');
|
|
382
|
+
const notes = [...new Set<string>(b.annotations)].map(id => store.annotation(project.id, id));
|
|
383
|
+
if (notes.some(note => note.taskId || isTaskReviewNote(note) || note.deletedAt !== undefined)) throw new Error('Select unsent PDF notes for this task.');
|
|
355
384
|
const { title } = annotationTask(notes);
|
|
356
385
|
return workspace.createTask({ id, projectId: project.id, title, instructions: '', annotations: notes.map(note => note.id), agent: agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined, effort: effort(b.effort), serviceTier: b.serviceTier === undefined ? undefined : string(b.serviceTier, 'Service tier', 64), author: author(request.viewer) });
|
|
357
386
|
});
|
|
387
|
+
// What the last build reported, placed on its PDF, and the TeX log itself.
|
|
388
|
+
app.get<{ Params: { id: string } }>('/api/projects/:id/manuscript/problems', project(), request => manuscripts.problems(store.project(request.params.id).id));
|
|
389
|
+
app.get<{ Params: { id: string } }>('/api/projects/:id/manuscript/log', project(), (request, reply) => reply.type('text/plain; charset=utf-8').header('Cache-Control', 'no-store').send(manuscripts.log(store.project(request.params.id).id)));
|
|
358
390
|
// Exact word places for the PDF view's selection layer; immutable per build key.
|
|
359
391
|
app.get<{ Params: { id: string }; Querystring: { key?: string; page?: string } }>('/api/projects/:id/manuscript/words', project(), async (request, reply) => {
|
|
360
392
|
const project = store.project(request.params.id);
|
|
@@ -423,6 +455,16 @@ export async function buildApp(options: AppOptions) {
|
|
|
423
455
|
app.get<{ Params: { projectId: string; number: string } }>('/review/:projectId/api/changes/rounds/:number', changesAccess, request => changes.detail(store.project(request.params.projectId).id, Number(request.params.number)));
|
|
424
456
|
app.get<{ Params: { projectId: string; number: string; unit: string } }>('/review/:projectId/api/changes/rounds/:number/effects/:unit', changesAccess, request =>
|
|
425
457
|
changes.effects(store.project(request.params.projectId).id, Number(request.params.number), request.params.unit));
|
|
458
|
+
app.get<{ Params: { projectId: string; number: string }; Querystring: { check?: string; retry?: string } }>('/review/:projectId/api/changes/rounds/:number/references', changesAccess, request => {
|
|
459
|
+
const project = store.project(request.params.projectId);
|
|
460
|
+
return changes.references(project.id, project.path, Number(request.params.number), request.query.check === '1', request.query.retry === '1');
|
|
461
|
+
});
|
|
462
|
+
app.post<{ Params: { projectId: string; number: string } }>('/review/:projectId/api/changes/rounds/:number/references/confirm', changesAccess, request => {
|
|
463
|
+
changesToken(request);
|
|
464
|
+
const project = store.project(request.params.projectId), b = request.body as any;
|
|
465
|
+
if (typeof b?.id !== 'string' || typeof b?.confirmed !== 'boolean') throw new Error('Choose an entry to confirm.');
|
|
466
|
+
return changes.confirmReference(project.id, project.path, Number(request.params.number), b.id, b.confirmed);
|
|
467
|
+
});
|
|
426
468
|
app.get<{ Params: { projectId: string; number: string; file: string } }>('/review/:projectId/changes/files/:number/:file', changesAccess, (request, reply) => {
|
|
427
469
|
const file = changes.file(store.project(request.params.projectId).id, Number(request.params.number), request.params.file);
|
|
428
470
|
return reply.header('Cache-Control', 'private, max-age=31536000, immutable').type(file.endsWith('.svg') ? 'image/svg+xml' : 'image/png').send(fs.createReadStream(file));
|
|
@@ -471,7 +513,7 @@ export async function buildApp(options: AppOptions) {
|
|
|
471
513
|
const task = store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]);
|
|
472
514
|
if (task) store.updateTask(task.id, { error: 'Review action failed: ' + (value as any).error });
|
|
473
515
|
}
|
|
474
|
-
if (response.ok && request.method === 'POST') {
|
|
516
|
+
if (response.ok && request.method === 'POST' && !route.endsWith('/references/confirm')) {
|
|
475
517
|
const task = store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]);
|
|
476
518
|
if (task) {
|
|
477
519
|
store.updateTask(task.id, { error: null });
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { countProblems, type BuildProblem } from '../shared.ts';
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
// A problem as read from the log: `input` is the file as TeX named it, relative
|
|
11
|
+
// to the directory TeX ran in. It never leaves the server.
|
|
12
|
+
export interface LogProblem extends Omit<BuildProblem, 'file' | 'page' | 'rect'> { input?: string; occurrence?: number }
|
|
13
|
+
export interface StoredProblem extends BuildProblem { source?: string; occurrence?: number }
|
|
14
|
+
export { countProblems };
|
|
15
|
+
|
|
16
|
+
// Overfull lines narrower than this are invisible in print.
|
|
17
|
+
export const OVERFULL_MIN_PT = 1;
|
|
18
|
+
// Locating runs SyncTeX once or twice per problem; a paper with more problems
|
|
19
|
+
// lists the rest without a page, starting with the least important.
|
|
20
|
+
export const LOCATE_LIMIT = 300;
|
|
21
|
+
const WRAP = 79;
|
|
22
|
+
const utf8Length = (lead: number) => lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1;
|
|
23
|
+
|
|
24
|
+
// TeX breaks every log line at 79 columns, even inside file names. pdfTeX
|
|
25
|
+
// counts bytes and may cut a character in two, LuaTeX breaks early rather than
|
|
26
|
+
// cut one, and XeTeX counts characters. Rejoin the pieces on the raw bytes,
|
|
27
|
+
// before decoding splits a character for good.
|
|
28
|
+
export function unwrapLog(input: string | Buffer) {
|
|
29
|
+
const bytes = typeof input === 'string' ? Buffer.from(input, 'utf8') : input, raw: Buffer[] = [];
|
|
30
|
+
for (let start = 0, i = 0; i <= bytes.length; i++) {
|
|
31
|
+
if (i < bytes.length && bytes[i] !== 0x0a) continue;
|
|
32
|
+
raw.push(bytes.subarray(start, i > start && bytes[i - 1] === 0x0d ? i - 1 : i)); start = i + 1;
|
|
33
|
+
}
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
let parts: Buffer[] = [];
|
|
36
|
+
for (let k = 0; k < raw.length; k++) {
|
|
37
|
+
const line = raw[k], next = raw[k + 1], text = line.toString('utf8');
|
|
38
|
+
parts.push(line);
|
|
39
|
+
const wrapped = next !== undefined && (line.length === WRAP || [...text].length === WRAP
|
|
40
|
+
|| (line.length < WRAP && next.length > 0 && next[0] >= 0xc0 && line.length + utf8Length(next[0]) > WRAP)
|
|
41
|
+
// Text decoded earlier keeps each half of a cut character as U+FFFD.
|
|
42
|
+
|| (text.endsWith('�') && next.toString('utf8').startsWith('�')));
|
|
43
|
+
if (!wrapped) { out.push(parts.length === 1 ? text : Buffer.concat(parts).toString('utf8')); parts = []; }
|
|
44
|
+
}
|
|
45
|
+
if (parts.length) out.push(Buffer.concat(parts).toString('utf8'));
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const looksLikeFile = (name: string) => /^(?:\.{1,2}\/|\/|[A-Za-z]:[\\/])/.test(name) || /\.[A-Za-z][\w-]*$/.test(name);
|
|
50
|
+
const IGNORED = [
|
|
51
|
+
/^There (?:were|was) (?:undefined|multiply)/, /^Rerun to get/, /Label\(s\) may have changed/, /^Please (?:\(re\))?run/i,
|
|
52
|
+
/^Writing or overwriting file/, /^File [`'][^']+' already exists on the system/, /^Unused global option/,
|
|
53
|
+
];
|
|
54
|
+
// The source context TeX prints after an error or a pdfTeX warning: a line
|
|
55
|
+
// starting "<to be read again> " or "l.12 " quotes the source up to where TeX
|
|
56
|
+
// stopped, cut on the left, so its parentheses may be unmatched. The indented
|
|
57
|
+
// line below it continues the source and then TeX's own output, which can
|
|
58
|
+
// close and open files, so that one is read as usual.
|
|
59
|
+
const CONTEXT = /^(?:<[a-z*][a-z ]*>|l\.\d+) /;
|
|
60
|
+
|
|
61
|
+
// TeX reports which file it is reading with "(name" and ")" around everything
|
|
62
|
+
// that file prints. Only a "(" followed by a file that exists opens a file, so
|
|
63
|
+
// parentheses in messages and wrapped text keep the stack balanced but never
|
|
64
|
+
// change the current file. `opened` collects every file TeX read.
|
|
65
|
+
export function parseLatexLog(text: string | Buffer, isFile: (name: string) => boolean = () => true, opened?: Set<string>): LogProblem[] {
|
|
66
|
+
const lines = unwrapLog(text), stack: (string | null)[] = [], found: LogProblem[] = [], seen = new Set<string>(), occurrences = new Map<string, number>();
|
|
67
|
+
const current = () => { for (let i = stack.length - 1; i >= 0; i--) if (stack[i]) return stack[i]!; return undefined; };
|
|
68
|
+
const add = (problem: LogProblem) => {
|
|
69
|
+
// Keep the position among all marks on the line, before repeated keys
|
|
70
|
+
// collapse into one row. In a,a,b, b is still the third printed mark.
|
|
71
|
+
if (problem.kind === 'reference' || problem.kind === 'citation') {
|
|
72
|
+
const line = JSON.stringify([problem.input, problem.line, problem.kind]);
|
|
73
|
+
problem.occurrence = occurrences.get(line) || 0;
|
|
74
|
+
occurrences.set(line, problem.occurrence + 1);
|
|
75
|
+
}
|
|
76
|
+
const id = JSON.stringify([problem.kind, problem.key, problem.input, problem.line, problem.end, problem.message]);
|
|
77
|
+
if (!seen.has(id)) { seen.add(id); found.push(problem); }
|
|
78
|
+
};
|
|
79
|
+
// pdfTeX prints a name with spaces unquoted, so the longest run of the line
|
|
80
|
+
// that names an existing file wins.
|
|
81
|
+
const fileAt = (rest: string): [string, number] | null => {
|
|
82
|
+
const quoted = /^"([^"]+)"/.exec(rest);
|
|
83
|
+
if (quoted) return isFile(quoted[1]) ? [quoted[1], quoted[0].length] : null;
|
|
84
|
+
const run = /^[^()[\]{}<>"]*/.exec(rest.slice(0, 512))![0];
|
|
85
|
+
if (!run || /^\s/.test(run)) return null;
|
|
86
|
+
const cuts = [run.length];
|
|
87
|
+
for (let at = run.lastIndexOf(' '); at > 0; at = run.lastIndexOf(' ', at - 1)) cuts.push(at);
|
|
88
|
+
for (const cut of cuts) {
|
|
89
|
+
const name = run.slice(0, cut).trimEnd();
|
|
90
|
+
if (name && looksLikeFile(name) && isFile(name)) return [name, name.length];
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
const scan = (line: string) => {
|
|
95
|
+
for (let j = 0; j < line.length; j++) {
|
|
96
|
+
if (line[j] === ')') { stack.pop(); continue; }
|
|
97
|
+
if (line[j] !== '(') continue;
|
|
98
|
+
const file = fileAt(line.slice(j + 1));
|
|
99
|
+
if (file) { stack.push(file[0]); opened?.add(file[0]); j += file[1]; } else stack.push(null);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
for (let i = 0; i < lines.length; i++) {
|
|
103
|
+
const line = lines[i];
|
|
104
|
+
// file:line: message (from -file-line-error) or "! message". The file may
|
|
105
|
+
// be one TeX generated, such as the .bbl, which a failed build has removed.
|
|
106
|
+
const located = /^(.+?):(\d+): (.*)$/.exec(line);
|
|
107
|
+
const errorFile = located && (located[1] === current() || looksLikeFile(located[1])) ? located : null;
|
|
108
|
+
if (errorFile || line.startsWith('! ')) {
|
|
109
|
+
let message = errorFile ? errorFile[3] : line.slice(2), last = line, end = i + 1;
|
|
110
|
+
for (; end < lines.length && end < i + 20; end++) {
|
|
111
|
+
const next = lines[end];
|
|
112
|
+
if (!next.trim() || next.startsWith('! ') || /^(?:See |Type |l\.\d+ |\s*\.\.\.|\s*<)/.test(next) || /^(.+?):(\d+): /.test(next)) break;
|
|
113
|
+
message += (last.length >= WRAP ? '' : ' ') + next; last = next;
|
|
114
|
+
}
|
|
115
|
+
let context = -1;
|
|
116
|
+
for (let k = end; k < Math.min(lines.length, end + 12); k++) if (/^l\.\d+ /.test(lines[k])) { context = k; break; }
|
|
117
|
+
message = message.replace(/\s+/g, ' ').trim();
|
|
118
|
+
if (!/==> Fatal error occurred|^Emergency stop/.test(message)) {
|
|
119
|
+
const missing = /File [`']([^']+)' not found/.exec(message);
|
|
120
|
+
add({
|
|
121
|
+
kind: missing ? 'missing-file' : 'error', severity: 'error', message, key: missing?.[1],
|
|
122
|
+
input: errorFile ? errorFile[1] : current(), line: errorFile ? Number(errorFile[2]) : context >= 0 ? Number(/^l\.(\d+)/.exec(lines[context])![1]) : undefined,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// Help text and the quoted source are TeX's commentary, not file events.
|
|
126
|
+
i = context >= 0 ? context : end - 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (CONTEXT.test(line)) continue;
|
|
130
|
+
const warning = /^(LaTeX|Package|Class|Module)(?: (\S+))? Warning: (.*)$|^(pdfTeX) warning(?: \([^)]*\))?: (.*)$/i.exec(line);
|
|
131
|
+
if (warning) {
|
|
132
|
+
const owner = warning[2] || '', isLatex = warning[1] === 'LaTeX';
|
|
133
|
+
let message = warning[3] ?? warning[5], end = i + 1;
|
|
134
|
+
// Continuation lines start with "(package)" or, for LaTeX, an indent.
|
|
135
|
+
for (; end < lines.length; end++) {
|
|
136
|
+
const next = lines[end], prefix = owner ? `(${owner})` : '';
|
|
137
|
+
if (prefix && next.startsWith(prefix)) message += ' ' + next.slice(prefix.length).trim();
|
|
138
|
+
else if (isLatex && !owner && /^\s{4,}\S/.test(next)) message += ' ' + next.trim();
|
|
139
|
+
else break;
|
|
140
|
+
}
|
|
141
|
+
i = end - 1;
|
|
142
|
+
message = message.replace(/\s+/g, ' ').trim();
|
|
143
|
+
if (owner === 'Font' || IGNORED.some(pattern => pattern.test(message))) continue;
|
|
144
|
+
const inputLine = /on input line (\d+)/.exec(message), at = inputLine ? Number(inputLine[1]) : undefined;
|
|
145
|
+
const reference = /^Reference [`']([^']+)' on page \S+ undefined/.exec(message);
|
|
146
|
+
const citation = /Citation [`']([^']+)'(?: on page \S+)? undefined/.exec(message);
|
|
147
|
+
const label = /^Label [`']([^']+)' multiply defined/.exec(message);
|
|
148
|
+
if (reference) add({ kind: 'reference', severity: 'warning', message, key: reference[1], input: current(), line: at });
|
|
149
|
+
else if (citation) add({ kind: 'citation', severity: 'warning', message, key: citation[1], input: current(), line: at });
|
|
150
|
+
// cleveref repeats every label as `name@cref`; the user's label is reported already.
|
|
151
|
+
else if (label) { if (!label[1].includes('@')) add({ kind: 'label', severity: 'warning', message, key: label[1] }); }
|
|
152
|
+
else add({ kind: 'other', severity: 'minor', message: `${owner && !isLatex ? owner + ': ' : ''}${message}`, input: current(), line: at });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const box = /^(Overfull|Underfull) \\([hv])box \((?:badness \d+|([\d.]+)pt too (?:wide|high))\)(?: (?:in paragraph|in alignment|detected) at lines? (\d+)(?:--(\d+))?)?/.exec(line);
|
|
156
|
+
if (box) {
|
|
157
|
+
const amount = Number(box[3]);
|
|
158
|
+
if (box[1] === 'Overfull' && amount >= OVERFULL_MIN_PT && box[4]) {
|
|
159
|
+
const start = Number(box[4]), stop = box[5] ? Number(box[5]) : start;
|
|
160
|
+
add({ kind: 'overfull', severity: 'minor', message: line.trim(), amount: Math.round(amount * 10) / 10, input: current(), line: start, end: stop > start ? stop : undefined });
|
|
161
|
+
}
|
|
162
|
+
// The box's contents follow until a blank line; their text has stray parentheses.
|
|
163
|
+
while (i + 1 < lines.length && lines[i + 1].trim()) i++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const glyph = /^Missing character: There is no (.+?) in font (.+?)!/.exec(line);
|
|
167
|
+
if (glyph) { add({ kind: 'glyph', severity: 'warning', message: line.trim(), key: glyph[1], input: current() }); continue; }
|
|
168
|
+
scan(line);
|
|
169
|
+
}
|
|
170
|
+
// A remark repeated for many objects, such as pdfTeX's duplicate link
|
|
171
|
+
// targets for every equation, is listed once with how often it occurred.
|
|
172
|
+
const remarks = new Map<string, LogProblem>();
|
|
173
|
+
return found.filter(problem => {
|
|
174
|
+
if (problem.kind !== 'other') return true;
|
|
175
|
+
const shape = problem.message.replace(/\{[^{}]*\}/g, '{}').replace(/[`'][^`']*'/g, "''").replace(/\d+/g, '#');
|
|
176
|
+
const first = remarks.get(shape);
|
|
177
|
+
if (!first) { remarks.set(shape, problem); return true; }
|
|
178
|
+
first.count = (first.count || 1) + 1;
|
|
179
|
+
return false;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The log without the build folder's path or the home directory.
|
|
184
|
+
export function scrubLog(text: string, folder?: string, home = os.homedir()) {
|
|
185
|
+
if (folder && folder.length > 1) text = text.split(folder).join('…');
|
|
186
|
+
if (home && home.length > 1) text = text.split(home + path.sep).join('~' + path.sep);
|
|
187
|
+
return text;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const stripComments = (line: string) => line.replace(/(^|[^\\])%.*$/, '$1');
|
|
191
|
+
// Files TeX writes for itself: the build folder and auxiliary files. A problem
|
|
192
|
+
// reported in one has no place in the manuscript to show.
|
|
193
|
+
const GENERATED = new Set(['.aux', '.bbl', '.toc', '.lof', '.lot', '.loa', '.out', '.ind', '.idx', '.gls', '.glo', '.nls', '.brf', '.nav', '.snm', '.vrb', '.bcf']);
|
|
194
|
+
|
|
195
|
+
// Where every problem is in the manuscript. TeX names files relative to the
|
|
196
|
+
// directory it ran in; the paper shows them relative to its repository, like
|
|
197
|
+
// note sources. A label defined twice is listed at each definition among the
|
|
198
|
+
// files TeX read.
|
|
199
|
+
export function collectProblems(log: string | Buffer, { cwd, root, sourceDir, outDir = path.join(cwd, 'build') }: { cwd: string; root: string; sourceDir: string; outDir?: string }): StoredProblem[] {
|
|
200
|
+
const resolve = (name: string) => path.resolve(cwd, name), known = new Map<string, boolean>(), opened = new Set<string>();
|
|
201
|
+
const isFile = (name: string) => {
|
|
202
|
+
let result = known.get(name);
|
|
203
|
+
if (result === undefined) { try { result = fs.statSync(resolve(name)).isFile(); } catch { result = false; } known.set(name, result); }
|
|
204
|
+
return result;
|
|
205
|
+
};
|
|
206
|
+
const parsed = parseLatexLog(log, isFile, opened);
|
|
207
|
+
const display = (absolute: string) => {
|
|
208
|
+
const relative = path.relative(root, absolute);
|
|
209
|
+
return relative.startsWith('..') || path.isAbsolute(relative) ? undefined : path.posix.join(sourceDir || '', relative.split(path.sep).join('/'));
|
|
210
|
+
};
|
|
211
|
+
const generated = (absolute: string) => {
|
|
212
|
+
const inside = path.relative(outDir, absolute);
|
|
213
|
+
return (!inside.startsWith('..') && !path.isAbsolute(inside)) || GENERATED.has(path.extname(absolute)) || absolute.endsWith('.run.xml');
|
|
214
|
+
};
|
|
215
|
+
let sources: { file: string; lines: string[] }[] | null = null;
|
|
216
|
+
const output: StoredProblem[] = [];
|
|
217
|
+
for (const { input, ...problem } of parsed) {
|
|
218
|
+
if (problem.kind === 'label' && problem.key) {
|
|
219
|
+
sources ??= [...opened].map(resolve).filter(file => file.endsWith('.tex') && display(file) !== undefined && !generated(file)).flatMap(file => {
|
|
220
|
+
try { return [{ file, lines: fs.readFileSync(file, 'utf8').split('\n') }]; } catch { return []; }
|
|
221
|
+
});
|
|
222
|
+
const needle = `\\label{${problem.key}}`, places: StoredProblem[] = [];
|
|
223
|
+
for (const { file, lines } of sources) lines.forEach((text, index) => {
|
|
224
|
+
if (stripComments(text).includes(needle)) places.push({ ...problem, file: display(file), line: index + 1, source: file });
|
|
225
|
+
});
|
|
226
|
+
output.push(...(places.length ? places : [problem]));
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const absolute = input ? resolve(input) : undefined;
|
|
230
|
+
if (absolute && generated(absolute)) {
|
|
231
|
+
// An error in, say, the .bbl names that file so its cause can be traced.
|
|
232
|
+
const named = problem.severity === 'error' ? { message: `${path.basename(absolute)}${problem.line ? ':' + problem.line : ''}: ${problem.message}` } : {};
|
|
233
|
+
output.push({ ...problem, ...named, line: undefined, end: undefined });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const file = absolute ? display(absolute) : undefined;
|
|
237
|
+
output.push(file ? { ...problem, file, source: absolute } : { ...problem, line: undefined, end: undefined });
|
|
238
|
+
}
|
|
239
|
+
return output;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
interface SyncBox { page: number; x: number; y: number; h: number; v: number; W: number; H: number }
|
|
243
|
+
// `synctex view` lists one record per box: the point it names (x, y) and the
|
|
244
|
+
// box itself, h from the left and v at its baseline, W wide and H tall.
|
|
245
|
+
export function parseSynctexView(output: string): SyncBox[] {
|
|
246
|
+
const boxes: Partial<SyncBox>[] = [];
|
|
247
|
+
for (const line of output.split('\n')) {
|
|
248
|
+
const match = /^(Page|x|y|h|v|W|H):(-?[\d.]+)\s*$/.exec(line);
|
|
249
|
+
if (!match) continue;
|
|
250
|
+
if (match[1] === 'Page') boxes.push({ page: Number(match[2]) - 1 });
|
|
251
|
+
else if (boxes.length) (boxes[boxes.length - 1] as Record<string, number>)[match[1]] = Number(match[2]);
|
|
252
|
+
}
|
|
253
|
+
return boxes.filter((box): box is SyncBox => ['page', 'x', 'y', 'h', 'v', 'W', 'H'].every(key => Number.isFinite((box as Record<string, number>)[key])));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface PageText { width: number; height: number; bounds: number[]; search(needle: string): number[][] }
|
|
257
|
+
// Put each problem on the page SyncTeX names. A missing reference or citation
|
|
258
|
+
// is narrowed to its "??" or "?" in that line; everything else marks its lines.
|
|
259
|
+
// Which problems get a place when there are more than `limit`: errors, then
|
|
260
|
+
// warnings, then typesetting, each in the order TeX reported them.
|
|
261
|
+
export function placementOrder(problems: Pick<BuildProblem, 'severity'>[], limit = LOCATE_LIMIT) {
|
|
262
|
+
const rank = { error: 0, warning: 1, minor: 2 };
|
|
263
|
+
return new Set(problems.map((problem, index) => index).sort((a, b) => rank[problems[a].severity] - rank[problems[b].severity] || a - b).slice(0, limit));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function locateProblems(problems: StoredProblem[], pdf: string, openPage: (page: number) => PageText | null, limit = LOCATE_LIMIT): Promise<BuildProblem[]> {
|
|
267
|
+
// SyncTeX reads its whole file on every call, so each source line is asked once.
|
|
268
|
+
const views = new Map<string, Promise<SyncBox[]>>();
|
|
269
|
+
const view = (file: string, line: number) => {
|
|
270
|
+
const id = `${line}:${file}`;
|
|
271
|
+
if (!views.has(id)) views.set(id, exec('synctex', ['view', '-i', `${line}:0:${file}`, '-o', pdf], { timeout: 10000 }).then(result => parseSynctexView(result.stdout), () => []));
|
|
272
|
+
return views.get(id)!;
|
|
273
|
+
};
|
|
274
|
+
// TeX warns about the missing references of one source line in reading
|
|
275
|
+
// order, so the second warning for a line belongs to its second "??".
|
|
276
|
+
const ordinal = new Map<StoredProblem, number>(), counted = new Map<string, number>();
|
|
277
|
+
for (const problem of problems) {
|
|
278
|
+
const id = JSON.stringify([problem.source, problem.line, problem.kind]), seen = counted.get(id) || 0;
|
|
279
|
+
ordinal.set(problem, problem.occurrence ?? seen); counted.set(id, (problem.occurrence ?? seen) + 1);
|
|
280
|
+
}
|
|
281
|
+
const place = async (problem: StoredProblem): Promise<BuildProblem> => {
|
|
282
|
+
const { source, occurrence: _occurrence, ...shown } = problem;
|
|
283
|
+
if (!source || !problem.line) return shown;
|
|
284
|
+
const lines = problem.end && problem.end !== problem.line ? [problem.line, problem.end] : [problem.line];
|
|
285
|
+
const boxes = (await Promise.all(lines.map(line => view(source, line)))).flat();
|
|
286
|
+
if (!boxes.length) return shown;
|
|
287
|
+
const page = boxes[0].page, text = openPage(page);
|
|
288
|
+
if (!text) return shown;
|
|
289
|
+
const [x0, y0] = text.bounds;
|
|
290
|
+
const onPage = boxes.filter(box => box.page === page), tidy = onPage.filter(box => box.H > 0 && box.H < text.height * 0.4);
|
|
291
|
+
let rect: number[] = tidy.length
|
|
292
|
+
? [Math.min(...tidy.map(b => b.h)), Math.min(...tidy.map(b => b.v - b.H)), Math.max(...tidy.map(b => b.h + b.W)), Math.max(...tidy.map(b => b.v))]
|
|
293
|
+
: [onPage[0].x - 6, onPage[0].y - 10, onPage[0].x + 6, onPage[0].y + 2];
|
|
294
|
+
rect = [rect[0] + x0, rect[1] + y0, rect[2] + x0, rect[3] + y0];
|
|
295
|
+
if (problem.kind === 'reference' || problem.kind === 'citation') {
|
|
296
|
+
// A citation prints "?" or its key; the "?" of a missing reference's "??" is not one.
|
|
297
|
+
const pairs = text.search('??'), inside = (mark: number[], outer: number[]) => mark[0] >= outer[0] - 0.5 && mark[2] <= outer[2] + 0.5 && mark[1] >= outer[1] - 0.5 && mark[3] <= outer[3] + 0.5;
|
|
298
|
+
const marks = (problem.kind === 'reference' ? pairs : [...text.search('?').filter(mark => !pairs.some(pair => inside(mark, pair))), ...(problem.key ? text.search(problem.key) : [])])
|
|
299
|
+
.filter(mark => { const middle = (mark[1] + mark[3]) / 2; return onPage.some(box => middle >= box.v - box.H + y0 - 3 && middle <= box.v + y0 + 3); })
|
|
300
|
+
.sort((a, b) => Math.abs(a[1] - b[1]) > 2 ? a[1] - b[1] : a[0] - b[0]);
|
|
301
|
+
// SyncTeX's points sit on the nodes this source line printed, so a mark
|
|
302
|
+
// that starts beside one came from this line, not from its neighbours.
|
|
303
|
+
const gap = (mark: number[]) => Math.min(...onPage.map(box => Math.abs(mark[0] - (box.x + x0))));
|
|
304
|
+
const own = marks.filter(mark => gap(mark) <= 6);
|
|
305
|
+
let best: number[] | undefined = own[ordinal.get(problem) || 0];
|
|
306
|
+
if (!best && marks.length) best = marks.reduce((a, b) => gap(b) < gap(a) ? b : a);
|
|
307
|
+
if (best) rect = [best[0] - 2, best[1] - 1, best[2] + 2, best[3] + 1];
|
|
308
|
+
}
|
|
309
|
+
const unit = (value: number) => Math.min(1, Math.max(0, Math.round(value * 10000) / 10000));
|
|
310
|
+
return { ...shown, page, rect: [unit((rect[0] - x0) / text.width), unit((rect[1] - y0) / text.height), unit((rect[2] - x0) / text.width), unit((rect[3] - y0) / text.height)] };
|
|
311
|
+
};
|
|
312
|
+
const output: BuildProblem[] = new Array(problems.length), chosen = placementOrder(problems, limit);
|
|
313
|
+
let next = 0;
|
|
314
|
+
await Promise.all(Array.from({ length: 6 }, async () => {
|
|
315
|
+
for (let index = next++; index < problems.length; index = next++) {
|
|
316
|
+
if (!chosen.has(index)) { const { source: _source, occurrence: _occurrence, ...shown } = problems[index]; output[index] = shown; continue; }
|
|
317
|
+
output[index] = await place(problems[index]);
|
|
318
|
+
}
|
|
319
|
+
}));
|
|
320
|
+
return output;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Errors first, then the paper's order: page, then position on the page.
|
|
324
|
+
export function sortProblems(problems: BuildProblem[]) {
|
|
325
|
+
const rank = { error: 0, warning: 1, minor: 2 };
|
|
326
|
+
return [...problems].sort((a, b) => rank[a.severity] - rank[b.severity]
|
|
327
|
+
|| (a.page ?? Infinity) - (b.page ?? Infinity) || (a.rect?.[1] ?? 0) - (b.rect?.[1] ?? 0)
|
|
328
|
+
|| (a.file || '').localeCompare(b.file || '') || (a.line ?? 0) - (b.line ?? 0));
|
|
329
|
+
}
|
package/src/server/changes.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
5
5
|
import { DATA, ENGINE } from './paths.ts';
|
|
6
6
|
import { mergeInProgress, workingTree } from './manuscript.ts';
|
|
7
7
|
import { ignorePattern } from '../commit.ts';
|
|
8
|
+
import { checker, confirmations, engine as referenceEngine } from './references.ts';
|
|
8
9
|
|
|
9
10
|
// The manuscript's uncommitted changes as a PDF comparison: the last commit
|
|
10
11
|
// against the working tree, rendered by the review engine like a task's round
|
|
@@ -197,14 +198,36 @@ export class Changes {
|
|
|
197
198
|
detail(projectId: string, number: number) {
|
|
198
199
|
const round = this.comparison(projectId).rounds.find(item => item.number === number);
|
|
199
200
|
if (!round) throw Object.assign(new Error('This comparison is out of date. Refresh the page.'), { statusCode: 404 });
|
|
200
|
-
|
|
201
|
-
try { render = JSON.parse(fs.readFileSync(path.join(round.folder, 'round.json'), 'utf8')); } catch { /* An empty comparison has no render. */ }
|
|
201
|
+
const render = this.rendered(round);
|
|
202
202
|
return {
|
|
203
203
|
task: 'changes', round: round.number, before: round.before, after: round.after,
|
|
204
204
|
units: round.units.map(unit => ({ ...unit, ...render.units[unit.id] })), sections: render.sections, loose: render.loose,
|
|
205
205
|
warnings: round.warnings, files: `/review/${projectId}/changes/files/${round.number}/`,
|
|
206
|
+
...(render.references ? { references: render.references } : {}),
|
|
206
207
|
};
|
|
207
208
|
}
|
|
209
|
+
private rendered(round: Round): { units: Record<string, object>; sections: unknown[]; loose: unknown[]; references?: unknown } {
|
|
210
|
+
try { return JSON.parse(fs.readFileSync(path.join(round.folder, 'round.json'), 'utf8')); } catch { return { units: {}, sections: [], loose: [] }; } // An empty comparison has no render.
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Online checks of the bibliography entries the uncommitted changes add or
|
|
214
|
+
// modify, as for a task's round; check asks for lookups to start.
|
|
215
|
+
async references(projectId: string, root: string, number: number, check: boolean, retry: boolean) {
|
|
216
|
+
const inputs = await this.entryInputs(projectId, number);
|
|
217
|
+
return { ...(await checker()).lookup(inputs, check, retry), confirmed: (await confirmations(root)).of(inputs) };
|
|
218
|
+
}
|
|
219
|
+
// The author checked an entry by hand, or takes that back.
|
|
220
|
+
async confirmReference(projectId: string, root: string, number: number, id: string, on: boolean) {
|
|
221
|
+
const inputs = await this.entryInputs(projectId, number), input = inputs.find(item => item.id === id);
|
|
222
|
+
if (!input) throw Object.assign(new Error('This entry is no longer among the changes. Refresh the page.'), { statusCode: 404 });
|
|
223
|
+
(await confirmations(root)).set(input, on);
|
|
224
|
+
return this.references(projectId, root, number, false, false);
|
|
225
|
+
}
|
|
226
|
+
private async entryInputs(projectId: string, number: number) {
|
|
227
|
+
const round = this.comparison(projectId).rounds.find(item => item.number === number);
|
|
228
|
+
if (!round) throw Object.assign(new Error('This comparison is out of date. Refresh the page.'), { statusCode: 404 });
|
|
229
|
+
return (await referenceEngine()).checkInputs(this.rendered(round).references);
|
|
230
|
+
}
|
|
208
231
|
|
|
209
232
|
// The places a change alters away from its own lines, past those the
|
|
210
233
|
// comparison rendered, rendered into its folder when first asked for.
|
package/src/server/codex.ts
CHANGED
|
@@ -34,7 +34,8 @@ export class Codex extends EventEmitter {
|
|
|
34
34
|
};
|
|
35
35
|
child.once('error', failed);
|
|
36
36
|
child.once('exit', (code, signal) => failed(new Error(`Codex exited (${signal || code})`)));
|
|
37
|
-
|
|
37
|
+
// The experimental API carries the tools Papilot gives its threads (see VIEW_PDF_TOOL).
|
|
38
|
+
await this.request('initialize', { clientInfo: { name: 'papilot', title: 'Papilot', version: VERSION }, capabilities: { experimentalApi: true } });
|
|
38
39
|
this.notify('initialized', {}); this.ready = true;
|
|
39
40
|
}
|
|
40
41
|
private receive(message: any) {
|