papilot 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +53 -100
  2. package/README.zh-CN.md +52 -97
  3. package/bin/doctor.mjs +55 -6
  4. package/bin/installation.d.mts +2 -0
  5. package/bin/installation.mjs +7 -1
  6. package/dist/assets/{_virtual_papilot-licenses-BWIsfrQh.js → _virtual_papilot-licenses-CMfCtEYQ.js} +54 -3
  7. package/dist/assets/index-BlLyIqT9.js +265 -0
  8. package/dist/assets/index-DVZtyZvu.css +1 -0
  9. package/dist/assets/latex-BarqJFtQ.svg +1 -0
  10. package/dist/assets/{math-BsEl3u6P.js → math-Bu3xEyrC.js} +1 -1
  11. package/dist/assets/pdf-view-PbkCuTZ-.js +598 -0
  12. package/dist/index.html +2 -2
  13. package/examples/papilot-paper/main.tex +1 -1
  14. package/examples/papilot-paper/preview/main.pdf +0 -0
  15. package/examples/papilot-paper/preview/main.synctex.gz +0 -0
  16. package/examples/papilot-paper/sections/02-using.tex +3 -1
  17. package/examples/papilot-paper/sections/09-agents.tex +8 -5
  18. package/package.json +18 -8
  19. package/skills/ai-review.md +18 -0
  20. package/skills/latex-warnings.md +1 -1
  21. package/src/conversation.ts +6 -0
  22. package/src/server/acp.ts +387 -0
  23. package/src/server/agents.ts +220 -43
  24. package/src/server/ai-review-routes.ts +72 -0
  25. package/src/server/ai-review.ts +38 -0
  26. package/src/server/app.ts +56 -14
  27. package/src/server/build-problems.ts +329 -0
  28. package/src/server/changes.ts +25 -2
  29. package/src/server/codex.ts +2 -1
  30. package/src/server/cursor.ts +153 -0
  31. package/src/server/desktop-demo.ts +38 -0
  32. package/src/server/latex-installation.ts +43 -0
  33. package/src/server/manuscript.ts +183 -23
  34. package/src/server/paths.ts +1 -1
  35. package/src/server/reconcile.ts +20 -0
  36. package/src/server/references.ts +70 -0
  37. package/src/server/review.ts +6 -5
  38. package/src/server/skills.ts +5 -8
  39. package/src/server/start.ts +19 -4
  40. package/src/server/store.ts +20 -2
  41. package/src/server/update-install.ts +53 -2
  42. package/src/server/update-worker.ts +6 -6
  43. package/src/server/updates.ts +49 -14
  44. package/src/server/workspace.ts +236 -41
  45. package/src/shared.ts +88 -9
  46. package/src/skill-icons.ts +18 -0
  47. package/src/skill-metadata.ts +45 -0
  48. package/vendor/latex-review/dist/web/assets/index-CXdnUjct.js +19 -0
  49. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
  50. package/vendor/latex-review/dist/web/index.html +2 -2
  51. package/vendor/latex-review/src/core/bib.ts +692 -0
  52. package/vendor/latex-review/src/core/changes.ts +39 -0
  53. package/vendor/latex-review/src/core/current-commit.ts +104 -0
  54. package/vendor/latex-review/src/core/delivery.ts +62 -16
  55. package/vendor/latex-review/src/core/git.ts +2 -1
  56. package/vendor/latex-review/src/core/publish.ts +7 -1
  57. package/vendor/latex-review/src/core/reconcile.ts +101 -0
  58. package/vendor/latex-review/src/core/review.ts +6 -2
  59. package/vendor/latex-review/src/core/store.ts +1 -0
  60. package/vendor/latex-review/src/core/tasks.ts +1 -1
  61. package/vendor/latex-review/src/core/verify.ts +784 -0
  62. package/vendor/latex-review/src/core/view.ts +204 -0
  63. package/vendor/latex-review/src/render/round.ts +11 -0
  64. package/vendor/latex-review/src/server.ts +77 -1
  65. package/vendor/latex-review/src/types.ts +130 -2
  66. package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
  67. package/vendor/latex-review/src/web/references.tsx +499 -0
  68. package/vendor/latex-review/src/web/review.tsx +263 -133
  69. package/vendor/latex-review/src/web/style.css +285 -0
  70. package/vendor/latex-review/src/web/zh-CN.ts +79 -2
  71. package/dist/assets/index-CIJEYlP7.js +0 -252
  72. package/dist/assets/index-hOJEruFc.css +0 -1
  73. package/dist/assets/pdf-view-Cz9_vK5U.js +0 -598
  74. package/vendor/latex-review/dist/web/assets/index-Be9CtgcZ.css +0 -1
  75. 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 { AgentId, NoteSummary } from '../shared.ts';
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 === 'claude' || value === 'antigravity' ? value : 'codex';
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
- 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: 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) });
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 => {
@@ -238,8 +255,9 @@ export async function buildApp(options: AppOptions) {
238
255
  return serialGit(task.projectId, () => workspace.commitTask(task.id, string(b.message, 'Commit message', 5000), b.push === true));
239
256
  });
