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.
Files changed (71) hide show
  1. package/README.de.md +110 -0
  2. package/README.es.md +110 -0
  3. package/README.fr.md +110 -0
  4. package/README.it.md +110 -0
  5. package/README.ja.md +110 -0
  6. package/README.ko.md +110 -0
  7. package/README.md +54 -121
  8. package/README.pt.md +110 -0
  9. package/README.sv.md +110 -0
  10. package/README.zh-CN.md +58 -117
  11. package/README.zh-Hant.md +110 -0
  12. package/bin/doctor.mjs +7 -5
  13. package/dist/assets/{_virtual_papilot-licenses-D7ro6ecN.js → _virtual_papilot-licenses-CMfCtEYQ.js} +1 -1
  14. package/dist/assets/index-oAIkxhfp.js +265 -0
  15. package/dist/assets/index-t_qCOF_B.css +1 -0
  16. package/dist/assets/{math-V9uUaUzA.js → math-CEU4m4-H.js} +1 -1
  17. package/dist/assets/{pdf-view-Bnb2uIBE.js → pdf-view-HWRGBLsZ.js} +1 -1
  18. package/dist/index.html +2 -2
  19. package/examples/papilot-paper/preview/main.pdf +0 -0
  20. package/examples/papilot-paper/preview/main.synctex.gz +0 -0
  21. package/package.json +18 -5
  22. package/src/server/acp.ts +44 -7
  23. package/src/server/agents.ts +5 -3
  24. package/src/server/app.ts +10 -3
  25. package/src/server/desktop-demo.ts +38 -0
  26. package/src/server/imports.ts +60 -21
  27. package/src/server/manuscript.ts +44 -8
  28. package/src/server/reconcile.ts +20 -0
  29. package/src/server/review.ts +3 -2
  30. package/src/server/start.ts +26 -4
  31. package/src/server/store.ts +0 -8
  32. package/src/server/update-install.ts +22 -2
  33. package/src/server/updates.ts +32 -12
  34. package/src/server/workspace.ts +78 -15
  35. package/src/shared.ts +20 -9
  36. package/src/skill-metadata.ts +1 -1
  37. package/vendor/latex-review/dist/web/assets/index-DVs7cTBl.js +19 -0
  38. package/vendor/latex-review/dist/web/assets/index-ysOpu05D.css +1 -0
  39. package/vendor/latex-review/dist/web/index.html +2 -2
  40. package/vendor/latex-review/src/core/build.ts +180 -97
  41. package/vendor/latex-review/src/core/current-commit.ts +104 -0
  42. package/vendor/latex-review/src/core/delivery.ts +62 -16
  43. package/vendor/latex-review/src/core/git.ts +135 -29
  44. package/vendor/latex-review/src/core/outline.ts +0 -9
  45. package/vendor/latex-review/src/core/publish.ts +7 -1
  46. package/vendor/latex-review/src/core/reconcile.ts +101 -0
  47. package/vendor/latex-review/src/core/review.ts +5 -2
  48. package/vendor/latex-review/src/core/store.ts +1 -0
  49. package/vendor/latex-review/src/core/tasks.ts +1 -1
  50. package/vendor/latex-review/src/locales.ts +47 -0
  51. package/vendor/latex-review/src/server.ts +12 -1
  52. package/vendor/latex-review/src/types.ts +5 -2
  53. package/vendor/latex-review/src/web/i18n.ts +9 -21
  54. package/vendor/latex-review/src/web/locales/de.json +250 -0
  55. package/vendor/latex-review/src/web/locales/es.json +250 -0
  56. package/vendor/latex-review/src/web/locales/fr.json +250 -0
  57. package/vendor/latex-review/src/web/locales/index.ts +16 -0
  58. package/vendor/latex-review/src/web/locales/it.json +250 -0
  59. package/vendor/latex-review/src/web/locales/ja.json +250 -0
  60. package/vendor/latex-review/src/web/locales/ko.json +250 -0
  61. package/vendor/latex-review/src/web/locales/pt.json +250 -0
  62. package/vendor/latex-review/src/web/locales/sv.json +250 -0
  63. package/vendor/latex-review/src/web/locales/zh-Hant.json +250 -0
  64. package/vendor/latex-review/src/web/references.tsx +1 -1
  65. package/vendor/latex-review/src/web/review.tsx +8 -6
  66. package/vendor/latex-review/src/web/style.css +1 -11
  67. package/vendor/latex-review/src/web/zh-CN.ts +4 -2
  68. package/dist/assets/index-Cc-awdNn.css +0 -1
  69. package/dist/assets/index-D_VX51no.js +0 -265
  70. package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +0 -19
  71. 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
- export(rev: string, folder: string, destination: string) {
265
- fs.mkdirSync(destination, { recursive: true });
266
- const files = this.files(rev, folder),
267
- blobs = this.blobs(rev, files);
268
- const links: [string, string][] = [];
269
- for (const file of files) {
270
- const relative = path.posix.relative(folder || ".", file);
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
- const target = path.join(destination, relative),
273
- data = blobs[file];
274
- if (data === null) continue;
275
- fs.mkdirSync(path.dirname(target), { recursive: true });
276
- const mode = this.mode(rev, file);
277
- if (mode === "120000") {
278
- const link = data.toString(),
279
- resolved = path.resolve(path.dirname(target), link);
280
- if (
281
- path.isAbsolute(link) ||
282
- !resolved.startsWith(path.resolve(destination) + path.sep)
283
- )
284
- throw new GitError(
285
- `A link in the snapshot points outside the document: ${file}`,
286
- );
287
- links.push([target, link]);
288
- } else
289
- fs.writeFileSync(target, data, {
290
- mode: mode === "100755" ? 0o755 : 0o644,
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
- scope = task.scope;
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 { zhCN } from "./zh-CN.ts";
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("lang")?.toLowerCase().startsWith("zh")
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 = () => (current === "zh-CN" ? "zh-CN" : "en-US");
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] ?? 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
- // English picks between one and other by count; {n} is the count.
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 === 1 ? one : other, { n: count, ...values });
35
+ return t(singularCount(current, count) ? one : other, { n: count, ...values });
48
36
  }