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
@@ -0,0 +1,784 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import type {
5
+ CheckDifference,
6
+ CheckRecord,
7
+ ReferenceCheck,
8
+ ReferenceChecks,
9
+ References,
10
+ } from "../types.ts";
11
+
12
+ // Checks bibliography entries against published records: a DOI through
13
+ // doi.org, an arXiv identifier through arXiv, and otherwise the title in
14
+ // Crossref, then DBLP, then arXiv. Only the entry's title, authors, year and
15
+ // identifiers leave the computer. A record agrees when its title, authors and
16
+ // year match; finding none is "unverified", not proof that the work does not
17
+ // exist, since theses, reports and many books are in none of these sources.
18
+
19
+ export interface CheckInput {
20
+ id: string;
21
+ type: string;
22
+ fields: Record<string, string>;
23
+ }
24
+ export type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
25
+ interface Candidate {
26
+ record: CheckRecord;
27
+ families: string[];
28
+ preprint: boolean;
29
+ /** Crossref's type of the work, such as journal-article or book. */
30
+ kind?: string;
31
+ }
32
+ type Lookup<T> = T | "unavailable";
33
+
34
+ const AGENT = "latex-review bibliography check (+https://github.com/cu1ch3n/papilot)";
35
+ // Milliseconds between two requests to a host. arXiv asks for one request
36
+ // every three seconds; DBLP for restraint.
37
+ const PACE: Record<string, number> = {
38
+ "doi.org": 200,
39
+ "api.crossref.org": 250,
40
+ "dblp.org": 1000,
41
+ "export.arxiv.org": 3000,
42
+ };
43
+
44
+ // --- Text -----------------------------------------------------------------
45
+
46
+ const LETTERS: Record<string, string> = {
47
+ ss: "ss", o: "o", O: "O", aa: "a", AA: "A", ae: "ae", AE: "AE",
48
+ oe: "oe", OE: "OE", l: "l", L: "L", i: "i", j: "j",
49
+ };
50
+ const GREEK: Record<string, string> = Object.fromEntries(
51
+ "alpha α beta β gamma γ delta δ epsilon ε varepsilon ε zeta ζ eta η theta θ vartheta θ iota ι kappa κ lambda λ mu μ nu ν xi ξ pi π rho ρ sigma σ tau τ upsilon υ phi φ varphi φ chi χ psi ψ omega ω Gamma Γ Delta Δ Theta Θ Lambda Λ Xi Ξ Pi Π Sigma Σ Phi Φ Psi Ψ Omega Ω"
52
+ .split(" ")
53
+ .flatMap((word, i, all) => (i % 2 ? [] : [[word, all[i + 1]]])),
54
+ );
55
+ /** LaTeX as plain text: accents to letters, Greek letters to characters,
56
+ * other commands dropped, braces gone. HTML tags, which Crossref puts in
57
+ * titles, go too. */
58
+ export function plain(text: string) {
59
+ return text
60
+ .replace(/<\/?(?:i|b|em|strong|sub|sup|scp|tt|span|mml:[a-z]+)\b[^>]*>/gi, "")
61
+ .replace(/\\([A-Za-z]+)(?![A-Za-z])/g, (match, name) => GREEK[name] ?? match)
62
+ .replace(/\\\\|\\[ ,;:!]/g, " ")
63
+ .replace(
64
+ /\\[`'^"~=.]\s*(?:\{\s*\\?([A-Za-z])\s*\}|\\?([A-Za-z]))/g,
65
+ (_, braced, bare) => braced ?? bare,
66
+ )
67
+ // Accents named by a letter, such as \c{c} or \v s, unlike \rm.
68
+ .replace(
69
+ /\\[uvHtcdbkr](?:\s*\{\s*\\?([A-Za-z])\s*\}|\s+\\?([A-Za-z]))/g,
70
+ (_, braced, bare) => braced ?? bare,
71
+ )
72
+ .replace(/\\(ss|aa|AA|ae|AE|oe|OE|o|O|l|L|i|j)(?![A-Za-z])\s*/g, (_, c) => LETTERS[c])
73
+ .replace(/\\([&%$#_{}])/g, "$1")
74
+ .replace(/\\(LaTeX|TeX)(?![A-Za-z])/g, "$1")
75
+ .replace(/\\[A-Za-z]+\*?\s*/g, "")
76
+ .replace(/[{}$]/g, "")
77
+ .replace(/~/g, " ")
78
+ .replace(/-{2,3}/g, "-")
79
+ .replace(/\s+/g, " ")
80
+ .trim();
81
+ }
82
+ const fold = (text: string) =>
83
+ text.normalize("NFKD").replace(/\p{M}+/gu, "").toLowerCase();
84
+ /** Letters and digits only, for comparing titles and names. */
85
+ export const compact = (text: string) =>
86
+ fold(plain(text)).replace(/[^\p{L}\p{N}]+/gu, "");
87
+ function similarity(a: string, b: string) {
88
+ if (!a || !b) return 0;
89
+ if (a === b) return 1;
90
+ let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
91
+ for (let i = 1; i <= a.length; i++) {
92
+ const row = [i];
93
+ for (let j = 1; j <= b.length; j++)
94
+ row[j] = Math.min(
95
+ previous[j] + 1,
96
+ row[j - 1] + 1,
97
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
98
+ );
99
+ previous = row;
100
+ }
101
+ return 1 - previous[b.length] / Math.max(a.length, b.length);
102
+ }
103
+ /** 1 for the same title; 0.9 when one adds only a subtitle after a colon,
104
+ * full stop or dash. Words added without one make another title. */
105
+ export function titleScore(a: string, b: string) {
106
+ const x = compact(a),
107
+ y = compact(b);
108
+ if (x && x === y) return 1;
109
+ const main = (title: string) => compact(plain(title).split(/\s*(?:[:.?!]|\s[-–—])\s+/)[0]),
110
+ subtitle =
111
+ (x.length >= 15 && main(b) === x && y !== x) || (y.length >= 15 && main(a) === y && x !== y)
112
+ ? 0.9
113
+ : 0;
114
+ return Math.max(similarity(x.slice(0, 400), y.slice(0, 400)), subtitle);
115
+ }
116
+
117
+ // --- Names ----------------------------------------------------------------
118
+
119
+ // Split at top-level " and ", as BibTeX does.
120
+ function splitNames(value: string) {
121
+ const names: string[] = [];
122
+ let depth = 0,
123
+ start = 0;
124
+ for (let i = 0; i < value.length; i++) {
125
+ if (value[i] === "{") depth++;
126
+ else if (value[i] === "}") depth--;
127
+ else if (depth === 0 && /\s/.test(value[i])) {
128
+ const match = /^\s+and\s+/i.exec(value.slice(i));
129
+ if (match) {
130
+ names.push(value.slice(start, i));
131
+ i += match[0].length - 1;
132
+ start = i + 1;
133
+ }
134
+ }
135
+ }
136
+ names.push(value.slice(start));
137
+ return names.map((n) => n.trim()).filter(Boolean);
138
+ }
139
+ function topLevel(name: string, separator: RegExp) {
140
+ const parts: string[] = [];
141
+ let depth = 0,
142
+ current = "";
143
+ for (const c of name) {
144
+ if (c === "{") depth++;
145
+ if (c === "}") depth--;
146
+ if (depth === 0 && separator.test(c)) {
147
+ parts.push(current);
148
+ current = "";
149
+ } else current += c;
150
+ }
151
+ parts.push(current);
152
+ return parts.map((p) => p.trim()).filter(Boolean);
153
+ }
154
+ /** A BibTeX author list as names and family names; `others` marks a list
155
+ * cut short with "and others". */
156
+ export function bibAuthors(value: string) {
157
+ const names: string[] = [],
158
+ families: string[] = [];
159
+ let others = false;
160
+ for (const name of splitNames(value)) {
161
+ if (name.toLowerCase() === "others") {
162
+ others = true;
163
+ continue;
164
+ }
165
+ let family: string;
166
+ if (/^\{[^{}]*\}$/.test(name)) family = name;
167
+ else {
168
+ const comma = topLevel(name, /,/);
169
+ if (comma.length > 1) family = comma[0];
170
+ else {
171
+ const words = topLevel(name, /\s/),
172
+ von = words.findIndex((w, i) => i > 0 && i < words.length - 1 && /^[a-z]/.test(w));
173
+ family = words.slice(von > 0 ? von : words.length - 1).join(" ");
174
+ }
175
+ }
176
+ names.push(plain(comma(name)));
177
+ families.push(compact(family));
178
+ }
179
+ return { names, families, others };
180
+ }
181
+ // "Knuth, Donald E." as "Donald E. Knuth" for display.
182
+ function comma(name: string) {
183
+ const parts = topLevel(name, /,/);
184
+ return parts.length === 2 ? `${parts[1]} ${parts[0]}` : name;
185
+ }
186
+ const recordFamily = (name: string) =>
187
+ compact(name.replace(/\s+\d{4}$/, "").trim().split(/\s+/).at(-1) ?? "");
188
+ const sameFamily = (a: string, b: string) =>
189
+ a === b || (Math.min(a.length, b.length) >= 3 && (a.endsWith(b) || b.endsWith(a)));
190
+
191
+ // --- Identifiers ----------------------------------------------------------
192
+
193
+ export function doiOf(fields: Record<string, string>) {
194
+ const clean = (value: string) =>
195
+ value
196
+ .replace(/\\_/g, "_")
197
+ .replace(/^\s*(?:https?:\/\/(?:dx\.)?doi\.org\/|doi:\s*)/i, "")
198
+ .replace(/[\s.,;]+$/, "")
199
+ .trim();
200
+ if (fields.doi) {
201
+ const doi = clean(fields.doi);
202
+ if (/^10\.\d{4,9}\/\S+$/.test(doi)) return doi;
203
+ }
204
+ const inUrl = /doi\.org\/(10\.\d{4,9}\/[^\s}]+)/i.exec(fields.url ?? "");
205
+ return inUrl ? clean(inUrl[1]) : null;
206
+ }
207
+ const ARXIV = /(\d{4}\.\d{4,5}|[a-z-]+(?:\.[A-Z]{2})?\/\d{7})(?:v\d+)?/;
208
+ export function arxivOf(fields: Record<string, string>) {
209
+ const kind = (fields.archiveprefix ?? fields.eprinttype ?? "").toLowerCase();
210
+ if (fields.eprint && (kind === "arxiv" || !kind)) {
211
+ const match = new RegExp(`^\\s*(?:arxiv:)?${ARXIV.source}\\s*$`, "i").exec(fields.eprint);
212
+ if (match) return match[1];
213
+ }
214
+ if (fields.arxiv) {
215
+ const match = ARXIV.exec(fields.arxiv);
216
+ if (match) return match[1];
217
+ }
218
+ const url = /arxiv\.org\/(?:abs|pdf)\/([^\s}?#]+)/i.exec(fields.url ?? "");
219
+ if (url) {
220
+ const match = ARXIV.exec(url[1]);
221
+ if (match) return match[1];
222
+ }
223
+ for (const name of ["journal", "note", "howpublished", "booktitle", "publisher"]) {
224
+ const match = new RegExp(`arXiv:\\s*${ARXIV.source}`, "i").exec(fields[name] ?? "");
225
+ if (match) return match[1];
226
+ }
227
+ const doi = /^10\.48550\/arxiv\.(.+)$/i.exec(doiOf(fields) ?? "");
228
+ return doi ? (ARXIV.exec(doi[1])?.[1] ?? null) : null;
229
+ }
230
+ const yearOf = (fields: Record<string, string>) => {
231
+ const match = /\b(\d{4})\b/.exec(fields.year ?? fields.date ?? "");
232
+ return match ? Number(match[1]) : null;
233
+ };
234
+
235
+ // --- HTTP -----------------------------------------------------------------
236
+
237
+ interface Answer {
238
+ status: number;
239
+ type: string;
240
+ body: string;
241
+ }
242
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
243
+ /** One request at a time to each host, paced, with a short retry, and a
244
+ * host that keeps failing set aside for a few minutes. */
245
+ export class Http {
246
+ private queues = new Map<string, Promise<unknown>>();
247
+ private last = new Map<string, number>();
248
+ private down = new Map<string, number>();
249
+ constructor(
250
+ private fetcher: Fetch = fetch,
251
+ private pace: Record<string, number> = PACE,
252
+ private now: () => number = Date.now,
253
+ private sleep: (ms: number) => Promise<unknown> = delay,
254
+ ) {}
255
+ get(url: string, accept: string): Promise<Lookup<Answer>> {
256
+ const host = new URL(url).hostname,
257
+ run = (this.queues.get(host) ?? Promise.resolve()).then(() =>
258
+ this.request(host, url, accept),
259
+ );
260
+ this.queues.set(host, run.catch(() => {}));
261
+ return run;
262
+ }
263
+ private async request(host: string, url: string, accept: string): Promise<Lookup<Answer>> {
264
+ if ((this.down.get(host) ?? 0) > this.now()) return "unavailable";
265
+ for (let attempt = 0; attempt < 3; attempt++) {
266
+ const wait = (this.last.get(host) ?? 0) + (this.pace[host] ?? 500) - this.now();
267
+ if (wait > 0) await this.sleep(wait);
268
+ this.last.set(host, this.now());
269
+ try {
270
+ const response = await this.fetcher(url, {
271
+ headers: { Accept: accept, "User-Agent": AGENT },
272
+ redirect: "follow",
273
+ signal: AbortSignal.timeout(15000),
274
+ });
275
+ const type = response.headers.get("content-type") ?? "",
276
+ body = await response.text();
277
+ // A page asking to prove the client is not a bot is not an answer.
278
+ if (response.status === 202 || (accept.includes("json") && response.ok && !type.includes("json"))) {
279
+ this.down.set(host, this.now() + 10 * 60000);
280
+ return "unavailable";
281
+ }
282
+ if (response.status === 429 || response.status >= 500) {
283
+ const after = Number(response.headers.get("retry-after"));
284
+ if (attempt < 2) {
285
+ await this.sleep(Math.min(Number.isFinite(after) && after > 0 ? after * 1000 : 1500 * (attempt + 1), 10000));
286
+ continue;
287
+ }
288
+ this.down.set(host, this.now() + 5 * 60000);
289
+ return "unavailable";
290
+ }
291
+ return { status: response.status, type, body };
292
+ } catch {
293
+ if (attempt < 2) {
294
+ await this.sleep(1000 * (attempt + 1));
295
+ continue;
296
+ }
297
+ this.down.set(host, this.now() + 2 * 60000);
298
+ return "unavailable";
299
+ }
300
+ }
301
+ return "unavailable";
302
+ }
303
+ }
304
+
305
+ // --- Sources --------------------------------------------------------------
306
+
307
+ const first = (value: unknown) =>
308
+ (Array.isArray(value) ? value[0] : value) as string | undefined;
309
+ function cslCandidate(item: any, source: CheckRecord["source"]): Candidate | null {
310
+ const title = first(item?.title);
311
+ if (!title) return null;
312
+ const people = (Array.isArray(item.author) ? item.author : []) as any[],
313
+ authors = people.map((a) => a.literal ?? a.name ?? [a.given, a.family].filter(Boolean).join(" ")).filter(Boolean),
314
+ families = people.map((a) => compact(a.family ?? a.literal ?? a.name ?? "")).filter(Boolean),
315
+ year = (item.issued ?? item.published ?? item["published-print"] ?? item["published-online"])?.["date-parts"]?.[0]?.[0],
316
+ doi = typeof item.DOI === "string" ? item.DOI : null,
317
+ venue = first(item["container-title"]) || (typeof item.publisher === "string" ? item.publisher : null) || null;
318
+ return {
319
+ record: {
320
+ source,
321
+ title: plain(String(title)),
322
+ authors,
323
+ year: Number.isInteger(year) ? year : null,
324
+ venue,
325
+ doi,
326
+ url: doi ? `https://doi.org/${doi}` : (item.URL ?? ""),
327
+ },
328
+ families,
329
+ preprint: item.type === "posted-content" || /arxiv/i.test(String(item.publisher ?? "")),
330
+ kind: typeof item.type === "string" ? item.type : undefined,
331
+ };
332
+ }
333
+ async function byDoi(http: Http, doi: string): Promise<Lookup<Candidate | null>> {
334
+ const url = "https://doi.org/" + doi.split("/").map(encodeURIComponent).join("/"),
335
+ answer = await http.get(url, "application/vnd.citationstyles.csl+json");
336
+ if (answer === "unavailable") return answer;
337
+ if (answer.status === 404) return null;
338
+ if (answer.status !== 200) return "unavailable";
339
+ try {
340
+ return cslCandidate(JSON.parse(answer.body), "doi") ?? "unavailable";
341
+ } catch {
342
+ return "unavailable";
343
+ }
344
+ }
345
+ // Crossref ranks the right record first far more often when the query
346
+ // carries the authors' names as well as the title.
347
+ async function searchCrossref(http: Http, title: string, names: string[]): Promise<Lookup<Candidate[]>> {
348
+ const query = new URLSearchParams({
349
+ "query.bibliographic": [title, ...names.slice(0, 4)].join(" "),
350
+ rows: "10",
351
+ select: "DOI,title,author,issued,container-title,type,publisher",
352
+ }),
353
+ answer = await http.get(`https://api.crossref.org/works?${query}`, "application/json");
354
+ if (answer === "unavailable" || answer.status !== 200) return "unavailable";
355
+ try {
356
+ const items: any[] = JSON.parse(answer.body)?.message?.items ?? [];
357
+ return items.map((item) => cslCandidate(item, "crossref")).filter((c): c is Candidate => c !== null);
358
+ } catch {
359
+ return "unavailable";
360
+ }
361
+ }
362
+ async function searchDblp(http: Http, title: string): Promise<Lookup<Candidate[]>> {
363
+ const query = new URLSearchParams({ q: title, format: "json", h: "5" }),
364
+ answer = await http.get(`https://dblp.org/search/publ/api?${query}`, "application/json");
365
+ if (answer === "unavailable" || answer.status !== 200) return "unavailable";
366
+ try {
367
+ const hits: any[] = JSON.parse(answer.body)?.result?.hits?.hit ?? [];
368
+ return hits.flatMap((hit): Candidate[] => {
369
+ const info = hit.info ?? {},
370
+ people = info.authors?.author,
371
+ list = (Array.isArray(people) ? people : people ? [people] : []).map((a: any) =>
372
+ String(a.text ?? a).replace(/\s+\d{4}$/, ""),
373
+ );
374
+ if (!info.title) return [];
375
+ return [{
376
+ record: {
377
+ source: "dblp",
378
+ title: String(info.title).replace(/\.$/, ""),
379
+ authors: list,
380
+ year: Number(info.year) || null,
381
+ venue: first(info.venue) ?? null,
382
+ doi: info.doi ?? null,
383
+ url: info.url ?? info.ee ?? "",
384
+ },
385
+ families: list.map(recordFamily),
386
+ preprint: /corr/i.test(String(info.venue ?? "")),
387
+ }];
388
+ });
389
+ } catch {
390
+ return "unavailable";
391
+ }
392
+ }
393
+ const decode = (text: string) =>
394
+ text
395
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
396
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
397
+ .replace(/&quot;/g, '"')
398
+ .replace(/&apos;/g, "'")
399
+ .replace(/&lt;/g, "<")
400
+ .replace(/&gt;/g, ">")
401
+ .replace(/&amp;/g, "&");
402
+ function atomCandidates(xml: string): Candidate[] {
403
+ return [...xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].flatMap(([, entry]): Candidate[] => {
404
+ const tag = (name: string) =>
405
+ decode(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`).exec(entry)?.[1] ?? "").replace(/\s+/g, " ").trim(),
406
+ id = ARXIV.exec(tag("id"))?.[1],
407
+ title = tag("title");
408
+ if (!id || !title) return [];
409
+ const authors = [...entry.matchAll(/<author>\s*<name>([\s\S]*?)<\/name>/g)].map((m) => decode(m[1]).trim());
410
+ return [{
411
+ record: {
412
+ source: "arxiv",
413
+ title,
414
+ authors,
415
+ year: Number(/^(\d{4})/.exec(tag("published"))?.[1]) || null,
416
+ venue: tag("arxiv:journal_ref") || "arXiv",
417
+ doi: tag("arxiv:doi") || null,
418
+ url: `https://arxiv.org/abs/${id}`,
419
+ },
420
+ families: authors.map(recordFamily),
421
+ preprint: true,
422
+ }];
423
+ });
424
+ }
425
+ async function byArxiv(http: Http, id: string): Promise<Lookup<Candidate | null>> {
426
+ const answer = await http.get(
427
+ `https://export.arxiv.org/api/query?id_list=${encodeURIComponent(id)}`,
428
+ "application/atom+xml",
429
+ );
430
+ if (answer === "unavailable" || answer.status !== 200) return "unavailable";
431
+ return atomCandidates(answer.body)[0] ?? null;
432
+ }
433
+ async function searchArxiv(http: Http, title: string): Promise<Lookup<Candidate[]>> {
434
+ const words = fold(title).replace(/[^\p{L}\p{N}\s-]+/gu, " ").replace(/\s+/g, " ").trim(),
435
+ answer = await http.get(
436
+ `https://export.arxiv.org/api/query?search_query=${encodeURIComponent(`ti:"${words}"`)}&max_results=5`,
437
+ "application/atom+xml",
438
+ );
439
+ if (answer === "unavailable" || answer.status !== 200) return "unavailable";
440
+ return atomCandidates(answer.body);
441
+ }
442
+
443
+ // --- Comparison -----------------------------------------------------------
444
+
445
+ interface Entry {
446
+ type: string;
447
+ title: string;
448
+ authors: ReturnType<typeof bibAuthors>;
449
+ year: number | null;
450
+ doi: string | null;
451
+ }
452
+ function readEntry(type: string, fields: Record<string, string>): Entry {
453
+ return {
454
+ type: type.toLowerCase(),
455
+ title: plain(fields.title ?? ""),
456
+ authors: bibAuthors(fields.author ?? fields.editor ?? ""),
457
+ year: yearOf(fields),
458
+ doi: doiOf(fields),
459
+ };
460
+ }
461
+ const yearAgrees = (entry: Entry, c: Candidate) =>
462
+ entry.year === null ||
463
+ c.record.year === null ||
464
+ entry.year === c.record.year ||
465
+ (c.preprint && entry.year > c.record.year && entry.year - c.record.year <= 2);
466
+ function authorsAgree(entry: Entry, c: Candidate) {
467
+ const bib = entry.authors.families,
468
+ record = c.families;
469
+ if (!bib.length || !record.length) return { first: true, all: true, order: true };
470
+ const found = bib.filter((f) => record.some((r) => sameFamily(f, r))),
471
+ counted = entry.authors.others ? bib.length <= record.length : bib.length === record.length;
472
+ return {
473
+ first: sameFamily(bib[0], record[0]),
474
+ all: found.length === bib.length && counted,
475
+ order: bib.every((f, i) => record[i] !== undefined && sameFamily(f, record[i])),
476
+ };
477
+ }
478
+ function differences(entry: Entry, c: Candidate): CheckDifference[] {
479
+ const result: CheckDifference[] = [],
480
+ title = titleScore(entry.title, c.record.title);
481
+ if (entry.title && title < 1)
482
+ result.push({ field: "title", bib: entry.title, record: c.record.title, ...(title >= 0.9 ? { minor: true } : {}) });
483
+ const authors = authorsAgree(entry, c);
484
+ if (!authors.all || !authors.first || !authors.order)
485
+ result.push({
486
+ field: "author",
487
+ bib: [...entry.authors.names, ...(entry.authors.others ? ["others"] : [])].join("; "),
488
+ record: c.record.authors.join("; "),
489
+ ...(authors.all && authors.first ? { minor: true } : {}),
490
+ });
491
+ if (entry.year !== null && c.record.year !== null && entry.year !== c.record.year)
492
+ result.push({ field: "year", bib: String(entry.year), record: String(c.record.year), ...(yearAgrees(entry, c) ? { minor: true } : {}) });
493
+ if (entry.doi && c.record.doi && entry.doi.toLowerCase() !== c.record.doi.toLowerCase() && c.record.source !== "arxiv")
494
+ result.push({ field: "doi", bib: entry.doi, record: c.record.doi });
495
+ return result;
496
+ }
497
+ function judge(entry: Entry, c: Candidate) {
498
+ const found = differences(entry, c);
499
+ return {
500
+ status: found.some((d) => !d.minor) ? ("mismatch" as const) : ("verified" as const),
501
+ record: c.record,
502
+ differences: found,
503
+ };
504
+ }
505
+ // A journal article with a book's title is a review of the book.
506
+ const BOOKS = new Set(["book", "booklet", "manual", "collection", "mvbook", "proceedings", "reference"]);
507
+ const review = (entry: Entry, c: Candidate) =>
508
+ BOOKS.has(entry.type) && (c.kind === "journal-article" || c.kind === "book-review");
509
+ // How well a search result fits the entry, and whether it agrees.
510
+ function fit(entry: Entry, c: Candidate) {
511
+ const title = titleScore(entry.title, c.record.title),
512
+ authors = authorsAgree(entry, c),
513
+ year = yearAgrees(entry, c);
514
+ return {
515
+ title,
516
+ agrees: title >= 0.9 && authors.first && year,
517
+ value: title * 3 + (authors.first ? 1 : 0) + (authors.all ? 0.5 : 0) + (year ? 0.5 : 0),
518
+ };
519
+ }
520
+
521
+ export async function checkEntry(input: CheckInput, http: Http, now = Date.now): Promise<ReferenceCheck> {
522
+ const entry = readEntry(input.type, input.fields),
523
+ arxiv = arxivOf(input.fields),
524
+ unavailable = new Set<string>(),
525
+ result = (check: Omit<ReferenceCheck, "unavailable" | "checked" | "differences"> & { differences?: CheckDifference[] }): ReferenceCheck => ({
526
+ differences: [],
527
+ ...check,
528
+ unavailable: [...unavailable],
529
+ checked: Math.floor(now() / 1000),
530
+ });
531
+ if (!entry.doi && !arxiv && !compact(entry.title))
532
+ return result({ status: "skipped", via: null, reason: "nothing-to-check" });
533
+ let doiLost = false,
534
+ arxivLost = false,
535
+ answered = false;
536
+ // An identifier that names no record is itself a finding, also when the
537
+ // work turns up by its title.
538
+ const settle = (c: Candidate, via: ReferenceCheck["via"], agrees = true) => {
539
+ let found = judge(entry, c).differences;
540
+ if (doiLost)
541
+ found = [{ field: "doi", bib: entry.doi!, record: c.record.doi ?? "" }, ...found.filter((d) => d.field !== "doi")];
542
+ if (arxivLost)
543
+ found = [{ field: "arxiv", bib: arxiv!, record: c.record.source === "arxiv" ? (ARXIV.exec(c.record.url)?.[1] ?? "") : "" }, ...found];
544
+ return result({
545
+ status: !agrees || found.some((d) => !d.minor) ? "mismatch" : "verified",
546
+ via,
547
+ record: c.record,
548
+ differences: found,
549
+ });
550
+ };
551
+ if (entry.doi && !/^10\.48550\//.test(entry.doi)) {
552
+ const found = await byDoi(http, entry.doi);
553
+ if (found === "unavailable") unavailable.add("doi.org");
554
+ else if (found) return settle(found, "doi");
555
+ else answered = doiLost = true;
556
+ }
557
+ if (arxiv) {
558
+ const found = await byArxiv(http, arxiv);
559
+ if (found === "unavailable") unavailable.add("arXiv");
560
+ else if (found) return settle(found, "arxiv");
561
+ else answered = arxivLost = true;
562
+ }
563
+ const reason = doiLost ? "doi-not-found" : arxivLost ? "arxiv-not-found" : "no-match";
564
+ if (compact(entry.title)) {
565
+ let best: { c: Candidate; fit: ReturnType<typeof fit> } | null = null;
566
+ const searches: [string, () => Promise<Lookup<Candidate[]>>][] = [
567
+ ["Crossref", () => searchCrossref(http, entry.title, entry.authors.names)],
568
+ ["DBLP", () => searchDblp(http, entry.title)],
569
+ ["arXiv", () => searchArxiv(http, entry.title)],
570
+ ];
571
+ for (const [name, search] of searches) {
572
+ const found = await search();
573
+ if (found === "unavailable") {
574
+ unavailable.add(name);
575
+ continue;
576
+ }
577
+ answered = true;
578
+ for (const c of found) {
579
+ if (review(entry, c)) continue;
580
+ const f = fit(entry, c);
581
+ // A result that agrees beats a closer one that does not.
582
+ if (
583
+ f.title >= 0.6 &&
584
+ (!best || (f.agrees === best.fit.agrees ? f.value > best.fit.value : f.agrees))
585
+ )
586
+ best = { c, fit: f };
587
+ }
588
+ if (best?.fit.agrees) break;
589
+ }
590
+ // The same title with other authors or another year is the work with
591
+ // wrong details; a similar title, or one with a subtitle added, is only
592
+ // the closest record.
593
+ if (best && (best.fit.agrees || best.fit.title >= 0.95)) return settle(best.c, "title", best.fit.agrees);
594
+ if (best)
595
+ return result({ status: "unverified", via: "title", reason, record: best.c.record, differences: judge(entry, best.c).differences });
596
+ }
597
+ return result({
598
+ status: answered ? "unverified" : "failed",
599
+ via: entry.doi ? "doi" : arxiv ? "arxiv" : "title",
600
+ reason,
601
+ });
602
+ }
603
+
604
+ // --- Checker --------------------------------------------------------------
605
+
606
+ /** The added and modified entries of a round, as the check reads them. */
607
+ export function checkInputs(references?: References): CheckInput[] {
608
+ return (references?.entries ?? [])
609
+ .filter((e) => e.status !== "removed" && !e.error)
610
+ .map((e) => ({
611
+ id: e.id,
612
+ type: e.type,
613
+ fields: Object.fromEntries(
614
+ e.fields.filter((f) => f.new !== null).map((f) => [f.name, f.resolved ?? f.new!]),
615
+ ),
616
+ }));
617
+ }
618
+ /** Entries of the whole bibliography, as the check reads them. */
619
+ export function entryInputs(entries: { id: string; type: string; fields: { name: string; value: string; resolved?: string }[]; error?: string }[]): CheckInput[] {
620
+ return entries
621
+ .filter((e) => !e.error)
622
+ .map((e) => ({ id: e.id, type: e.type, fields: Object.fromEntries(e.fields.map((f) => [f.name, f.resolved ?? f.value])) }));
623
+ }
624
+ // Bump when the comparison changes, so that saved results are redone.
625
+ const VERSION = 2;
626
+ const LOOKED_UP = ["title", "author", "editor", "year", "date", "doi", "eprint", "archiveprefix", "eprinttype", "arxiv", "url", "journal", "note", "howpublished", "booktitle", "publisher"];
627
+ export const keyOf = (input: CheckInput) =>
628
+ createHash("sha256")
629
+ .update(JSON.stringify([VERSION, input.type.toLowerCase(), LOOKED_UP.map((n) => input.fields[n] ?? "")]))
630
+ .digest("hex")
631
+ .slice(0, 32);
632
+ // How long a result stands before the entry is looked up again, in seconds.
633
+ const KEEP: Record<ReferenceCheck["status"], number> = {
634
+ verified: 30 * 86400,
635
+ mismatch: 30 * 86400,
636
+ skipped: 30 * 86400,
637
+ unverified: 86400,
638
+ failed: 120,
639
+ };
640
+
641
+ /** Checks entries in the background and keeps the results in a file, so
642
+ * that a page can ask again until every entry has one. */
643
+ export class ReferenceChecker {
644
+ private saved: Record<string, ReferenceCheck> | null = null;
645
+ private failed = new Map<string, ReferenceCheck>();
646
+ private running = new Map<string, Promise<void>>();
647
+ private waiting: (() => void)[] = [];
648
+ private active = 0;
649
+ private now: () => number;
650
+ private concurrency: number;
651
+ private http: Http;
652
+ constructor(
653
+ private file: string | null,
654
+ options: { fetch?: Fetch; pace?: Record<string, number>; now?: () => number; concurrency?: number } = {},
655
+ ) {
656
+ this.now = options.now ?? Date.now;
657
+ this.concurrency = options.concurrency ?? 3;
658
+ this.http = new Http(options.fetch, options.pace, this.now);
659
+ }
660
+ private results() {
661
+ if (!this.saved) {
662
+ try {
663
+ const data = JSON.parse(fs.readFileSync(this.file ?? "", "utf8"));
664
+ this.saved = data?.version === VERSION && data.checks && typeof data.checks === "object" ? data.checks : {};
665
+ } catch {
666
+ this.saved = {};
667
+ }
668
+ }
669
+ return this.saved!;
670
+ }
671
+ private save() {
672
+ if (!this.file) return;
673
+ const kept = Object.entries(this.results())
674
+ .sort((a, b) => b[1].checked - a[1].checked)
675
+ .slice(0, 3000);
676
+ this.saved = Object.fromEntries(kept);
677
+ try {
678
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
679
+ const temporary = `${this.file}.${process.pid}.tmp`;
680
+ fs.writeFileSync(temporary, JSON.stringify({ version: VERSION, checks: this.saved }));
681
+ fs.renameSync(temporary, this.file);
682
+ } catch {
683
+ /* The results stay in memory. */
684
+ }
685
+ }
686
+ private fresh(check: ReferenceCheck) {
687
+ return this.now() / 1000 - check.checked < KEEP[check.status];
688
+ }
689
+ /** The results known for these entries. With `start`, entries without a
690
+ * fresh result are looked up; `retry` also redoes those that found nothing
691
+ * or could not be checked. */
692
+ lookup(inputs: CheckInput[], start = false, retry = false): ReferenceChecks {
693
+ const checks: Record<string, ReferenceCheck> = {};
694
+ let pending = 0;
695
+ for (const input of inputs) {
696
+ const key = keyOf(input),
697
+ known = this.failed.get(key) ?? this.results()[key],
698
+ redo = retry && known && (known.status === "failed" || known.status === "unverified");
699
+ if (this.running.has(key)) {
700
+ pending++;
701
+ continue;
702
+ }
703
+ if (known && this.fresh(known) && !redo) {
704
+ checks[input.id] = known;
705
+ continue;
706
+ }
707
+ if (!start) {
708
+ if (known) checks[input.id] = known;
709
+ continue;
710
+ }
711
+ pending++;
712
+ this.run(key, input);
713
+ }
714
+ return { checks, pending };
715
+ }
716
+ private run(key: string, input: CheckInput) {
717
+ const task = (async () => {
718
+ if (this.active >= this.concurrency) await new Promise<void>((r) => this.waiting.push(r));
719
+ this.active++;
720
+ try {
721
+ const check = await checkEntry(input, this.http, this.now);
722
+ if (check.status === "failed") this.failed.set(key, check);
723
+ else {
724
+ this.failed.delete(key);
725
+ this.results()[key] = check;
726
+ this.save();
727
+ }
728
+ } catch {
729
+ this.failed.set(key, {
730
+ status: "failed",
731
+ via: null,
732
+ differences: [],
733
+ unavailable: [],
734
+ checked: Math.floor(this.now() / 1000),
735
+ });
736
+ } finally {
737
+ this.active--;
738
+ this.waiting.shift()?.();
739
+ this.running.delete(key);
740
+ }
741
+ })();
742
+ this.running.set(key, task);
743
+ }
744
+ /** Settles when every lookup in progress has finished, for tests. */
745
+ async idle() {
746
+ while (this.running.size) await Promise.all(this.running.values());
747
+ }
748
+ }
749
+
750
+ /** Entries the author has checked by hand, kept beside the repository's
751
+ * review state so that the panel, the review of uncommitted changes and
752
+ * every task's review share them. A confirmation is of the entry's looked-up
753
+ * fields: editing them asks again. */
754
+ export class Confirmations {
755
+ constructor(private file: string) {}
756
+ private read(): Record<string, number> {
757
+ try {
758
+ const data = JSON.parse(fs.readFileSync(this.file, "utf8"));
759
+ return data?.version === 1 && data.confirmed && typeof data.confirmed === "object" ? data.confirmed : {};
760
+ } catch {
761
+ return {};
762
+ }
763
+ }
764
+ /** When each of these entries was confirmed, in seconds. */
765
+ of(inputs: CheckInput[]) {
766
+ const saved = this.read(),
767
+ result: Record<string, number> = {};
768
+ for (const input of inputs) {
769
+ const at = saved[keyOf(input)];
770
+ if (typeof at === "number") result[input.id] = at;
771
+ }
772
+ return result;
773
+ }
774
+ set(input: CheckInput, on: boolean, now = Date.now()) {
775
+ const saved = this.read(),
776
+ key = keyOf(input);
777
+ if (on) saved[key] = Math.floor(now / 1000);
778
+ else delete saved[key];
779
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
780
+ const temporary = `${this.file}.${process.pid}.tmp`;
781
+ fs.writeFileSync(temporary, JSON.stringify({ version: 1, confirmed: saved }));
782
+ fs.renameSync(temporary, this.file);
783
+ }
784
+ }