240
257
  app.post<{ Params: { id: string } }>('/api/tasks/:id/apply', task, request => {
241
- const task = store.task(request.params.id);
242
- return serialGit(task.projectId, () => workspace.deliverTask(task.id, 'workspace'));
258
+ const task = store.task(request.params.id), b = (request.body || {}) as any;
259
+ if (b.mode !== undefined && b.mode !== 'workspace' && b.mode !== 'commit') throw new Error('Invalid save action.');
260
+ return serialGit(task.projectId, () => workspace.applyTask(task.id, b.keepCurrent === true, b.mode === 'commit' ? { message: string(b.message, 'Commit message', 5000), ...(b.confirmation ? { confirmation: string(b.confirmation, 'Commit confirmation', 100) } : {}) } : undefined));
243
261
  });
244
262
  app.post<{ Params: { id: string } }>('/api/tasks/:id/commit-message', task, request => {
245
263
  const task = store.task(request.params.id);
@@ -247,7 +265,7 @@ export async function buildApp(options: AppOptions) {
247
265
  });
248
266
  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
267
  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));
268
+ 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
269
  app.post<{ Params: { id: string } }>('/api/tasks/:id/interrupt', task, request => workspace.interrupt(request.params.id).then(() => ({ ok: true })));
252
270
  // Deleting a task that is already gone succeeds, so a lost response can be retried; a hidden one answers the same.
