papilot 0.8.0 → 0.8.2
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.de.md +110 -0
- package/README.es.md +110 -0
- package/README.fr.md +110 -0
- package/README.it.md +110 -0
- package/README.ja.md +110 -0
- package/README.ko.md +110 -0
- package/README.md +54 -121
- package/README.pt.md +110 -0
- package/README.sv.md +110 -0
- package/README.zh-CN.md +58 -117
- package/README.zh-Hant.md +110 -0
- package/bin/doctor.mjs +7 -5
- package/dist/assets/{_virtual_papilot-licenses-D7ro6ecN.js → _virtual_papilot-licenses-CMfCtEYQ.js} +1 -1
- package/dist/assets/index-oAIkxhfp.js +265 -0
- package/dist/assets/index-t_qCOF_B.css +1 -0
- package/dist/assets/{math-V9uUaUzA.js → math-CEU4m4-H.js} +1 -1
- package/dist/assets/{pdf-view-Bnb2uIBE.js → pdf-view-HWRGBLsZ.js} +1 -1
- package/dist/index.html +2 -2
- package/examples/papilot-paper/preview/main.pdf +0 -0
- package/examples/papilot-paper/preview/main.synctex.gz +0 -0
- package/package.json +18 -5
- package/src/server/acp.ts +44 -7
- package/src/server/agents.ts +5 -3
- package/src/server/app.ts +10 -3
- package/src/server/desktop-demo.ts +38 -0
- package/src/server/imports.ts +60 -21
- package/src/server/manuscript.ts +44 -8
- package/src/server/reconcile.ts +20 -0
- package/src/server/review.ts +3 -2
- package/src/server/start.ts +26 -4
- package/src/server/store.ts +0 -8
- package/src/server/update-install.ts +22 -2
- package/src/server/updates.ts +32 -12
- package/src/server/workspace.ts +78 -15
- package/src/shared.ts +20 -9
- package/src/skill-metadata.ts +1 -1
- package/vendor/latex-review/dist/web/assets/index-DVs7cTBl.js +19 -0
- package/vendor/latex-review/dist/web/assets/index-ysOpu05D.css +1 -0
- package/vendor/latex-review/dist/web/index.html +2 -2
- package/vendor/latex-review/src/core/build.ts +180 -97
- 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 +135 -29
- package/vendor/latex-review/src/core/outline.ts +0 -9
- 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 +5 -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/locales.ts +47 -0
- package/vendor/latex-review/src/server.ts +12 -1
- package/vendor/latex-review/src/types.ts +5 -2
- package/vendor/latex-review/src/web/i18n.ts +9 -21
- package/vendor/latex-review/src/web/locales/de.json +250 -0
- package/vendor/latex-review/src/web/locales/es.json +250 -0
- package/vendor/latex-review/src/web/locales/fr.json +250 -0
- package/vendor/latex-review/src/web/locales/index.ts +16 -0
- package/vendor/latex-review/src/web/locales/it.json +250 -0
- package/vendor/latex-review/src/web/locales/ja.json +250 -0
- package/vendor/latex-review/src/web/locales/ko.json +250 -0
- package/vendor/latex-review/src/web/locales/pt.json +250 -0
- package/vendor/latex-review/src/web/locales/sv.json +250 -0
- package/vendor/latex-review/src/web/locales/zh-Hant.json +250 -0
- package/vendor/latex-review/src/web/references.tsx +1 -1
- package/vendor/latex-review/src/web/review.tsx +8 -6
- package/vendor/latex-review/src/web/style.css +1 -11
- package/vendor/latex-review/src/web/zh-CN.ts +4 -2
- package/dist/assets/index-Cc-awdNn.css +0 -1
- package/dist/assets/index-D_VX51no.js +0 -265
- package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +0 -19
- package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
4
5
|
import { run, which } from "./process.ts";
|
|
5
6
|
import { same } from "./files.ts";
|
|
6
7
|
const IDENTITY = {
|
|
@@ -128,6 +129,14 @@ export class Repo {
|
|
|
128
129
|
.split("\0")
|
|
129
130
|
.filter(Boolean);
|
|
130
131
|
}
|
|
132
|
+
entries(rev: string, folder = "") {
|
|
133
|
+
return this.run([
|
|
134
|
+
"ls-tree", "-r", "-l", "-z", rev, ...(folder ? ["--", folder] : []),
|
|
135
|
+
]).toString().split("\0").filter(Boolean).flatMap(entry => {
|
|
136
|
+
const tab = entry.indexOf("\t"), [mode, type, oid, size] = entry.slice(0, tab).split(/ +/);
|
|
137
|
+
return type === "blob" ? [{ mode, oid, size: Number(size), file: entry.slice(tab + 1) }] : [];
|
|
138
|
+
});
|
|
139
|
+
}
|
|
131
140
|
changedFiles(before: string, after: string) {
|
|
132
141
|
return this.run([
|
|
133
142
|
"diff",
|
|
@@ -261,42 +270,139 @@ export class Repo {
|
|
|
261
270
|
return this.text(["write-tree"], { env });
|
|
262
271
|
});
|
|
263
272
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
273
|
+
// Write a revision's files under destination. Given an earlier export of
|
|
274
|
+
// another tree, files with the same Git object are cloned from it
|
|
275
|
+
// (copy-on-write where the file system supports it), so only changed files
|
|
276
|
+
// are read from Git.
|
|
277
|
+
async export(rev: string, folder: string, destination: string, base?: { root: string; tree: string }) {
|
|
278
|
+
const previous = new Map<string, string>();
|
|
279
|
+
if (base) for (const entry of this.entries(base.tree)) previous.set(entry.file, entry.mode + " " + entry.oid);
|
|
280
|
+
const wanted = this.entries(rev, folder).map(entry => {
|
|
281
|
+
const relative = path.posix.relative(folder || ".", entry.file);
|
|
271
282
|
cleanPath(relative);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
283
|
+
return { ...entry, relative, target: path.join(destination, relative) };
|
|
284
|
+
});
|
|
285
|
+
// On APFS, cp clones a whole folder far faster than creating each file
|
|
286
|
+
// separately; files that are not in this tree are then removed again.
|
|
287
|
+
let cloned = false;
|
|
288
|
+
if (base && process.platform === "darwin" && !fs.existsSync(destination))
|
|
289
|
+
try {
|
|
290
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
291
|
+
cloned = run("/bin/cp", ["-c", "-R", base.root, destination], { check: false }).status === 0;
|
|
292
|
+
}
|
|
293
|
+
catch { /* Copy file by file below. */ }
|
|
294
|
+
if (cloned) {
|
|
295
|
+
const keep = new Set(wanted.map(entry => entry.relative));
|
|
296
|
+
(function prune(directory: string, prefix: string) {
|
|
297
|
+
for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
298
|
+
const relative = prefix + item.name, file = path.join(directory, item.name);
|
|
299
|
+
if (item.isDirectory()) prune(file, relative + "/");
|
|
300
|
+
else if (!keep.has(relative)) fs.rmSync(file, { force: true });
|
|
301
|
+
}
|
|
302
|
+
})(destination, "");
|
|
303
|
+
} else {
|
|
304
|
+
fs.rmSync(destination, { recursive: true, force: true });
|
|
305
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
306
|
+
}
|
|
307
|
+
const folders = new Set<string>(), entries: typeof wanted = [];
|
|
308
|
+
for (const entry of wanted) {
|
|
309
|
+
const { relative, target } = entry;
|
|
310
|
+
if (!folders.has(path.dirname(target))) { fs.mkdirSync(path.dirname(target), { recursive: true }); folders.add(path.dirname(target)); }
|
|
311
|
+
if (entry.mode !== "120000" && previous.get(relative) === entry.mode + " " + entry.oid) {
|
|
312
|
+
const source = cloned ? target : path.join(base!.root, relative);
|
|
313
|
+
try {
|
|
314
|
+
// A compiler may have rewritten a file in the earlier export.
|
|
315
|
+
const stat = fs.lstatSync(source);
|
|
316
|
+
if (stat.isFile() && stat.size === entry.size) {
|
|
317
|
+
if (!cloned) fs.copyFileSync(source, target, fs.constants.COPYFILE_FICLONE);
|
|
318
|
+
if ((stat.mode & 0o777) !== (entry.mode === "100755" ? 0o755 : 0o644)) fs.chmodSync(target, entry.mode === "100755" ? 0o755 : 0o644);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
} catch { /* Read this file from Git. */ }
|
|
322
|
+
}
|
|
323
|
+
// A cloned link or rewritten file is replaced, never written through.
|
|
324
|
+
if (cloned) fs.rmSync(target, { force: true });
|
|
325
|
+
entries.push(entry);
|
|
326
|
+
}
|
|
327
|
+
if (!entries.length) return;
|
|
328
|
+
// Keep one Git process and stream each blob to disk. Collecting the entire
|
|
329
|
+
// snapshot with blobs() both duplicates memory and hits spawnSync's buffer
|
|
330
|
+
// limit for ordinary repositories containing PDFs and figures.
|
|
331
|
+
const child = spawn(gitBinary(), ["cat-file", "--batch"], {
|
|
332
|
+
cwd: this.root, stdio: ["pipe", "pipe", "pipe"],
|
|
333
|
+
});
|
|
334
|
+
let stderr = "", timedOut = false;
|
|
335
|
+
child.stderr.on("data", (data: Buffer) => { stderr = (stderr + data.toString()).slice(-65536); });
|
|
336
|
+
child.stdin.on("error", () => { /* Report process failures through finished. */ });
|
|
337
|
+
const finished = new Promise<Error | null>(resolve => {
|
|
338
|
+
child.once("error", resolve);
|
|
339
|
+
child.once("close", code => resolve(code === 0 && !timedOut ? null : new GitError(
|
|
340
|
+
timedOut ? "Git snapshot export timed out." : stderr.trim() || `Git snapshot export failed (${code}).`,
|
|
341
|
+
)));
|
|
342
|
+
});
|
|
343
|
+
const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, 300000);
|
|
344
|
+
const output = child.stdout[Symbol.asyncIterator]();
|
|
345
|
+
let chunk: Buffer = Buffer.alloc(0), offset = 0;
|
|
346
|
+
const available = async () => {
|
|
347
|
+
if (offset < chunk.length) return;
|
|
348
|
+
const next = await output.next();
|
|
349
|
+
if (next.done) throw await finished || new GitError("Git snapshot export ended before a file was complete.");
|
|
350
|
+
chunk = next.value; offset = 0;
|
|
351
|
+
};
|
|
352
|
+
const header = async () => {
|
|
353
|
+
const parts: Buffer[] = [];
|
|
354
|
+
while (true) {
|
|
355
|
+
await available();
|
|
356
|
+
const end = chunk.indexOf(10, offset);
|
|
357
|
+
parts.push(chunk.subarray(offset, end < 0 ? chunk.length : end));
|
|
358
|
+
offset = end < 0 ? chunk.length : end + 1;
|
|
359
|
+
if (end >= 0) return Buffer.concat(parts).toString();
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
const links: [string, string][] = [];
|
|
363
|
+
try {
|
|
364
|
+
for (const { file, mode, oid, target } of entries) {
|
|
365
|
+
child.stdin.write(oid + "\n");
|
|
366
|
+
const [object, type, size] = (await header()).split(" ");
|
|
367
|
+
let remaining = Number(size);
|
|
368
|
+
if (object !== oid || type !== "blob" || !Number.isSafeInteger(remaining) || remaining < 0)
|
|
369
|
+
throw new GitError(`Git snapshot export could not read: ${file}`);
|
|
370
|
+
const linkParts: Buffer[] = [], fd = mode === "120000" ? null : fs.openSync(target, "w", mode === "100755" ? 0o755 : 0o644);
|
|
371
|
+
try {
|
|
372
|
+
while (remaining) {
|
|
373
|
+
await available();
|
|
374
|
+
const length = Math.min(remaining, chunk.length - offset), data = chunk.subarray(offset, offset + length);
|
|
375
|
+
if (fd === null) linkParts.push(Buffer.from(data));
|
|
376
|
+
else fs.writeFileSync(fd, data);
|
|
377
|
+
remaining -= length; offset += length;
|
|
378
|
+
}
|
|
379
|
+
} finally { if (fd !== null) fs.closeSync(fd); }
|
|
380
|
+
await available();
|
|
381
|
+
if (chunk[offset++] !== 10) throw new GitError(`Git snapshot export returned an invalid file boundary: ${file}`);
|
|
382
|
+
if (mode === "120000") {
|
|
383
|
+
const link = Buffer.concat(linkParts).toString(), resolved = path.resolve(path.dirname(target), link);
|
|
384
|
+
if (path.isAbsolute(link) || !resolved.startsWith(path.resolve(destination) + path.sep))
|
|
385
|
+
throw new GitError(`A link in the snapshot points outside the document: ${file}`);
|
|
386
|
+
links.push([target, link]);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
child.stdin.end();
|
|
390
|
+
if (!(await output.next()).done) throw new GitError("Git snapshot export returned unexpected output.");
|
|
391
|
+
const failure = await finished;
|
|
392
|
+
if (failure) throw failure;
|
|
393
|
+
for (const [target, link] of links) fs.symlinkSync(link, target);
|
|
394
|
+
} finally {
|
|
395
|
+
clearTimeout(timer);
|
|
396
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
397
|
+
await finished;
|
|
292
398
|
}
|
|
293
|
-
for (const [target, link] of links) fs.symlinkSync(link, target);
|
|
294
399
|
}
|
|
295
400
|
}
|
|
296
401
|
export function merge3(
|
|
297
402
|
ours: Buffer | null,
|
|
298
403
|
base: Buffer | null,
|
|
299
404
|
theirs: Buffer | null,
|
|
405
|
+
favorCurrent = false,
|
|
300
406
|
): [Buffer | null, boolean] {
|
|
301
407
|
if (same(ours, base) || same(ours, theirs)) return [theirs, false];
|
|
302
408
|
if (same(theirs, base)) return [ours, false];
|
|
@@ -312,7 +418,7 @@ export function merge3(
|
|
|
312
418
|
path.join(folder, name),
|
|
313
419
|
);
|
|
314
420
|
[ours, base, theirs].forEach((data, i) => fs.writeFileSync(files[i], data));
|
|
315
|
-
const result = run(gitBinary(), ["merge-file", "-p", ...files], {
|
|
421
|
+
const result = run(gitBinary(), ["merge-file", "-p", ...(favorCurrent ? ["--ours"] : []), ...files], {
|
|
316
422
|
check: false,
|
|
317
423
|
});
|
|
318
424
|
return [result.stdout, result.status !== 0];
|
|
@@ -247,15 +247,6 @@ export function buildOutline(
|
|
|
247
247
|
.slice(0, 20),
|
|
248
248
|
};
|
|
249
249
|
}
|
|
250
|
-
export function overlaps(a: Ranges, b: Ranges) {
|
|
251
|
-
return Object.keys(a).some(
|
|
252
|
-
(file) =>
|
|
253
|
-
b[file] &&
|
|
254
|
-
a[file].some(([x, y]) =>
|
|
255
|
-
b[file].some(([u, v]) => Math.max(x, u) <= Math.min(y, v)),
|
|
256
|
-
),
|
|
257
|
-
);
|
|
258
|
-
}
|
|
259
250
|
export function selectedRanges(
|
|
260
251
|
nodes: Heading[],
|
|
261
252
|
ids: string[],
|
|
@@ -321,6 +321,7 @@ export class Publisher {
|
|
|
321
321
|
const app = this.app,
|
|
322
322
|
name = job.task,
|
|
323
323
|
project = app.project;
|
|
324
|
+
if (app.reconciliation.get(name)?.ready === false) throw new Error("Retry applying to finish preparing the merge review.");
|
|
324
325
|
let task = app.tasks.get(name);
|
|
325
326
|
if (!fs.existsSync(task.worktree))
|
|
326
327
|
throw new PublishError(
|
|
@@ -335,13 +336,18 @@ export class Publisher {
|
|
|
335
336
|
latest = app.review.latestRound(name),
|
|
336
337
|
decided = app.review.lastChange(name),
|
|
337
338
|
violations = scopeViolations(app.repo, task.base, after, task.scope);
|
|
339
|
+
const reconciliation = app.reconciliation.get(name);
|
|
340
|
+
if (reconciliation) {
|
|
341
|
+
for (const file of app.repo.changedFiles(task.base, after))
|
|
342
|
+
if (!reconciliation.files.includes(file) && !violations.includes(file)) violations.push(file);
|
|
343
|
+
}
|
|
338
344
|
if (violations.length)
|
|
339
345
|
throw new PublishError(
|
|
340
346
|
"Some edits are outside the task scope: " +
|
|
341
347
|
violations.slice(0, 10).join(", "),
|
|
342
348
|
[
|
|
343
349
|
"Allowed: " +
|
|
344
|
-
(task.scope.sections.length
|
|
350
|
+
(reconciliation ? reconciliation.files : task.scope.sections.length
|
|
345
351
|
? task.scope.sections.map((s) => `${s.number} ${s.title}`)
|
|
346
352
|
: task.scope.paths
|
|
347
353
|
).join(", "),
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { App } from "../server.ts";
|
|
3
|
+
import { merge3 } from "./git.ts";
|
|
4
|
+
import { readBytes, same, writeOrDelete } from "./files.ts";
|
|
5
|
+
import { safePath } from "./review.ts";
|
|
6
|
+
import { GENERATED, revisionReader } from "./tasks.ts";
|
|
7
|
+
import { buildOutline, resolveSections, selectedRanges } from "./outline.ts";
|
|
8
|
+
|
|
9
|
+
export interface Reconciliation {
|
|
10
|
+
id: string;
|
|
11
|
+
round: number;
|
|
12
|
+
files: string[];
|
|
13
|
+
sourceBase: string;
|
|
14
|
+
sourceAccepted: string;
|
|
15
|
+
base: string;
|
|
16
|
+
reviewBase: string;
|
|
17
|
+
worktreeBefore: string;
|
|
18
|
+
ready: boolean;
|
|
19
|
+
intent?: { mode: "workspace" | "commit"; message: string };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A conflict is another proposal in the same task. The manuscript and its
|
|
23
|
+
// index stay untouched; only the private proposal is rebased for PDF review.
|
|
24
|
+
export class Reconciliations {
|
|
25
|
+
constructor(public app: App) {}
|
|
26
|
+
get(name: string) {
|
|
27
|
+
return this.app.store.one<{ data: Reconciliation }>("SELECT data FROM reconciliations WHERE task=?", name)?.data;
|
|
28
|
+
}
|
|
29
|
+
intent(name: string, intent: NonNullable<Reconciliation["intent"]>) {
|
|
30
|
+
const data = { ...this.get(name)!, intent };
|
|
31
|
+
this.app.store.execute("UPDATE reconciliations SET data=? WHERE task=?", JSON.stringify(data), name);
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
34
|
+
clear(name: string) {
|
|
35
|
+
this.app.store.execute("DELETE FROM reconciliations WHERE task=?", name);
|
|
36
|
+
}
|
|
37
|
+
start(name: string, force = false, intent?: Reconciliation["intent"]) {
|
|
38
|
+
const app = this.app, task = app.tasks.get(name), previous = this.get(name);
|
|
39
|
+
if (previous && (!previous.ready || (!force && (app.review.latestRound(name)?.number ?? 0) <= previous.round)))
|
|
40
|
+
return this.prepare(name, previous);
|
|
41
|
+
const worktreeBefore = app.tasks.snapshot(task);
|
|
42
|
+
if (app.repo.changedFiles(task.accepted, worktreeBefore).length)
|
|
43
|
+
throw new Error("Review the latest task edits before applying the manuscript changes.");
|
|
44
|
+
const delivered = app.delivery.get(name);
|
|
45
|
+
const sourceBase = previous?.base ?? (delivered && delivered.mode !== "branch" ? delivered.accepted : task.base);
|
|
46
|
+
const base = app.repo.snapshot([app.project.source_dir, ...task.scope.paths], GENERATED);
|
|
47
|
+
const changed = app.repo.changedFiles(sourceBase, task.accepted);
|
|
48
|
+
const old = app.repo.blobs(sourceBase, changed), accepted = app.repo.blobs(task.accepted, changed), current = app.repo.blobs(base, changed);
|
|
49
|
+
const files: string[] = [], contents: Record<string, Buffer | null> = {};
|
|
50
|
+
for (const file of changed) {
|
|
51
|
+
const [merged, conflict] = merge3(current[file], old[file], accepted[file]);
|
|
52
|
+
if (!conflict) contents[file] = merged;
|
|
53
|
+
else {
|
|
54
|
+
files.push(file);
|
|
55
|
+
// Keep clean hunks even in a file with conflicts. At overlapping
|
|
56
|
+
// hunks keep the latest manuscript as the baseline for human review.
|
|
57
|
+
const [kept, failed] = merge3(current[file], old[file], accepted[file], true);
|
|
58
|
+
contents[file] = failed ? current[file] : kept;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!files.length) return null; // The author may have resolved it meanwhile.
|
|
62
|
+
const reviewBase = app.repo.commitWith(base, contents, "latex-review: prepare merge review");
|
|
63
|
+
const data: Reconciliation = { id: randomUUID(), round: app.review.latestRound(name)?.number ?? 0, files, sourceBase, sourceAccepted: task.accepted, base, reviewBase, worktreeBefore, ready: false, ...(intent ? { intent } : {}) };
|
|
64
|
+
for (const file of app.repo.changedFiles(worktreeBefore, reviewBase)) safePath(task.worktree, file);
|
|
65
|
+
// Keep the three versions available to the agent and across Git GC.
|
|
66
|
+
for (const [kind, rev] of Object.entries({ original: sourceBase, proposed: task.accepted, current: base, before: worktreeBefore, review: reviewBase }))
|
|
67
|
+
app.repo.updateRef(`refs/latex-review/${name}/merge/${data.id}/${kind}`, rev);
|
|
68
|
+
app.store.execute("INSERT OR REPLACE INTO reconciliations(task,data) VALUES(?,?)", name, JSON.stringify(data));
|
|
69
|
+
return this.prepare(name, data);
|
|
70
|
+
}
|
|
71
|
+
prepare(name: string, data: Reconciliation) {
|
|
72
|
+
if (data.ready) return data;
|
|
73
|
+
const app = this.app, task = app.tasks.get(name);
|
|
74
|
+
const files = app.repo.changedFiles(data.worktreeBefore, data.reviewBase);
|
|
75
|
+
const before = app.repo.blobs(data.worktreeBefore, files), after = app.repo.blobs(data.reviewBase, files);
|
|
76
|
+
const writes = files.map(file => ({ file, path: safePath(task.worktree, file) }));
|
|
77
|
+
for (const write of writes) {
|
|
78
|
+
const current = readBytes(write.path);
|
|
79
|
+
if (!same(current, before[write.file]) && !same(current, after[write.file]))
|
|
80
|
+
throw new Error("The task changed while preparing its merge review. Keep those edits and retry after reviewing them.");
|
|
81
|
+
}
|
|
82
|
+
let scope = task.scope;
|
|
83
|
+
if (scope.sections.length) {
|
|
84
|
+
const outline = buildOutline(revisionReader(app.repo, data.reviewBase, app.project.doc_dir), app.project.main);
|
|
85
|
+
const [ranges, sections] = selectedRanges(outline.nodes, resolveSections(outline.nodes, scope.sections.map(section => section.id.replace(/^label:/, ""))));
|
|
86
|
+
scope = { ranges, sections, paths: Object.keys(ranges).sort() };
|
|
87
|
+
}
|
|
88
|
+
for (const write of writes) writeOrDelete(write.path, after[write.file]);
|
|
89
|
+
app.repo.updateRef(`refs/latex-review/${name}/base`, data.reviewBase);
|
|
90
|
+
app.repo.updateRef(`refs/latex-review/${name}/accepted`, data.reviewBase);
|
|
91
|
+
app.store.transaction(() => {
|
|
92
|
+
app.store.execute("UPDATE tasks SET base=?,accepted=?,scope=? WHERE name=?", data.reviewBase, data.reviewBase, JSON.stringify(scope), name);
|
|
93
|
+
app.store.execute("UPDATE reconciliations SET data=? WHERE task=?", JSON.stringify({ ...data, ready: true }), name);
|
|
94
|
+
});
|
|
95
|
+
return { ...data, ready: true };
|
|
96
|
+
}
|
|
97
|
+
recover() {
|
|
98
|
+
for (const row of this.app.store.all<{ task: string; data: Reconciliation }>("SELECT * FROM reconciliations"))
|
|
99
|
+
if (!row.data.ready) try { this.prepare(row.task, row.data); } catch { /* Retry without overwriting intervening edits. */ }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -121,8 +121,8 @@ export class Review {
|
|
|
121
121
|
"SELECT * FROM actions WHERE task=? AND status='applied' ORDER BY seq DESC LIMIT 1",
|
|
122
122
|
name,
|
|
123
123
|
);
|
|
124
|
-
const delivered = this.app.delivery.get(name);
|
|
125
|
-
return action && latest && action.round === latest.number && (!delivered || action.seq > delivered.seq) ? action : null;
|
|
124
|
+
const delivered = this.app.delivery.get(name), reconciliation = this.app.reconciliation.get(name);
|
|
125
|
+
return action && latest && (!reconciliation || action.round > reconciliation.round) && action.round === latest.number && (!delivered || action.seq > delivered.seq) ? action : null;
|
|
126
126
|
}
|
|
127
127
|
summary(name: string): Summary {
|
|
128
128
|
const task = this.app.tasks.get(name),
|
|
@@ -367,6 +367,9 @@ export class Review {
|
|
|
367
367
|
const task = this.app.tasks.get(name),
|
|
368
368
|
kind = data.action;
|
|
369
369
|
this.app.delivery.assertReady(name);
|
|
370
|
+
const reconciliation = this.app.reconciliation.get(name);
|
|
371
|
+
if (reconciliation && (!reconciliation.ready || data.round <= reconciliation.round))
|
|
372
|
+
throw new Error("Review the new merge suggestion before changing decisions.");
|
|
370
373
|
if (!Object.hasOwn(DECIDED, kind))
|
|
371
374
|
throw new Error("The action must be accept, reject or comment.");
|
|
372
375
|
const identity = data.id || randomUUID();
|
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
const SCHEMA = `
|
|
5
5
|
CREATE TABLE IF NOT EXISTS tasks(name TEXT PRIMARY KEY,title TEXT NOT NULL,worktree TEXT NOT NULL,base TEXT NOT NULL,accepted TEXT NOT NULL,scope TEXT NOT NULL,created REAL NOT NULL,closed REAL);
|
|
6
|
+
CREATE TABLE IF NOT EXISTS reconciliations(task TEXT PRIMARY KEY,data TEXT NOT NULL);
|
|
6
7
|
CREATE TABLE IF NOT EXISTS deliveries(task TEXT PRIMARY KEY,data TEXT NOT NULL);
|
|
7
8
|
CREATE TABLE IF NOT EXISTS rounds(task TEXT NOT NULL,number INTEGER NOT NULL,before TEXT NOT NULL,after TEXT NOT NULL,units TEXT NOT NULL,summary TEXT NOT NULL,created REAL NOT NULL,PRIMARY KEY(task,number));
|
|
8
9
|
CREATE TABLE IF NOT EXISTS actions(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,task TEXT NOT NULL,round INTEGER NOT NULL,kind TEXT NOT NULL,changes TEXT NOT NULL,body TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,detail TEXT NOT NULL DEFAULT '{}',created REAL NOT NULL);
|
|
@@ -155,7 +155,7 @@ export class Tasks {
|
|
|
155
155
|
name,
|
|
156
156
|
);
|
|
157
157
|
app.store.execute("DELETE FROM tasks WHERE name=?", name);
|
|
158
|
-
for (const table of ["rounds", "actions", "messages", "jobs", "deliveries"])
|
|
158
|
+
for (const table of ["rounds", "actions", "messages", "jobs", "deliveries", "reconciliations"])
|
|
159
159
|
app.store.execute(`DELETE FROM ${table} WHERE task=?`, name);
|
|
160
160
|
app.store.execute(
|
|
161
161
|
"INSERT INTO tasks(name,title,worktree,base,accepted,scope,created,deferred) VALUES(?,?,?,?,?,?,?,?)",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Shared by the workspace and the standalone review viewer.
|
|
2
|
+
export const LANGUAGES = [
|
|
3
|
+
{ id: 'en', name: 'English' },
|
|
4
|
+
{ id: 'zh-Hans', name: '简体中文' },
|
|
5
|
+
{ id: 'zh-Hant', name: '繁體中文' },
|
|
6
|
+
{ id: 'ja', name: '日本語' },
|
|
7
|
+
{ id: 'ko', name: '한국어' },
|
|
8
|
+
{ id: 'de', name: 'Deutsch' },
|
|
9
|
+
{ id: 'fr', name: 'Français' },
|
|
10
|
+
{ id: 'es', name: 'Español' },
|
|
11
|
+
{ id: 'pt', name: 'Português' },
|
|
12
|
+
{ id: 'it', name: 'Italiano' },
|
|
13
|
+
{ id: 'sv', name: 'Svenska' },
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type Language = typeof LANGUAGES[number]['id'];
|
|
17
|
+
|
|
18
|
+
// Accept older saved choices and regional browser preferences, but expose only
|
|
19
|
+
// the canonical language/script choices in the interface.
|
|
20
|
+
export function supportedLanguage(tag: string | null | undefined): Language | null {
|
|
21
|
+
if (!tag) return null;
|
|
22
|
+
try {
|
|
23
|
+
const locale = new Intl.Locale(tag);
|
|
24
|
+
if (locale.language === 'zh') return locale.maximize().script === 'Hant' ? 'zh-Hant' : 'zh-Hans';
|
|
25
|
+
return LANGUAGES.find(item => item.id === locale.language)?.id || null;
|
|
26
|
+
} catch { return null; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const rules = new Map<Language, Intl.PluralRules>();
|
|
30
|
+
export function singularCount(language: Language, count: number) {
|
|
31
|
+
let rule = rules.get(language);
|
|
32
|
+
if (!rule) { rule = new Intl.PluralRules(language); rules.set(language, rule); }
|
|
33
|
+
return rule.select(count) === 'one';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const numberFormats = new Map<Language, Intl.NumberFormat>();
|
|
37
|
+
export function interpolateMessage(text: string, language: Language, values?: Record<string, string | number>) {
|
|
38
|
+
if (!values) return text;
|
|
39
|
+
return text.replace(/\{(\w+)\}/g, (match, name: string) => {
|
|
40
|
+
if (!Object.hasOwn(values, name)) return match;
|
|
41
|
+
const value = values[name];
|
|
42
|
+
if (typeof value !== 'number') return value;
|
|
43
|
+
let format = numberFormats.get(language);
|
|
44
|
+
if (!format) { format = new Intl.NumberFormat(language, { maximumFractionDigits: 20 }); numberFormats.set(language, format); }
|
|
45
|
+
return format.format(value);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -10,6 +10,7 @@ import { CONFIG_NAME, loadProject, type Project } from "./core/config.ts";
|
|
|
10
10
|
import { Store } from "./core/store.ts";
|
|
11
11
|
import { Tasks } from "./core/tasks.ts";
|
|
12
12
|
import { Review } from "./core/review.ts";
|
|
13
|
+
import { Reconciliations } from "./core/reconcile.ts";
|
|
13
14
|
import { Deliveries } from "./core/delivery.ts";
|
|
14
15
|
import { more, PublishError, Publisher } from "./core/publish.ts";
|
|
15
16
|
import { view, type ViewRequest } from "./core/view.ts";
|
|
@@ -29,6 +30,7 @@ export class App {
|
|
|
29
30
|
tasks: Tasks;
|
|
30
31
|
review: Review;
|
|
31
32
|
delivery: Deliveries;
|
|
33
|
+
reconciliation: Reconciliations;
|
|
32
34
|
publisher: Publisher;
|
|
33
35
|
references: ReferenceChecker;
|
|
34
36
|
confirmations: Confirmations;
|
|
@@ -42,6 +44,7 @@ export class App {
|
|
|
42
44
|
this.tasks = new Tasks(this);
|
|
43
45
|
this.review = new Review(this);
|
|
44
46
|
this.delivery = new Deliveries(this);
|
|
47
|
+
this.reconciliation = new Reconciliations(this);
|
|
45
48
|
this.publisher = new Publisher(this);
|
|
46
49
|
this.references = new ReferenceChecker(
|
|
47
50
|
path.join(this.state, "references.json"),
|
|
@@ -51,6 +54,7 @@ export class App {
|
|
|
51
54
|
);
|
|
52
55
|
this.review.recover();
|
|
53
56
|
this.delivery.recover();
|
|
57
|
+
this.reconciliation.recover();
|
|
54
58
|
}
|
|
55
59
|
get project() {
|
|
56
60
|
const config = path.join(this.root, CONFIG_NAME),
|
|
@@ -67,11 +71,13 @@ export class App {
|
|
|
67
71
|
taskInfo(name: string) {
|
|
68
72
|
const task = this.tasks.get(name),
|
|
69
73
|
delivery = this.delivery.get(name),
|
|
70
|
-
|
|
74
|
+
reconciliation = this.reconciliation.get(name),
|
|
75
|
+
scope = reconciliation ? { ...task.scope, paths: reconciliation.files, ranges: null } : task.scope;
|
|
71
76
|
return {
|
|
72
77
|
...this.review.summary(name),
|
|
73
78
|
deferred: Boolean(task.deferred),
|
|
74
79
|
delivery: delivery ? { ...this.delivery.result(delivery), pending: !delivery.done } : null,
|
|
80
|
+
reconciliation: reconciliation ?? null,
|
|
75
81
|
worktree: task.worktree,
|
|
76
82
|
url: this.page(name),
|
|
77
83
|
viewing: now() - (this.viewers.get(name) ?? 0) < 15,
|
|
@@ -337,6 +343,11 @@ export function createServer(app: App) {
|
|
|
337
343
|
{ schema: { body: { ...objectSchema, additionalProperties: false, properties: { locations: { type: "array", maxItems: 8, items: { type: "string", maxLength: 1000 } }, pages: { type: "array", maxItems: 8, items: { type: "string", maxLength: 40 } }, accepted: { type: "boolean" } } } } },
|
|
338
344
|
(request) => view(app, request.params.name, request.body),
|
|
339
345
|
);
|
|
346
|
+
server.post<{ Params: { name: string }; Body: { keepCurrent?: boolean; mode?: "workspace" | "commit"; message?: string; confirmation?: string } }>(
|
|
347
|
+
"/api/tasks/:name/apply",
|
|
348
|
+
{ schema: { body: { ...objectSchema, additionalProperties: false, properties: { confirmation: { type: "string", maxLength: 100 }, keepCurrent: { type: "boolean" }, mode: { enum: ["workspace", "commit"] }, message: { type: "string", maxLength: 5000 } } } } },
|
|
349
|
+
request => app.delivery.apply(request.params.name, request.body.keepCurrent === true, request.body.mode, request.body.message, request.body.confirmation),
|
|
350
|
+
);
|
|
340
351
|
server.post<{ Params: { name: string }; Body: { mode: "workspace" | "branch"; message?: string } }>(
|
|
341
352
|
"/api/tasks/:name/deliver",
|
|
342
353
|
{ schema: { body: { ...objectSchema, required: ["mode"], properties: { mode: { enum: ["workspace", "branch"] }, message: { type: "string", maxLength: 5000 } } } } },
|
|
@@ -50,12 +50,14 @@ export interface Task {
|
|
|
50
50
|
closed: number | null;
|
|
51
51
|
}
|
|
52
52
|
export interface Delivery {
|
|
53
|
-
mode: "workspace" | "branch";
|
|
53
|
+
mode: "workspace" | "commit" | "branch";
|
|
54
54
|
accepted: string;
|
|
55
55
|
seq: number;
|
|
56
56
|
branch?: string;
|
|
57
57
|
commit?: string;
|
|
58
58
|
parent?: string;
|
|
59
|
+
indexBefore?: string;
|
|
60
|
+
indexAfter?: string;
|
|
59
61
|
previous?: string;
|
|
60
62
|
done: boolean;
|
|
61
63
|
writes: { file: string; before: string | null; after: string | null }[];
|
|
@@ -422,8 +424,9 @@ export interface PageState {
|
|
|
422
424
|
* and how many manuscript files this task's accepted changes touched.
|
|
423
425
|
*/
|
|
424
426
|
workspace?: {
|
|
427
|
+
merging?: boolean;
|
|
425
428
|
deferred?: boolean;
|
|
426
|
-
destination?: "workspace" | "branch";
|
|
429
|
+
destination?: "workspace" | "commit" | "branch";
|
|
427
430
|
status: string;
|
|
428
431
|
unsent: number;
|
|
429
432
|
comments?: number;
|
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { supportedLanguage, singularCount, interpolateMessage, type Language } from '../locales.ts';
|
|
2
|
+
import { messages } from './locales/index.ts';
|
|
3
|
+
export type { Language };
|
|
2
4
|
|
|
3
|
-
// The viewer reads in English unless the page asks for another language with
|
|
4
|
-
// ?lang=zh-CN (Papilot passes its interface language). The English text is the
|
|
5
|
-
// message key, so a string without a translation still reads correctly.
|
|
6
|
-
export type Language = "en" | "zh-CN";
|
|
7
|
-
const messages: Record<Language, Record<string, string>> = {
|
|
8
|
-
en: {},
|
|
9
|
-
"zh-CN": zhCN,
|
|
10
|
-
};
|
|
11
5
|
export function pageLanguage(search = location.search): Language {
|
|
12
|
-
return new URLSearchParams(search).get(
|
|
13
|
-
? "zh-CN"
|
|
14
|
-
: "en";
|
|
6
|
+
return supportedLanguage(new URLSearchParams(search).get('lang')) || 'en';
|
|
15
7
|
}
|
|
16
8
|
const current: Language =
|
|
17
9
|
typeof location === "undefined" ? "en" : pageLanguage();
|
|
@@ -19,7 +11,7 @@ if (typeof document !== "undefined") document.documentElement.lang = current;
|
|
|
19
11
|
|
|
20
12
|
export const language = () => current;
|
|
21
13
|
// Dates and numbers follow the interface language.
|
|
22
|
-
export const locale = () =>
|
|
14
|
+
export const locale = () => current;
|
|
23
15
|
|
|
24
16
|
// Links inside the viewer keep the page's language.
|
|
25
17
|
export function withLanguage(href: string) {
|
|
@@ -30,19 +22,15 @@ export function withLanguage(href: string) {
|
|
|
30
22
|
|
|
31
23
|
// t("Change {n}", { n }) fills {placeholders} after translating.
|
|
32
24
|
export function t(text: string, values?: Record<string, string | number>) {
|
|
33
|
-
const template = messages[current][text]
|
|
34
|
-
return values
|
|
35
|
-
? template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
|
36
|
-
name in values ? String(values[name]) : match,
|
|
37
|
-
)
|
|
38
|
-
: template;
|
|
25
|
+
const template = Object.hasOwn(messages[current], text) ? messages[current][text] : text;
|
|
26
|
+
return interpolateMessage(template, current, values);
|
|
39
27
|
}
|
|
40
|
-
//
|
|
28
|
+
// Select the count form using the active locale; {n} is the count.
|
|
41
29
|
export function tn(
|
|
42
30
|
count: number,
|
|
43
31
|
one: string,
|
|
44
32
|
other: string,
|
|
45
33
|
values?: Record<string, string | number>,
|
|
46
34
|
) {
|
|
47
|
-
return t(count
|
|
35
|
+
return t(singularCount(current, count) ? one : other, { n: count, ...values });
|
|
48
36
|
}
|