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.
- package/README.md +53 -100
- package/README.zh-CN.md +52 -97
- package/bin/doctor.mjs +55 -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-CMfCtEYQ.js} +54 -3
- package/dist/assets/index-BlLyIqT9.js +265 -0
- package/dist/assets/index-DVZtyZvu.css +1 -0
- package/dist/assets/latex-BarqJFtQ.svg +1 -0
- package/dist/assets/{math-BsEl3u6P.js → math-Bu3xEyrC.js} +1 -1
- package/dist/assets/pdf-view-PbkCuTZ-.js +598 -0
- package/dist/index.html +2 -2
- package/examples/papilot-paper/main.tex +1 -1
- package/examples/papilot-paper/preview/main.pdf +0 -0
- package/examples/papilot-paper/preview/main.synctex.gz +0 -0
- package/examples/papilot-paper/sections/02-using.tex +3 -1
- package/examples/papilot-paper/sections/09-agents.tex +8 -5
- package/package.json +18 -8
- 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 +387 -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 +56 -14
- 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/desktop-demo.ts +38 -0
- package/src/server/latex-installation.ts +43 -0
- package/src/server/manuscript.ts +183 -23
- package/src/server/paths.ts +1 -1
- package/src/server/reconcile.ts +20 -0
- package/src/server/references.ts +70 -0
- package/src/server/review.ts +6 -5
- package/src/server/skills.ts +5 -8
- package/src/server/start.ts +19 -4
- 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 +49 -14
- package/src/server/workspace.ts +236 -41
- package/src/shared.ts +88 -9
- package/src/skill-icons.ts +18 -0
- package/src/skill-metadata.ts +45 -0
- package/vendor/latex-review/dist/web/assets/index-CXdnUjct.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/current-commit.ts +104 -0
- package/vendor/latex-review/src/core/delivery.ts +62 -16
- package/vendor/latex-review/src/core/git.ts +2 -1
- package/vendor/latex-review/src/core/publish.ts +7 -1
- package/vendor/latex-review/src/core/reconcile.ts +101 -0
- package/vendor/latex-review/src/core/review.ts +6 -2
- package/vendor/latex-review/src/core/store.ts +1 -0
- package/vendor/latex-review/src/core/tasks.ts +1 -1
- 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 +77 -1
- package/vendor/latex-review/src/types.ts +130 -2
- 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 +263 -133
- package/vendor/latex-review/src/web/style.css +285 -0
- package/vendor/latex-review/src/web/zh-CN.ts +79 -2
- 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
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { deepestNode } from "./outline.ts";
|
|
3
|
+
import { compact, doiOf } from "./verify.ts";
|
|
4
|
+
import type {
|
|
5
|
+
Bibliography,
|
|
6
|
+
BibliographyEntry,
|
|
7
|
+
Heading,
|
|
8
|
+
MissingCitation,
|
|
9
|
+
ReferenceCitation,
|
|
10
|
+
ReferenceEntry,
|
|
11
|
+
ReferenceField,
|
|
12
|
+
ReferenceProblem,
|
|
13
|
+
References,
|
|
14
|
+
Span,
|
|
15
|
+
Unit,
|
|
16
|
+
} from "../types.ts";
|
|
17
|
+
|
|
18
|
+
// BibTeX entries as they are written, so that a review can show a .bib
|
|
19
|
+
// change entry by entry and check it. The reader follows BibTeX: text
|
|
20
|
+
// outside entries and @comment are ignored, @string macros resolve, and a
|
|
21
|
+
// value is {braced}, "quoted", a number or a macro name, joined with #.
|
|
22
|
+
|
|
23
|
+
interface BibSource {
|
|
24
|
+
file: string;
|
|
25
|
+
lines: Span;
|
|
26
|
+
}
|
|
27
|
+
type Macros = Map<string, { resolved: string; dependencies: BibSource[] }>;
|
|
28
|
+
export interface BibField {
|
|
29
|
+
name: string;
|
|
30
|
+
/** The value as written, without its outer braces or quotes. */
|
|
31
|
+
value: string;
|
|
32
|
+
/** The value with macros expanded and parts joined. */
|
|
33
|
+
resolved: string;
|
|
34
|
+
/** Source lines of @string definitions used here, including nested macros. */
|
|
35
|
+
dependencies?: BibSource[];
|
|
36
|
+
}
|
|
37
|
+
export interface BibEntry {
|
|
38
|
+
key: string;
|
|
39
|
+
type: string;
|
|
40
|
+
fields: BibField[];
|
|
41
|
+
/** First and last line of the entry, counted from 1. */
|
|
42
|
+
lines: Span;
|
|
43
|
+
/** Why the entry could not be read to its end. */
|
|
44
|
+
error?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const MONTHS: Record<string, string> = {
|
|
48
|
+
jan: "January",
|
|
49
|
+
feb: "February",
|
|
50
|
+
mar: "March",
|
|
51
|
+
apr: "April",
|
|
52
|
+
may: "May",
|
|
53
|
+
jun: "June",
|
|
54
|
+
jul: "July",
|
|
55
|
+
aug: "August",
|
|
56
|
+
sep: "September",
|
|
57
|
+
oct: "October",
|
|
58
|
+
nov: "November",
|
|
59
|
+
dec: "December",
|
|
60
|
+
};
|
|
61
|
+
// Line breaks and indentation inside a value are one space when typeset.
|
|
62
|
+
export const collapse = (text: string) => text.replace(/\s+/g, " ").trim();
|
|
63
|
+
|
|
64
|
+
class Unreadable extends Error {}
|
|
65
|
+
|
|
66
|
+
const defaultMacros = (): Macros => new Map(Object.entries(MONTHS).map(([name, resolved]) =>
|
|
67
|
+
[name, { resolved, dependencies: [] }],
|
|
68
|
+
));
|
|
69
|
+
|
|
70
|
+
export function parseBib(text: string, file = "", macros = defaultMacros()): BibEntry[] {
|
|
71
|
+
const lineAt = lines(text),
|
|
72
|
+
entries: BibEntry[] = [];
|
|
73
|
+
let at = 0;
|
|
74
|
+
const space = () => {
|
|
75
|
+
while (at < text.length && /\s/.test(text[at])) at++;
|
|
76
|
+
};
|
|
77
|
+
// From an opening brace to just after its matching close.
|
|
78
|
+
const braced = () => {
|
|
79
|
+
const start = at;
|
|
80
|
+
let depth = 0;
|
|
81
|
+
// BibTeX counts every brace, \{ included.
|
|
82
|
+
for (; at < text.length; at++) {
|
|
83
|
+
if (text[at] === "{") depth++;
|
|
84
|
+
else if (text[at] === "}" && --depth === 0) {
|
|
85
|
+
at++;
|
|
86
|
+
return text.slice(start + 1, at - 1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw new Unreadable("A brace is not closed.");
|
|
90
|
+
};
|
|
91
|
+
const quoted = () => {
|
|
92
|
+
const start = ++at;
|
|
93
|
+
let depth = 0;
|
|
94
|
+
for (; at < text.length; at++) {
|
|
95
|
+
if (text[at] === "{") depth++;
|
|
96
|
+
else if (text[at] === "}") depth--;
|
|
97
|
+
else if (text[at] === '"' && depth === 0) {
|
|
98
|
+
at++;
|
|
99
|
+
return text.slice(start, at - 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw new Unreadable("A quotation mark is not closed.");
|
|
103
|
+
};
|
|
104
|
+
const name = () => {
|
|
105
|
+
const match = /^[^\s=,{}()"#%]+/.exec(text.slice(at, at + 200));
|
|
106
|
+
if (!match) return "";
|
|
107
|
+
at += match[0].length;
|
|
108
|
+
return match[0];
|
|
109
|
+
};
|
|
110
|
+
const value = (): Omit<BibField, "name"> => {
|
|
111
|
+
const start = at,
|
|
112
|
+
parts: { text: string; resolved: string }[] = [],
|
|
113
|
+
dependencies: BibSource[] = [];
|
|
114
|
+
for (;;) {
|
|
115
|
+
space();
|
|
116
|
+
const c = text[at];
|
|
117
|
+
if (c === "{") {
|
|
118
|
+
const inner = braced();
|
|
119
|
+
parts.push({ text: inner, resolved: inner });
|
|
120
|
+
} else if (c === '"') {
|
|
121
|
+
const inner = quoted();
|
|
122
|
+
parts.push({ text: inner, resolved: inner });
|
|
123
|
+
} else {
|
|
124
|
+
const word = name();
|
|
125
|
+
if (!word) throw new Unreadable("A field has no value.");
|
|
126
|
+
const macro = /^\d+$/.test(word) ? undefined : macros.get(word.toLowerCase());
|
|
127
|
+
if (macro) dependencies.push(...macro.dependencies);
|
|
128
|
+
parts.push({
|
|
129
|
+
text: word,
|
|
130
|
+
resolved: macro?.resolved ?? word,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
space();
|
|
134
|
+
if (text[at] !== "#") break;
|
|
135
|
+
at++;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
value: collapse(parts.length === 1 ? parts[0].text : text.slice(start, at)),
|
|
139
|
+
resolved: collapse(parts.map((p) => p.resolved).join("")),
|
|
140
|
+
...(dependencies.length ? { dependencies: uniqueSources(dependencies) } : {}),
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
// `name = value` pairs up to the closing delimiter.
|
|
144
|
+
const fields = (close: string) => {
|
|
145
|
+
const result: BibField[] = [];
|
|
146
|
+
for (;;) {
|
|
147
|
+
space();
|
|
148
|
+
if (text[at] === close) {
|
|
149
|
+
at++;
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
if (text[at] === ",") {
|
|
153
|
+
at++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const field = name().toLowerCase();
|
|
157
|
+
if (!field) {
|
|
158
|
+
if (at >= text.length) throw new Unreadable("The entry is not closed.");
|
|
159
|
+
throw new Unreadable(`Unexpected “${text[at]}” in the entry.`);
|
|
160
|
+
}
|
|
161
|
+
space();
|
|
162
|
+
if (text[at] !== "=")
|
|
163
|
+
throw new Unreadable(`The field “${field}” has no “=”.`);
|
|
164
|
+
at++;
|
|
165
|
+
result.push({ name: field, ...value() });
|
|
166
|
+
space();
|
|
167
|
+
if (text[at] !== "," && text[at] !== close)
|
|
168
|
+
throw new Unreadable(`A comma is missing after the field “${field}”.`);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
for (;;) {
|
|
172
|
+
const sign = text.indexOf("@", at);
|
|
173
|
+
if (sign < 0) break;
|
|
174
|
+
at = sign + 1;
|
|
175
|
+
space();
|
|
176
|
+
const type = /^[A-Za-z]+/.exec(text.slice(at, at + 40))?.[0].toLowerCase();
|
|
177
|
+
if (!type) continue;
|
|
178
|
+
at += type.length;
|
|
179
|
+
space();
|
|
180
|
+
const open = text[at];
|
|
181
|
+
if (open !== "{" && open !== "(") continue;
|
|
182
|
+
const close = open === "{" ? "}" : ")";
|
|
183
|
+
if (type === "comment" || type === "preamble") {
|
|
184
|
+
try {
|
|
185
|
+
if (open === "{") braced();
|
|
186
|
+
else at = Math.max(text.indexOf(")", at), at) + 1;
|
|
187
|
+
} catch {
|
|
188
|
+
at = text.length;
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
at++;
|
|
193
|
+
if (type === "string") {
|
|
194
|
+
try {
|
|
195
|
+
for (const field of fields(close)) macros.set(field.name, {
|
|
196
|
+
resolved: field.resolved,
|
|
197
|
+
dependencies: uniqueSources([{ file, lines: [lineAt(sign), lineAt(at - 1)] }, ...(field.dependencies ?? [])]),
|
|
198
|
+
});
|
|
199
|
+
} catch {
|
|
200
|
+
/* A broken macro leaves its uses unresolved. */
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
space();
|
|
205
|
+
const keyStart = at;
|
|
206
|
+
while (at < text.length && !/[,\s]/.test(text[at]) && text[at] !== close) at++;
|
|
207
|
+
const entry: BibEntry = {
|
|
208
|
+
key: text.slice(keyStart, at),
|
|
209
|
+
type,
|
|
210
|
+
fields: [],
|
|
211
|
+
lines: [lineAt(sign), lineAt(sign)],
|
|
212
|
+
};
|
|
213
|
+
try {
|
|
214
|
+
entry.fields = fields(close);
|
|
215
|
+
entry.lines[1] = lineAt(at - 1);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (!(error instanceof Unreadable)) throw error;
|
|
218
|
+
entry.error = error.message;
|
|
219
|
+
// Read on from the next line that starts an entry.
|
|
220
|
+
const next = /\n[ \t]*@/g;
|
|
221
|
+
next.lastIndex = sign + 1;
|
|
222
|
+
const found = next.exec(text);
|
|
223
|
+
at = found ? found.index + 1 : text.length;
|
|
224
|
+
entry.lines[1] = lineAt(Math.max(at - 1, sign));
|
|
225
|
+
}
|
|
226
|
+
entries.push(entry);
|
|
227
|
+
}
|
|
228
|
+
return entries;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface EntryChange {
|
|
232
|
+
key: string;
|
|
233
|
+
oldKey?: string;
|
|
234
|
+
type: string;
|
|
235
|
+
oldType?: string;
|
|
236
|
+
status: "added" | "removed" | "modified";
|
|
237
|
+
fields: ReferenceField[];
|
|
238
|
+
/** Lines in the new file, or in the old one for a removed entry. */
|
|
239
|
+
lines: Span;
|
|
240
|
+
oldLines?: Span;
|
|
241
|
+
/** Entry and macro definition lines that can change its effective values. */
|
|
242
|
+
sources: BibSource[];
|
|
243
|
+
oldSources?: BibSource[];
|
|
244
|
+
error?: string;
|
|
245
|
+
}
|
|
246
|
+
const fieldMap = (entry: BibEntry) =>
|
|
247
|
+
new Map(entry.fields.map((f) => [f.name, f]));
|
|
248
|
+
// Every field of an entry pair, those of the new entry in its order first.
|
|
249
|
+
function fieldChanges(old: BibEntry | null, next: BibEntry | null) {
|
|
250
|
+
const before = old ? fieldMap(old) : new Map<string, BibField>(),
|
|
251
|
+
after = next ? fieldMap(next) : new Map<string, BibField>(),
|
|
252
|
+
names = [...after.keys(), ...[...before.keys()].filter((n) => !after.has(n))];
|
|
253
|
+
return names.map((name): ReferenceField => {
|
|
254
|
+
const a = before.get(name),
|
|
255
|
+
b = after.get(name);
|
|
256
|
+
return {
|
|
257
|
+
name,
|
|
258
|
+
old: a?.value ?? null,
|
|
259
|
+
new: b?.value ?? null,
|
|
260
|
+
...(a && a.resolved !== a.value ? { oldResolved: a.resolved } : {}),
|
|
261
|
+
...(b && b.resolved !== b.value ? { resolved: b.resolved } : {}),
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
const plainTitle = (entry: BibEntry) =>
|
|
266
|
+
(fieldMap(entry).get("title")?.resolved ?? "")
|
|
267
|
+
.toLowerCase()
|
|
268
|
+
.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
269
|
+
|
|
270
|
+
// The entries a change of one .bib file adds, removes or modifies. Entries
|
|
271
|
+
// pair by key; an added and a removed entry with the same title are one
|
|
272
|
+
// entry under a new key.
|
|
273
|
+
export function entryChanges(oldText: string, newText: string): EntryChange[] {
|
|
274
|
+
return compareEntries(parseBib(oldText), parseBib(newText), "");
|
|
275
|
+
}
|
|
276
|
+
function compareEntries(before: BibEntry[], after: BibEntry[], file: string): EntryChange[] {
|
|
277
|
+
const unpaired = new Map<string, BibEntry[]>();
|
|
278
|
+
for (const entry of before)
|
|
279
|
+
unpaired.set(entry.key, [...(unpaired.get(entry.key) ?? []), entry]);
|
|
280
|
+
const pairs: [BibEntry | null, BibEntry | null][] = [];
|
|
281
|
+
for (const entry of after) {
|
|
282
|
+
const old = unpaired.get(entry.key)?.shift() ?? null;
|
|
283
|
+
pairs.push([old, entry]);
|
|
284
|
+
}
|
|
285
|
+
const removed = [...unpaired.values()].flat();
|
|
286
|
+
for (const pair of pairs) {
|
|
287
|
+
if (pair[0]) continue;
|
|
288
|
+
const title = plainTitle(pair[1]!),
|
|
289
|
+
index = title
|
|
290
|
+
? removed.findIndex(
|
|
291
|
+
(old) => plainTitle(old) === title && old.type === pair[1]!.type,
|
|
292
|
+
)
|
|
293
|
+
: -1;
|
|
294
|
+
if (index >= 0) pair[0] = removed.splice(index, 1)[0];
|
|
295
|
+
}
|
|
296
|
+
for (const old of removed) pairs.push([old, null]);
|
|
297
|
+
const changes: EntryChange[] = [];
|
|
298
|
+
for (const [old, next] of pairs) {
|
|
299
|
+
const fields = fieldChanges(old, next),
|
|
300
|
+
entry = (next ?? old)!;
|
|
301
|
+
if (
|
|
302
|
+
old &&
|
|
303
|
+
next &&
|
|
304
|
+
old.key === next.key &&
|
|
305
|
+
old.type === next.type &&
|
|
306
|
+
old.error === next.error &&
|
|
307
|
+
fields.every((f) => f.old === f.new && (f.oldResolved ?? f.old) === (f.resolved ?? f.new))
|
|
308
|
+
)
|
|
309
|
+
continue;
|
|
310
|
+
changes.push({
|
|
311
|
+
key: entry.key,
|
|
312
|
+
...(old && next && old.key !== next.key ? { oldKey: old.key } : {}),
|
|
313
|
+
type: entry.type,
|
|
314
|
+
...(old && next && old.type !== next.type ? { oldType: old.type } : {}),
|
|
315
|
+
status: !old ? "added" : !next ? "removed" : "modified",
|
|
316
|
+
fields,
|
|
317
|
+
lines: entry.lines,
|
|
318
|
+
...(old && next ? { oldLines: old.lines } : {}),
|
|
319
|
+
sources: entrySources(entry, file),
|
|
320
|
+
...(old && next ? { oldSources: entrySources(old, file) } : {}),
|
|
321
|
+
...(next?.error ? { error: next.error } : {}),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return changes.sort(
|
|
325
|
+
(a, b) =>
|
|
326
|
+
(a.status === "removed" ? 1 : 0) - (b.status === "removed" ? 1 : 0) ||
|
|
327
|
+
a.lines[0] - b.lines[0],
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Line ranges [start, end) of each entry, counted from 0, for grouping a
|
|
332
|
+
// .bib diff's hunks by entry.
|
|
333
|
+
const uniqueSources = (sources: BibSource[]): BibSource[] =>
|
|
334
|
+
[...new Map(sources.map((source) => [JSON.stringify([source.file, source.lines]), source])).values()];
|
|
335
|
+
const entrySources = (entry: BibEntry, file: string) =>
|
|
336
|
+
uniqueSources([{ file, lines: entry.lines }, ...entry.fields.flatMap((f) => f.dependencies ?? [])]);
|
|
337
|
+
export function entrySpans(text: string) {
|
|
338
|
+
return parseBib(text).flatMap((e) => entrySources(e, "").map(({ lines: span }) => ({
|
|
339
|
+
key: e.key,
|
|
340
|
+
span: [span[0] - 1, span[1]] as Span,
|
|
341
|
+
})));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// TeX comments, blanked so offsets and line numbers stay.
|
|
345
|
+
export function uncomment(text: string) {
|
|
346
|
+
return text.replace(/(^|[^\\])((?:\\\\)*)%[^\n]*/g, (match, before, slashes) =>
|
|
347
|
+
before + slashes + " ".repeat(match.length - before.length - slashes.length),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
// Ignore comments and literal examples in one pass: a percent sign inside
|
|
351
|
+
// \verb is data, not the start of a comment. Preserve offsets and newlines.
|
|
352
|
+
function texSource(text: string) {
|
|
353
|
+
const parts: string[] = [],
|
|
354
|
+
tokens = /\\([A-Za-z]+)\*?|\\[^A-Za-z]|%[^\n]*/g,
|
|
355
|
+
literal = /^(?:verbatim\*?|Verbatim\*?|BVerbatim|LVerbatim|SaveVerbatim|lstlisting|minted|comment)$/;
|
|
356
|
+
let from = 0;
|
|
357
|
+
for (let token; (token = tokens.exec(text)); ) {
|
|
358
|
+
let end = tokens.lastIndex;
|
|
359
|
+
const command = token[1];
|
|
360
|
+
if (token[0].startsWith("%")) {
|
|
361
|
+
// The whole comment is already consumed.
|
|
362
|
+
} else if (command === "begin") {
|
|
363
|
+
const env = /^\s*\{([^}]+)\}/.exec(text.slice(end));
|
|
364
|
+
if (!env || !literal.test(env[1])) continue;
|
|
365
|
+
const close = new RegExp(String.raw`\\end\s*\{${env[1].replace("*", "\\*")}\}`, "g");
|
|
366
|
+
close.lastIndex = end + env[0].length;
|
|
367
|
+
const found = close.exec(text);
|
|
368
|
+
end = found ? close.lastIndex : text.length;
|
|
369
|
+
} else if (command === "verb" || command === "Verb" || command === "lstinline" || command === "mintinline") {
|
|
370
|
+
if (command !== "verb") end += /^\s*(?:\[[^\]]*\])?/.exec(text.slice(end))![0].length;
|
|
371
|
+
if (command === "mintinline") {
|
|
372
|
+
const language = /^\s*\{[^}]*\}\s*/.exec(text.slice(end));
|
|
373
|
+
if (!language) continue;
|
|
374
|
+
end += language[0].length;
|
|
375
|
+
}
|
|
376
|
+
const delimiter = text[end];
|
|
377
|
+
if (!delimiter || /\s/.test(delimiter)) continue;
|
|
378
|
+
if (delimiter === "{" && (command === "lstinline" || command === "mintinline")) {
|
|
379
|
+
let depth = 1;
|
|
380
|
+
while (++end < text.length && depth) {
|
|
381
|
+
if (text[end] === "{") depth++;
|
|
382
|
+
else if (text[end] === "}") depth--;
|
|
383
|
+
}
|
|
384
|
+
end = Math.min(end, text.length);
|
|
385
|
+
} else {
|
|
386
|
+
const close = text.indexOf(delimiter, end + 1), newline = text.indexOf("\n", end + 1);
|
|
387
|
+
end = close >= 0 && (newline < 0 || close < newline)
|
|
388
|
+
? close + 1 : newline >= 0 ? newline : text.length;
|
|
389
|
+
}
|
|
390
|
+
} else if (token[0] !== "\\\\") {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
parts.push(text.slice(from, token.index), text.slice(token.index, end).replace(/[^\n\r]/g, " "));
|
|
394
|
+
from = end;
|
|
395
|
+
tokens.lastIndex = end;
|
|
396
|
+
}
|
|
397
|
+
return parts.join("") + text.slice(from);
|
|
398
|
+
}
|
|
399
|
+
// The line of each offset, counted from 1.
|
|
400
|
+
function lines(text: string) {
|
|
401
|
+
const starts = [0];
|
|
402
|
+
for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1);
|
|
403
|
+
return (offset: number) => {
|
|
404
|
+
let low = 0,
|
|
405
|
+
high = starts.length - 1;
|
|
406
|
+
while (low < high) {
|
|
407
|
+
const mid = (low + high + 1) >> 1;
|
|
408
|
+
if (starts[mid] <= offset) low = mid;
|
|
409
|
+
else high = mid - 1;
|
|
410
|
+
}
|
|
411
|
+
return low + 1;
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
// Commands that print a citation or add one to the bibliography, in the
|
|
415
|
+
// forms of LaTeX, natbib and biblatex, including \cites{a}{b}.
|
|
416
|
+
const CITE = /\\([A-Za-z]*[cC]ite[A-Za-z]*|nocite)\*?(?![A-Za-z])/g;
|
|
417
|
+
const NOT_CITES = new Set(["citestyle", "citesetup", "citeindextrue", "citeindexfalse", "citename"]);
|
|
418
|
+
export function citations(text: string, file: string) {
|
|
419
|
+
const source = texSource(text),
|
|
420
|
+
lineAt = lines(source),
|
|
421
|
+
found: { key: string; file: string; line: number }[] = [];
|
|
422
|
+
for (const match of source.matchAll(CITE)) {
|
|
423
|
+
if (NOT_CITES.has(match[1])) continue;
|
|
424
|
+
let at = match.index + match[0].length;
|
|
425
|
+
const groups = match[1].endsWith("cites") ? Infinity : 1;
|
|
426
|
+
for (let taken = 0; taken < groups; ) {
|
|
427
|
+
const rest = source.slice(at),
|
|
428
|
+
options = /^\s*(?:\([^)]*\)\s*)*(?:\[[^\]]*\]\s*)*/.exec(rest)![0],
|
|
429
|
+
body = /^\{([^{}]*)\}/.exec(rest.slice(options.length));
|
|
430
|
+
if (!body) break;
|
|
431
|
+
const line = lineAt(at + options.length);
|
|
432
|
+
for (const key of body[1].split(",").map((k) => k.trim()).filter((k) => k && !/[\\#\s]/.test(k)))
|
|
433
|
+
found.push({ key, file, line });
|
|
434
|
+
at += options.length + body[0].length;
|
|
435
|
+
taken++;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return found;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// The document's files in reading order, from the main file through
|
|
442
|
+
// \input, \include, \subfile and \import, and the .bib files it names.
|
|
443
|
+
export function documentFiles(read: (file: string) => string | null, main: string) {
|
|
444
|
+
const base = path.posix.dirname(main),
|
|
445
|
+
files: string[] = [],
|
|
446
|
+
bibs: string[] = [];
|
|
447
|
+
const resolve = (name: string, dir = base, extension = ".tex") => {
|
|
448
|
+
name = name.trim();
|
|
449
|
+
if (!name || /[\\#]/.test(name)) return null;
|
|
450
|
+
const file = path.posix.normalize(path.posix.join(dir, name));
|
|
451
|
+
if (file.startsWith("../") || path.posix.isAbsolute(name)) return null;
|
|
452
|
+
return path.posix.extname(file) ? file : file + extension;
|
|
453
|
+
};
|
|
454
|
+
const walk = (file: string) => {
|
|
455
|
+
if (files.includes(file)) return;
|
|
456
|
+
const text = read(file);
|
|
457
|
+
if (text == null) return;
|
|
458
|
+
files.push(file);
|
|
459
|
+
const source = texSource(text),
|
|
460
|
+
here = path.posix.dirname(file);
|
|
461
|
+
for (const m of source.matchAll(
|
|
462
|
+
/\\(?:input|include|subfile|InputIfFileExists)\s*\{([^}]+)\}|\\(sub)?(?:import|inputfrom|includefrom)\*?\s*\{([^}]*)\}\s*\{([^}]+)\}|\\bibliography\s*\{([^}]+)\}|\\add(?:bibresource|globalbib|sectionbib)\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/g,
|
|
463
|
+
)) {
|
|
464
|
+
if (m[5] || m[6]) {
|
|
465
|
+
for (const name of m[5] ? m[5].split(",") : [m[6]]) {
|
|
466
|
+
const bib = resolve(name, base, ".bib");
|
|
467
|
+
if (bib && !bibs.includes(bib)) bibs.push(bib);
|
|
468
|
+
}
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const target = m[1]
|
|
472
|
+
? resolve(m[1])
|
|
473
|
+
: resolve(m[4], path.posix.join(m[2] ? here : base, m[3]));
|
|
474
|
+
if (target) walk(target);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
walk(main);
|
|
478
|
+
return { files, bibs };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** A built document as the reference check reads it. */
|
|
482
|
+
export interface Document {
|
|
483
|
+
read(file: string): string | null;
|
|
484
|
+
main: string;
|
|
485
|
+
outline: Heading[];
|
|
486
|
+
}
|
|
487
|
+
function citationsOf(doc: Document) {
|
|
488
|
+
const { files, bibs } = documentFiles((f) => doc.read(f), doc.main),
|
|
489
|
+
cites = files.flatMap((file) => citations(doc.read(file) ?? "", file)),
|
|
490
|
+
entries = bibliographyEntries(doc.read.bind(doc), bibs),
|
|
491
|
+
keys = new Map<string, number>();
|
|
492
|
+
for (const values of entries.values())
|
|
493
|
+
for (const entry of values)
|
|
494
|
+
keys.set(entry.key, (keys.get(entry.key) ?? 0) + 1);
|
|
495
|
+
return { cites, bibs, entries, keys, all: cites.some((c) => c.key === "*") };
|
|
496
|
+
}
|
|
497
|
+
// BibTeX reads databases in declaration order, retaining @string definitions
|
|
498
|
+
// for subsequent files. Keep the defining file with every dependency.
|
|
499
|
+
function bibliographyEntries(read: (file: string) => string | null, bibs: string[]) {
|
|
500
|
+
const macros = defaultMacros();
|
|
501
|
+
return new Map(bibs.map((file) => [file, parseBib(read(file) ?? "", file, macros)]));
|
|
502
|
+
}
|
|
503
|
+
const sectionOf = (doc: Document, file: string, line: number) => {
|
|
504
|
+
const node = deepestNode(doc.outline, file, line, line);
|
|
505
|
+
return node ? `${node.number} ${node.title}`.trim() : null;
|
|
506
|
+
};
|
|
507
|
+
const meets = (a: Span, b: Span) => a[0] <= b[1] && b[0] <= a[1];
|
|
508
|
+
// A grouped macro edit can span unrelated entries. Match its actual edits,
|
|
509
|
+
// not the envelope between the first and last hunk.
|
|
510
|
+
function changesSources(unit: Unit, sources: BibSource[], side: "old" | "new") {
|
|
511
|
+
const own = sources.filter((source) => source.file === unit.file);
|
|
512
|
+
if (unit.whole_file) return own.length > 0;
|
|
513
|
+
const ranges: Span[] = unit.ops.length
|
|
514
|
+
? unit.ops.map((op) => side === "old" ? [op[1], op[2]] : [op[3], op[4]])
|
|
515
|
+
: [side === "old" ? [unit.old_start - 1, unit.old_end] : [unit.new_start - 1, unit.new_end]];
|
|
516
|
+
return own.some(({ lines: [start, end] }) => ranges.some(([a, b]) =>
|
|
517
|
+
a === b ? start - 1 < a && a < end : a < end && start - 1 < b,
|
|
518
|
+
));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// What a round changes in the bibliography: each added, removed or modified
|
|
522
|
+
// entry under the edit it belongs to, where the revised document cites it,
|
|
523
|
+
// and the keys it newly cites without an entry.
|
|
524
|
+
export function referenceChanges(
|
|
525
|
+
units: Unit[],
|
|
526
|
+
old: Document,
|
|
527
|
+
next: Document,
|
|
528
|
+
): References | undefined {
|
|
529
|
+
const bibUnits = units.filter((u) => u.file.endsWith(".bib") && u.kind === "text");
|
|
530
|
+
const before = citationsOf(old),
|
|
531
|
+
after = citationsOf(next),
|
|
532
|
+
entries: ReferenceEntry[] = [];
|
|
533
|
+
for (const file of new Set([...bibUnits.map((u) => u.file), ...before.bibs, ...after.bibs])) {
|
|
534
|
+
const counts = new Map<string, number>(),
|
|
535
|
+
a = before.entries.get(file) ?? parseBib(old.read(file) ?? "", file),
|
|
536
|
+
b = after.entries.get(file) ?? parseBib(next.read(file) ?? "", file);
|
|
537
|
+
for (const change of compareEntries(a, b, file)) {
|
|
538
|
+
const affected = bibUnits.filter((u) =>
|
|
539
|
+
change.status === "removed"
|
|
540
|
+
? changesSources(u, change.sources, "old")
|
|
541
|
+
: changesSources(u, change.sources, "new") ||
|
|
542
|
+
(change.oldSources !== undefined && changesSources(u, change.oldSources, "old")),
|
|
543
|
+
);
|
|
544
|
+
if (!affected.length) continue;
|
|
545
|
+
const n = (counts.get(change.key) ?? 0) + 1;
|
|
546
|
+
counts.set(change.key, n);
|
|
547
|
+
const cited = (key: string) =>
|
|
548
|
+
after.cites
|
|
549
|
+
.filter((c) => c.key === key)
|
|
550
|
+
.map(
|
|
551
|
+
(c): ReferenceCitation => ({
|
|
552
|
+
file: c.file,
|
|
553
|
+
line: c.line,
|
|
554
|
+
section: sectionOf(next, c.file, c.line),
|
|
555
|
+
}),
|
|
556
|
+
),
|
|
557
|
+
problems: ReferenceProblem[] = [];
|
|
558
|
+
if (change.error) problems.push("unreadable");
|
|
559
|
+
if (change.status !== "removed" && (after.keys.get(change.key) ?? 0) > 1)
|
|
560
|
+
problems.push("duplicate-key");
|
|
561
|
+
const gone = change.status === "removed" ? change.key : change.oldKey;
|
|
562
|
+
if (gone && !after.keys.has(gone) && cited(gone).length)
|
|
563
|
+
problems.push("still-cited");
|
|
564
|
+
if (after.bibs.length && !after.bibs.includes(file))
|
|
565
|
+
problems.push("not-in-bibliography");
|
|
566
|
+
entries.push({
|
|
567
|
+
id: `${file}#${change.key}${n > 1 ? `#${n}` : ""}`,
|
|
568
|
+
unit: affected[0].id,
|
|
569
|
+
...(affected.length > 1 ? { units: affected.map((u) => u.id) } : {}),
|
|
570
|
+
file,
|
|
571
|
+
key: change.key,
|
|
572
|
+
...(change.oldKey ? { old_key: change.oldKey } : {}),
|
|
573
|
+
type: change.type,
|
|
574
|
+
...(change.oldType ? { old_type: change.oldType } : {}),
|
|
575
|
+
status: change.status,
|
|
576
|
+
fields: change.fields,
|
|
577
|
+
lines: change.lines,
|
|
578
|
+
cited: cited(change.key),
|
|
579
|
+
...(gone && gone !== change.key ? { old_cited: cited(gone) } : {}),
|
|
580
|
+
problems,
|
|
581
|
+
...(change.error ? { error: change.error } : {}),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// Keys the revised document cites with no entry in its bibliography, if
|
|
586
|
+
// it names one and did not already lack them.
|
|
587
|
+
const missing: MissingCitation[] = [];
|
|
588
|
+
if (after.bibs.length) {
|
|
589
|
+
// A key whose entry this round removes or renames is shown with it.
|
|
590
|
+
const lacking = new Set([
|
|
591
|
+
...before.cites.filter((c) => !before.keys.has(c.key)).map((c) => c.key),
|
|
592
|
+
...entries.flatMap((e) => (e.status === "removed" ? [e.key] : e.old_key ? [e.old_key] : [])),
|
|
593
|
+
]);
|
|
594
|
+
for (const cite of after.cites) {
|
|
595
|
+
if (cite.key === "*" || after.keys.has(cite.key) || lacking.has(cite.key))
|
|
596
|
+
continue;
|
|
597
|
+
const unit = units.find(
|
|
598
|
+
(u) =>
|
|
599
|
+
u.file === cite.file &&
|
|
600
|
+
u.status !== "deleted" &&
|
|
601
|
+
meets([cite.line, cite.line], [u.new_start, u.new_end]),
|
|
602
|
+
);
|
|
603
|
+
missing.push({
|
|
604
|
+
key: cite.key,
|
|
605
|
+
file: cite.file,
|
|
606
|
+
line: cite.line,
|
|
607
|
+
unit: unit?.id ?? null,
|
|
608
|
+
section: sectionOf(next, cite.file, cite.line),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (!entries.length && !missing.length) return undefined;
|
|
613
|
+
return { entries, missing };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// The paper's whole bibliography as it is, for a panel that checks it
|
|
617
|
+
// rather than a round's changes.
|
|
618
|
+
export function bibliographyReport(doc: Document): Bibliography {
|
|
619
|
+
const read = (file: string) => doc.read(file),
|
|
620
|
+
{ files, bibs } = documentFiles(read, doc.main),
|
|
621
|
+
parsed = bibliographyEntries(read, bibs),
|
|
622
|
+
cites = files.flatMap((file) => citations(read(file) ?? "", file)),
|
|
623
|
+
place = (c: { file: string; line: number }): ReferenceCitation => ({
|
|
624
|
+
file: c.file,
|
|
625
|
+
line: c.line,
|
|
626
|
+
section: sectionOf(doc, c.file, c.line),
|
|
627
|
+
}),
|
|
628
|
+
entries: BibliographyEntry[] = [],
|
|
629
|
+
absent: string[] = [],
|
|
630
|
+
counts = new Map<string, number>();
|
|
631
|
+
for (const file of bibs) {
|
|
632
|
+
const text = read(file);
|
|
633
|
+
if (text == null) {
|
|
634
|
+
absent.push(file);
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
for (const entry of parsed.get(file)!) {
|
|
638
|
+
const n = (counts.get(`${file}#${entry.key}`) ?? 0) + 1;
|
|
639
|
+
counts.set(`${file}#${entry.key}`, n);
|
|
640
|
+
entries.push({
|
|
641
|
+
id: `${file}#${entry.key}${n > 1 ? `#${n}` : ""}`,
|
|
642
|
+
file,
|
|
643
|
+
key: entry.key,
|
|
644
|
+
type: entry.type,
|
|
645
|
+
fields: entry.fields.map((f) => ({
|
|
646
|
+
name: f.name,
|
|
647
|
+
value: f.value,
|
|
648
|
+
...(f.resolved !== f.value ? { resolved: f.resolved } : {}),
|
|
649
|
+
})),
|
|
650
|
+
lines: entry.lines,
|
|
651
|
+
cited: cites.filter((c) => c.key === entry.key).map(place),
|
|
652
|
+
...(entry.error ? { error: entry.error } : {}),
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const keys = new Set(entries.map((e) => e.key)),
|
|
657
|
+
group = (value: (e: BibliographyEntry) => string) => {
|
|
658
|
+
const groups = new Map<string, BibliographyEntry[]>();
|
|
659
|
+
for (const entry of entries) {
|
|
660
|
+
const v = value(entry);
|
|
661
|
+
if (v) groups.set(v, [...(groups.get(v) ?? []), entry]);
|
|
662
|
+
}
|
|
663
|
+
return [...groups.values()].filter((list) => list.length > 1);
|
|
664
|
+
},
|
|
665
|
+
field = (e: BibliographyEntry, name: string) => {
|
|
666
|
+
const f = e.fields.find((x) => x.name === name);
|
|
667
|
+
return f ? (f.resolved ?? f.value) : "";
|
|
668
|
+
},
|
|
669
|
+
titled = (e: BibliographyEntry) => {
|
|
670
|
+
const title = compact(field(e, "title"));
|
|
671
|
+
return title.length >= 15 ? title : "";
|
|
672
|
+
},
|
|
673
|
+
distinct = (list: BibliographyEntry[]) => new Set(list.map((e) => e.key)).size > 1;
|
|
674
|
+
return {
|
|
675
|
+
bibs,
|
|
676
|
+
absent,
|
|
677
|
+
entries,
|
|
678
|
+
missing: bibs.length
|
|
679
|
+
? cites.filter((c) => c.key !== "*" && !keys.has(c.key)).map((c) => ({ key: c.key, ...place(c) }))
|
|
680
|
+
: [],
|
|
681
|
+
duplicates: group((e) => e.key).map((list) => list.map((e) => e.id)),
|
|
682
|
+
similar: [
|
|
683
|
+
...group((e) => (doiOf(Object.fromEntries(e.fields.map((f) => [f.name, f.resolved ?? f.value]))) ?? "").toLowerCase())
|
|
684
|
+
.filter(distinct)
|
|
685
|
+
.map((list) => ({ ids: list.map((e) => e.id), by: "doi" as const })),
|
|
686
|
+
...group(titled)
|
|
687
|
+
.filter(distinct)
|
|
688
|
+
.map((list) => ({ ids: list.map((e) => e.id), by: "title" as const })),
|
|
689
|
+
],
|
|
690
|
+
all_cited: cites.some((c) => c.key === "*"),
|
|
691
|
+
};
|
|
692
|
+
}
|