253
271
  app.post<{ Params: { id: string } }>('/api/tasks/:id/delete', { config: { access: { task: 'id' }, hidden: 'ok' } }, request => {
@@ -262,6 +280,14 @@ export async function buildApp(options: AppOptions) {
262
280
  // The manuscript preview compiles each paper's working tree and reports its Git status.
263
281
  app.get<{ Params: { id: string } }>('/api/projects/:id/manuscript', project(), request => manuscript(request.params.id));
264
282
  app.get<{ Params: { id: string } }>('/api/projects/:id/git', project(), request => gitStatus(store.project(request.params.id).path));
283
+ // The paper's bibliography: every entry, its problems and its online check.
284
+ app.get<{ Params: { id: string }; Querystring: { check?: string; retry?: string } }>('/api/projects/:id/references', project(), request =>
285
+ bibliography(store.project(request.params.id).path, request.query.check === '1', request.query.retry === '1'));
286
+ app.post<{ Params: { id: string } }>('/api/projects/:id/references/confirm', project(), request => {
287
+ const b = request.body as any;
288
+ if (typeof b?.id !== 'string' || typeof b?.confirmed !== 'boolean') throw new Error('Choose an entry to confirm.');
289
+ return confirmEntry(store.project(request.params.id).path, b.id, b.confirmed);
290
+ });
265
291
  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
292
  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
293
  const paths = (value: unknown): string[] => Array.isArray(value) ? value.filter((p: unknown): p is string => typeof p === 'string').slice(0, 5000) : [];
@@ -311,7 +337,7 @@ export async function buildApp(options: AppOptions) {
311
337
  return serialGit(project.id, async () => { await gitRemote(project.path, action); return { git: await gitStatus(project.path) }; });
312
338
  });
313
339
  app.post<{ Params: { id: string } }>('/api/projects/:id/manuscript', project(), request => manuscript(request.params.id, true));
314
- // Notes marked on the manuscript PDF become one agent task when sent.
340
+ // Notes are saved for reading; only explicitly attached IDs reach an agent task.
315
341
  app.post<{ Params: { id: string } }>('/api/projects/:id/annotations', project(), async request => {
316
342
  const project = store.project(request.params.id), b = request.body as any;
317
343
  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 +352,7 @@ export async function buildApp(options: AppOptions) {
326
352
  });
327
353
  app.post<{ Params: { id: string; note: string } }>('/api/projects/:id/annotations/:note', project(), request => {
328
354
  const note = store.annotation(request.params.id, request.params.note);
355
+ if (isTaskReviewNote(note)) throw new Error('Manage this suggestion in its AI review task, or copy it to the manuscript first.');
329
356
  if (workspace.annotationSending(note.id)) throw new Error('This note is being sent. Wait for the task to finish creating.');
330
357
  if (note.taskId) throw new Error('This note has already been sent.');
331
358
  if (note.deletedAt !== undefined) throw new Error('This note has been deleted. Undo the deletion first.');
@@ -333,28 +360,34 @@ export async function buildApp(options: AppOptions) {
333
360
  });
334
361
  app.post<{ Params: { id: string; note: string } }>('/api/projects/:id/annotations/:note/delete', project(), request => {
335
362
  const note = store.annotation(request.params.id, request.params.note);
363
+ if (isTaskReviewNote(note)) throw new Error('Manage this suggestion in its AI review task, or copy it to the manuscript first.');
336
364
  if (workspace.annotationSending(note.id)) throw new Error('This note is being sent. Wait for the task to finish creating.');
337
365
  store.deleteAnnotation(request.params.id, request.params.note); return { ok: true };
338
366
  });
339
367
  app.post<{ Params: { id: string; note: string } }>('/api/projects/:id/annotations/:note/restore', project(), request => {
340
368
  const note = store.annotation(request.params.id, request.params.note);
369
+ if (isTaskReviewNote(note)) throw new Error('Manage this suggestion in its AI review task, or copy it to the manuscript first.');
341
370
  if (workspace.annotationSending(note.id)) throw new Error('This note is being sent. Wait for the task to finish creating.');
342
371
  if (note.taskId) throw new Error('This note has already been sent.');
343
372
  const { deletedAt: _, ...restored } = note;
344
373
  store.saveAnnotation(restored);
345
374
  // Return the original order, including any notes added since the deletion.
346
- return { annotations: store.annotations(request.params.id).filter(note => !note.taskId) };
375
+ return { annotations: store.annotations(request.params.id).filter(note => !note.taskId && !isTaskReviewNote(note)) };
347
376
  });
348
377
  app.post<{ Params: { id: string } }>('/api/projects/:id/annotations/send', project(), async request => {
349
378
  const project = store.project(request.params.id), b = request.body as any, id = string(b.id, 'Task ID', 64);
350
379
  // A retried send returns its task; an ID from another paper is refused.
351
380
  const existing = store.tasks().find(task => task.id === id);
352
381
  if (existing) { if (existing.projectId !== project.id) throw new Error('Invalid task ID.'); return existing; }
353
- const notes = store.annotations(project.id).filter(note => !note.taskId);
354
- if (!notes.length) throw new Error('Mark a region on the PDF and add a note first.');
382
+ 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.');
383
+ const notes = [...new Set<string>(b.annotations)].map(id => store.annotation(project.id, id));
384
+ if (notes.some(note => note.taskId || isTaskReviewNote(note) || note.deletedAt !== undefined)) throw new Error('Select unsent PDF notes for this task.');
355
385
  const { title } = annotationTask(notes);
356
386
  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
387
  });
388
+ // What the last build reported, placed on its PDF, and the TeX log itself.
389
+ app.get<{ Params: { id: string } }>('/api/projects/:id/manuscript/problems', project(), request => manuscripts.problems(store.project(request.params.id).id));
390
+ 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
391
  // Exact word places for the PDF view's selection layer; immutable per build key.
359
392
  app.get<{ Params: { id: string }; Querystring: { key?: string; page?: string } }>('/api/projects/:id/manuscript/words', project(), async (request, reply) => {
360
393
  const project = store.project(request.params.id);
@@ -423,6 +456,16 @@ export async function buildApp(options: AppOptions) {
423
456
  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
457
  app.get<{ Params: { projectId: string; number: string; unit: string } }>('/review/:projectId/api/changes/rounds/:number/effects/:unit', changesAccess, request =>
425
458
  changes.effects(store.project(request.params.projectId).id, Number(request.params.number), request.params.unit));
459
+ app.get<{ Params: { projectId: string; number: string }; Querystring: { check?: string; retry?: string } }>('/review/:projectId/api/changes/rounds/:number/references', changesAccess, request => {
460
+ const project = store.project(request.params.projectId);
461
+ return changes.references(project.id, project.path, Number(request.params.number), request.query.check === '1', request.query.retry === '1');
462
+ });
463
+ app.post<{ Params: { projectId: string; number: string } }>('/review/:projectId/api/changes/rounds/:number/references/confirm', changesAccess, request => {
464
+ changesToken(request);
465
+ const project = store.project(request.params.projectId), b = request.body as any;
466
+ if (typeof b?.id !== 'string' || typeof b?.confirmed !== 'boolean') throw new Error('Choose an entry to confirm.');
467
+ return changes.confirmReference(project.id, project.path, Number(request.params.number), b.id, b.confirmed);
468
+ });
426
469
  app.get<{ Params: { projectId: string; number: string; file: string } }>('/review/:projectId/changes/files/:number/:file', changesAccess, (request, reply) => {
427
470
  const file = changes.file(store.project(request.params.projectId).id, Number(request.params.number), request.params.file);
428
471
  return reply.header('Cache-Control', 'private, max-age=31536000, immutable').type(file.endsWith('.svg') ? 'image/svg+xml' : 'image/png').send(fs.createReadStream(file));
@@ -471,12 +514,11 @@ export async function buildApp(options: AppOptions) {
471
514
  const task = store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]);
472
515
  if (task) store.updateTask(task.id, { error: 'Review action failed: ' + (value as any).error });
473
516
  }
474
- if (response.ok && request.method === 'POST') {
517
+ if (response.ok && request.method === 'POST' && !route.endsWith('/references/confirm')) {
475
518
  const task = store.tasks().find(t => t.projectId === project.id && t.name === route.split('/')[3]);
476
519
  if (task) {
477
520
  store.updateTask(task.id, { error: null });
478
521
  await workspace.refresh(task.id);
479
- void workspace.prepareCommitMessage(task.id);
480
522
  }
481
523
  }
482
524
  return rewriteReviewJson(value, prefix);
@@ -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
+ }
@@ -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
- let render: { units: Record<string, object>; sections: unknown[]; loose: unknown[] } = { units: {}, sections: [], loose: [] };
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.
@@ -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
- await this.request('initialize', { clientInfo: { name: 'papilot', title: 'Papilot', version: VERSION } });
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) {