papilot 0.8.2 → 0.8.4

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 (115) hide show
  1. package/README.md +2 -2
  2. package/bin/doctor.mjs +1 -1
  3. package/dist/assets/{_virtual_papilot-licenses-CMfCtEYQ.js → _virtual_papilot-licenses-YJbh9c1G.js} +1 -1
  4. package/dist/assets/index-CwFvCo6r.css +1 -0
  5. package/dist/assets/index-dByEATYE.js +267 -0
  6. package/dist/assets/{math-CEU4m4-H.js → math-UelUOI-R.js} +1 -1
  7. package/dist/assets/{pdf-view-HWRGBLsZ.js → pdf-view-ChJvfsD8.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/engine/dist/web/assets/FullPaper-DbT25OhQ.js +54 -0
  10. package/engine/dist/web/assets/index-C_KwLmXQ.js +19 -0
  11. package/engine/dist/web/assets/index-D3_ZFdT8.css +1 -0
  12. package/engine/dist/web/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
  13. package/{vendor/latex-review → engine}/dist/web/index.html +2 -2
  14. package/engine/package-lock.json +3390 -0
  15. package/engine/package.json +50 -0
  16. package/{vendor/latex-review → engine}/src/core/build.ts +22 -6
  17. package/engine/src/core/progress.ts +67 -0
  18. package/{vendor/latex-review → engine}/src/core/review.ts +32 -0
  19. package/{vendor/latex-review → engine}/src/render/inline.ts +1 -0
  20. package/{vendor/latex-review → engine}/src/render/locate.ts +26 -16
  21. package/engine/src/render/positions.ts +34 -0
  22. package/engine/src/render/synctex.ts +188 -0
  23. package/engine/src/render/synctex.wasm +0 -0
  24. package/engine/src/render/synctex.wasm.LICENSE +50 -0
  25. package/{vendor/latex-review → engine}/src/server.ts +5 -3
  26. package/{vendor/latex-review → engine}/src/types.ts +13 -0
  27. package/engine/src/web/FullPaper.tsx +318 -0
  28. package/{vendor/latex-review → engine}/src/web/locales/de.json +3 -0
  29. package/{vendor/latex-review → engine}/src/web/locales/es.json +3 -0
  30. package/{vendor/latex-review → engine}/src/web/locales/fr.json +3 -0
  31. package/{vendor/latex-review → engine}/src/web/locales/it.json +3 -0
  32. package/{vendor/latex-review → engine}/src/web/locales/ja.json +3 -0
  33. package/{vendor/latex-review → engine}/src/web/locales/ko.json +3 -0
  34. package/{vendor/latex-review → engine}/src/web/locales/pt.json +3 -0
  35. package/{vendor/latex-review → engine}/src/web/locales/sv.json +3 -0
  36. package/{vendor/latex-review → engine}/src/web/locales/zh-Hant.json +3 -0
  37. package/engine/src/web/paper.ts +13 -0
  38. package/{vendor/latex-review → engine}/src/web/review.tsx +94 -1
  39. package/{vendor/latex-review → engine}/src/web/style.css +70 -0
  40. package/engine/src/web/view-preferences.ts +15 -0
  41. package/{vendor/latex-review → engine}/src/web/zh-CN.ts +3 -0
  42. package/package.json +10 -8
  43. package/src/conversation.ts +1 -1
  44. package/src/server/app.ts +18 -1
  45. package/src/server/changes.ts +12 -3
  46. package/src/server/imports.ts +49 -22
  47. package/src/server/main.ts +3 -2
  48. package/src/server/manuscript.ts +5 -4
  49. package/src/server/paths.ts +1 -1
  50. package/src/server/start.ts +3 -10
  51. package/src/server/update-install.ts +8 -25
  52. package/src/server/updates.ts +43 -25
  53. package/src/shared.ts +6 -3
  54. package/vendor/latex-review/package-lock.json +4 -3106
  55. package/vendor/latex-review/package.json +3 -46
  56. package/README.de.md +0 -110
  57. package/README.es.md +0 -110
  58. package/README.fr.md +0 -110
  59. package/README.it.md +0 -110
  60. package/README.ja.md +0 -110
  61. package/README.ko.md +0 -110
  62. package/README.pt.md +0 -110
  63. package/README.sv.md +0 -110
  64. package/README.zh-CN.md +0 -110
  65. package/README.zh-Hant.md +0 -110
  66. package/dist/assets/index-oAIkxhfp.js +0 -265
  67. package/dist/assets/index-t_qCOF_B.css +0 -1
  68. package/vendor/latex-review/dist/web/assets/index-DVs7cTBl.js +0 -19
  69. package/vendor/latex-review/dist/web/assets/index-ysOpu05D.css +0 -1
  70. /package/{vendor/latex-review → engine}/LICENSE +0 -0
  71. /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  72. /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  73. /package/{vendor/latex-review → engine}/src/cli.ts +0 -0
  74. /package/{vendor/latex-review → engine}/src/client.ts +0 -0
  75. /package/{vendor/latex-review → engine}/src/core/bib.ts +0 -0
  76. /package/{vendor/latex-review → engine}/src/core/changes.ts +0 -0
  77. /package/{vendor/latex-review → engine}/src/core/config.ts +0 -0
  78. /package/{vendor/latex-review → engine}/src/core/current-commit.ts +0 -0
  79. /package/{vendor/latex-review → engine}/src/core/delivery.ts +0 -0
  80. /package/{vendor/latex-review → engine}/src/core/diff.ts +0 -0
  81. /package/{vendor/latex-review → engine}/src/core/files.ts +0 -0
  82. /package/{vendor/latex-review → engine}/src/core/git.ts +0 -0
  83. /package/{vendor/latex-review → engine}/src/core/outline.ts +0 -0
  84. /package/{vendor/latex-review → engine}/src/core/process.ts +0 -0
  85. /package/{vendor/latex-review → engine}/src/core/publish.ts +0 -0
  86. /package/{vendor/latex-review → engine}/src/core/reconcile.ts +0 -0
  87. /package/{vendor/latex-review → engine}/src/core/store.ts +0 -0
  88. /package/{vendor/latex-review → engine}/src/core/tasks.ts +0 -0
  89. /package/{vendor/latex-review → engine}/src/core/verify.ts +0 -0
  90. /package/{vendor/latex-review → engine}/src/core/view.ts +0 -0
  91. /package/{vendor/latex-review → engine}/src/locales.ts +0 -0
  92. /package/{vendor/latex-review → engine}/src/render/effects.ts +0 -0
  93. /package/{vendor/latex-review → engine}/src/render/pdf.ts +0 -0
  94. /package/{vendor/latex-review → engine}/src/render/pdfdiff.ts +0 -0
  95. /package/{vendor/latex-review → engine}/src/render/round.ts +0 -0
  96. /package/{vendor/latex-review → engine}/src/render/worker.ts +0 -0
  97. /package/{vendor/latex-review → engine}/src/runtime.ts +0 -0
  98. /package/{vendor/latex-review → engine}/src/web/ChangeMap.tsx +0 -0
  99. /package/{vendor/latex-review → engine}/src/web/PdfStrip.tsx +0 -0
  100. /package/{vendor/latex-review → engine}/src/web/effect-preferences.ts +0 -0
  101. /package/{vendor/latex-review → engine}/src/web/fonts/GUST-FONT-LICENSE.txt +0 -0
  102. /package/{vendor/latex-review → engine}/src/web/fonts/LPPL.txt +0 -0
  103. /package/{vendor/latex-review → engine}/src/web/fonts/MANIFEST-newpx.txt +0 -0
  104. /package/{vendor/latex-review → engine}/src/web/fonts/README-newpx.txt +0 -0
  105. /package/{vendor/latex-review → engine}/src/web/fonts/README.md +0 -0
  106. /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
  107. /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
  108. /package/{vendor/latex-review → engine}/src/web/i18n.ts +0 -0
  109. /package/{vendor/latex-review → engine}/src/web/index.html +0 -0
  110. /package/{vendor/latex-review → engine}/src/web/locales/index.ts +0 -0
  111. /package/{vendor/latex-review → engine}/src/web/main.tsx +0 -0
  112. /package/{vendor/latex-review → engine}/src/web/model.ts +0 -0
  113. /package/{vendor/latex-review → engine}/src/web/overview.tsx +0 -0
  114. /package/{vendor/latex-review → engine}/src/web/references.tsx +0 -0
  115. /package/{vendor/latex-review → engine}/src/web/shortcuts.ts +0 -0
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  Fragment,
3
+ lazy,
4
+ Suspense,
3
5
  useEffect,
4
6
  useMemo,
5
7
  useRef,
@@ -18,7 +20,11 @@ import type {
18
20
  } from "../types.ts";
19
21
  import { PdfStrip } from "./PdfStrip.tsx";
20
22
  import { ChangeMap } from "./ChangeMap.tsx";
23
+ import { placeable, type PlacedChange } from "./paper.ts";
24
+ // pdf.js loads only when the full paper is shown.
25
+ const FullPaper = lazy(() => import("./FullPaper.tsx").then((module) => ({ default: module.FullPaper })));
21
26
  import { EFFECTS_EVENT, EFFECTS_STORAGE, readExpandedEffects } from "./effect-preferences.ts";
27
+ import { readView, VIEW_EVENT, VIEW_STORAGE, type ReviewView } from "./view-preferences.ts";
22
28
  import { STATUS } from "./overview.tsx";
23
29
  import { language, locale, t, tn, withLanguage } from "./i18n.ts";
24
30
  import {
@@ -167,6 +173,22 @@ export function ReviewApp({
167
173
  scrollUntil = useRef(0),
168
174
  snapshot = useRef({ state, detail, busy, composer, current });
169
175
  const [shortcuts, setShortcuts] = useState(readShortcuts);
176
+ // The whole paper with the changes in place, or the changed sections only.
177
+ // A host may fix the view in the address (Papilot's manuscript chooses its
178
+ // PDF with its own switch); otherwise it opens on the stored default.
179
+ const [pinnedView] = useState<ReviewView | null>(() => {
180
+ const value = new URLSearchParams(location.search).get("view");
181
+ return value === "paper" || value === "changes" ? value : null;
182
+ });
183
+ const [view, setView] = useState<ReviewView>(() => pinnedView ?? readView());
184
+ useEffect(() => {
185
+ if (pinnedView) return;
186
+ const chosen = (event: Event) => setView((event as CustomEvent).detail === "changes" ? "changes" : "paper");
187
+ const stored = (event: StorageEvent) => { if (event.key === VIEW_STORAGE || event.key === null) setView(readView()); };
188
+ window.addEventListener(VIEW_EVENT, chosen);
189
+ window.addEventListener("storage", stored);
190
+ return () => { window.removeEventListener(VIEW_EVENT, chosen); window.removeEventListener("storage", stored); };
191
+ }, [pinnedView]);
170
192
  const [expandedEffects, setExpandedEffects] = useState(readExpandedEffects);
171
193
  // Per-edit disclosure is temporary and scoped to its round. Changing the
172
194
  // global preference resets those overrides without reloading the review.
@@ -195,6 +217,16 @@ export function ReviewApp({
195
217
  [detail],
196
218
  ),
197
219
  stops = useMemo(() => (detail ? buildStops(detail) : []), [detail]),
220
+ // The change blocks in the order and numbering of the changes view.
221
+ placed = useMemo(() => {
222
+ const list: PlacedChange[] = [];
223
+ if (!detail) return list;
224
+ for (const section of detail.sections)
225
+ for (const block of section.blocks)
226
+ if (block.kind === "change") list.push({ block, index: list.length });
227
+ for (const block of detail.loose) list.push({ block, index: list.length });
228
+ return list;
229
+ }, [detail]),
198
230
  // An edit's places show under the first change that includes it.
199
231
  firstStop = useMemo(() => {
200
232
  const found = new Map<string, number>();
@@ -1094,6 +1126,28 @@ export function ReviewApp({
1094
1126
  </article>
1095
1127
  );
1096
1128
  }
1129
+ const fullPaper = !pinnedView && Boolean(detail?.pdf) && placeable(placed),
1130
+ paperView = fullPaper && view === "paper";
1131
+ // A host shows its own switch while the whole paper can be shown.
1132
+ useEffect(() => {
1133
+ const root = document.documentElement;
1134
+ if (fullPaper) root.dataset.fullPaper = "available";
1135
+ else delete root.dataset.fullPaper;
1136
+ }, [fullPaper]);
1137
+ // Keep the selected change in view when the view changes, from here or the host.
1138
+ const shownView = useRef(paperView);
1139
+ useEffect(() => {
1140
+ if (shownView.current === paperView) return;
1141
+ shownView.current = paperView;
1142
+ requestAnimationFrame(() => {
1143
+ const article = paper.current?.querySelector<HTMLElement>(`[data-stop="${snapshot.current.current}"]`);
1144
+ if (article) reveal(article);
1145
+ });
1146
+ }, [paperView]);
1147
+ // The switch changes this review only; the default is a Papilot setting.
1148
+ function chooseView(next: ReviewView) {
1149
+ setView(next);
1150
+ }
1097
1151
  let index = 0;
1098
1152
  return (
1099
1153
  <>
@@ -1233,6 +1287,24 @@ export function ReviewApp({
1233
1287
  {t("Undo revert")}
1234
1288
  </button>
1235
1289
  )}
1290
+ {fullPaper && (
1291
+ <div className="view-switch" role="group" aria-label={t("Review view")}>
1292
+ <button
1293
+ className={`ghost${view === "paper" ? " active" : ""}`}
1294
+ aria-pressed={view === "paper"}
1295
+ onClick={() => chooseView("paper")}
1296
+ >
1297
+ PDF
1298
+ </button>
1299
+ <button
1300
+ className={`ghost${view === "changes" ? " active" : ""}`}
1301
+ aria-pressed={view === "changes"}
1302
+ onClick={() => chooseView("changes")}
1303
+ >
1304
+ {t("Changes")}
1305
+ </button>
1306
+ </div>
1307
+ )}
1236
1308
  {/* History is a toolbar action; its own row appears only while
1237
1309
  browsing earlier decisions. */}
1238
1310
  {detail && !history && !changes && (
@@ -1462,7 +1534,28 @@ export function ReviewApp({
1462
1534
  />
1463
1535
  )}
1464
1536
  <div className="document">
1465
- {detail ? (
1537
+ {detail && paperView ? (
1538
+ <>
1539
+ <Suspense fallback={<div className="paper-status" role="status"><div className="pulse" /></div>}>
1540
+ <FullPaper
1541
+ url={detail.pdf!}
1542
+ changes={placed}
1543
+ render={(block, at) => change(block, at, true)}
1544
+ scrollRoot={paper}
1545
+ onReady={() => {
1546
+ const article = paper.current?.querySelector<HTMLElement>(
1547
+ `[data-stop="${snapshot.current.current}"]`,
1548
+ );
1549
+ if (article) reveal(article);
1550
+ }}
1551
+ onUnavailable={() => chooseView("changes")}
1552
+ />
1553
+ </Suspense>
1554
+ {!stops.length && !changes && (
1555
+ <p className="empty">{t("No changes to review.")}</p>
1556
+ )}
1557
+ </>
1558
+ ) : detail ? (
1466
1559
  <>
1467
1560
  {detail.sections.map((section) => (
1468
1561
  <section className="part" key={section.node}>
@@ -247,6 +247,33 @@ kbd {
247
247
  border-color: var(--line);
248
248
  }
249
249
 
250
+ /* Full paper or changes only, as one segmented control (a host such as
251
+ Papilot shows its own instead). */
252
+ .view-switch {
253
+ display: inline-grid;
254
+ grid-template-columns: repeat(2, 1fr);
255
+ flex: none;
256
+ gap: 2px;
257
+ padding: 2px;
258
+ border: 1px solid var(--line);
259
+ border-radius: 9px;
260
+ background: var(--head);
261
+ }
262
+ .view-switch .ghost {
263
+ padding: 3px 10px;
264
+ border-radius: 6px;
265
+ font-weight: 500;
266
+ }
267
+ .view-switch .ghost:hover {
268
+ text-decoration: none;
269
+ border-color: transparent;
270
+ }
271
+ .view-switch .ghost.active {
272
+ color: var(--ink);
273
+ background: var(--paper);
274
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.06), 0 0 0 1px var(--line);
275
+ }
276
+
250
277
  /* Document */
251
278
  #paper {
252
279
  overflow: auto;
@@ -898,6 +925,49 @@ kbd {
898
925
  text-align: center;
899
926
  padding: 90px 16px;
900
927
  }
928
+ /* The whole paper: the new PDF's pages, opened where each change is shown
929
+ as in the changes view. A host's narrow-screen crop of the strips
930
+ (--pdf-crop-scale, --pdf-crop-shift) applies to the pages too. */
931
+ .paper-page {
932
+ background: var(--pdf-page, var(--paper));
933
+ box-shadow: var(--shadow);
934
+ border-radius: 3px;
935
+ margin-bottom: 14px;
936
+ }
937
+ .paper-context {
938
+ line-height: 0;
939
+ }
940
+ .paper-slice {
941
+ position: relative;
942
+ width: 100%;
943
+ }
944
+ .paper-slice canvas {
945
+ position: absolute;
946
+ inset: 0;
947
+ width: 100%;
948
+ height: 100%;
949
+ filter: var(--pdf-filter, none);
950
+ }
951
+ @media (max-width: 680px) {
952
+ .paper-slice {
953
+ width: calc(100% * var(--pdf-crop-scale, 1));
954
+ margin-left: calc(-100% * var(--pdf-crop-scale, 1) * var(--pdf-crop-shift, 0));
955
+ }
956
+ .paper-context {
957
+ overflow-x: clip;
958
+ }
959
+ }
960
+ .paper-status {
961
+ text-align: center;
962
+ padding: 90px 16px;
963
+ color: var(--muted);
964
+ }
965
+ .paper-status p {
966
+ margin: 6px 0 14px;
967
+ }
968
+ .paper-clock {
969
+ font-variant-numeric: tabular-nums;
970
+ }
901
971
  .waiting p {
902
972
  margin: 6px 0;
903
973
  }
@@ -0,0 +1,15 @@
1
+ // Whole paper ("paper") or changed sections only ("changes"). Reviews open on
2
+ // the stored default: the changed sections, which show fastest, unless the
3
+ // whole paper was chosen in Papilot's settings. Switching views lasts for the
4
+ // open review only. A host such as Papilot shows its own switch and tells this
5
+ // frame; storage events bring a new default to reviews already open.
6
+ export const VIEW_STORAGE = "latex-review:default-view";
7
+ export const VIEW_EVENT = "latex-review:view-changed";
8
+ export type ReviewView = "paper" | "changes";
9
+ export function readView(): ReviewView {
10
+ try {
11
+ return localStorage.getItem(VIEW_STORAGE) === "paper" ? "paper" : "changes";
12
+ } catch {
13
+ return "changes";
14
+ }
15
+ }
@@ -66,6 +66,9 @@ export const zhCN: Record<string, string> = {
66
66
  "Review history": "审阅历史",
67
67
  "Keyboard shortcuts": "键盘快捷键",
68
68
  "Review view": "审阅视图",
69
+ "Changes": "改动",
70
+ "Preparing the full PDF…": "正在准备完整 PDF…",
71
+ "The full PDF could not be loaded.": "无法加载完整 PDF。",
69
72
  "Undo {action} · {changes}": "撤销{action} · {changes}",
70
73
  "Change {list}": "修改 {list}",
71
74
  "Changes {list}": "修改 {list}",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
5
5
  "keywords": [
6
6
  "latex",
@@ -48,23 +48,25 @@
48
48
  "scripts/demo.ts",
49
49
  "scripts/service.ts",
50
50
  "examples/papilot-paper",
51
- "vendor/latex-review/src",
52
- "vendor/latex-review/dist",
51
+ "engine/src",
52
+ "engine/dist",
53
+ "engine/package.json",
54
+ "engine/package-lock.json",
55
+ "engine/LICENSE",
53
56
  "vendor/latex-review/package.json",
54
- "vendor/latex-review/package-lock.json",
55
- "vendor/latex-review/LICENSE"
57
+ "vendor/latex-review/package-lock.json"
56
58
  ],
57
59
  "engines": {
58
60
  "node": ">=22.22.0"
59
61
  },
60
- "desktopShell": 1,
62
+ "desktopShell": 2,
61
63
  "scripts": {
62
64
  "dev": "tsx watch src/server/main.ts",
63
65
  "start": "tsx src/server/main.ts",
64
66
  "build": "tsc --noEmit && vite build",
65
67
  "typecheck": "tsc --noEmit && tsc -p desktop/tsconfig.json --noEmit",
66
68
  "test": "tsx --test tests/*.test.ts",
67
- "setup:review": "npm --prefix vendor/latex-review ci && npm --prefix vendor/latex-review run build",
69
+ "setup:review": "npm --prefix engine ci && npm --prefix engine run build",
68
70
  "demo": "tsx scripts/demo.ts",
69
71
  "doctor": "node bin/papilot.mjs doctor",
70
72
  "service:install": "tsx scripts/service.ts install",
@@ -79,7 +81,7 @@
79
81
  "test:desktop": "node scripts/check-desktop.mjs",
80
82
  "i18n": "tsx scripts/i18n.ts",
81
83
  "i18n:check-app": "tsx scripts/i18n/app-locales.ts",
82
- "test:locales": "npm run build && npm --prefix vendor/latex-review run build && node --import tsx scripts/check-locales.mjs"
84
+ "test:locales": "npm run build && npm --prefix engine run build && node --import tsx scripts/check-locales.mjs"
83
85
  },
84
86
  "dependencies": {
85
87
  "@fastify/compress": "^9.2.2",
@@ -33,7 +33,7 @@ export function skillPrompt(skill: { name: string; body: string; folder?: string
33
33
  }
34
34
 
35
35
  interface FeedbackItem { kind?: string; round?: number | null; text?: string; changes?: (string | { file?: string; lines?: [number, number] })[] }
36
- // The review engine's feedback (see vendor/latex-review FeedbackItem) as the chat shows it.
36
+ // The review engine's feedback (see engine FeedbackItem) as the chat shows it.
37
37
  export function summarizeFeedback(feedback: { feedback?: FeedbackItem[]; build_error?: unknown }): FeedbackSummary | undefined {
38
38
  const items = feedback.feedback || [];
39
39
  if (!items.length && !feedback.build_error) return undefined;
package/src/server/app.ts CHANGED
@@ -25,7 +25,7 @@ import type { Updates } from './updates.ts';
25
25
  import { Diagnostics } from './diagnostics.ts';
26
26
  import { attachmentIds } from './attachments.ts';
27
27
  import { MAX_ATTACHMENT_BYTES } from '../shared.ts';
28
- import { PaperImports } from './imports.ts';
28
+ import { PaperImports, openFolder } from './imports.ts';
29
29
  import { DATA } from './paths.ts';
30
30
  import { registerAiReviewRoutes } from './ai-review-routes.ts';
31
31
  import { detectLatex } from './latex-installation.ts';
@@ -174,6 +174,7 @@ export async function buildApp(options: AppOptions) {
174
174
  const string = (value: unknown, name: string, max = 20000) => { if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(`${name} is required and must be no longer than ${max} characters.`); return value.trim(); };
175
175
  // Adding a paper names a folder on this host, so only the owner can.
176
176
  app.post('/api/projects', owner, async request => { const b = request.body as any; return workspace.addProject(string(b.path, 'Repository path', 4000), b.name ? string(b.name, 'Paper name', 120) : undefined); });
177
+ app.post('/api/projects/open', owner, request => openFolder((request.body || {}) as any, root => store.projectByPath(root), (root, name) => workspace.addProject(root, name)));
177
178
  app.post('/api/imports', { ...owner, bodyLimit: 90 * 1024 * 1024 }, request => imports!.create((request.body || {}) as any));
178
179
  app.get<{ Params: { id: string } }>('/api/imports/:id', owner, request => imports!.get(request.params.id));
179
180
  app.post<{ Params: { id: string } }>('/api/imports/:id/build', owner, request => imports!.build(request.params.id, (request.body || {}) as any));
@@ -473,6 +474,10 @@ export async function buildApp(options: AppOptions) {
473
474
  if (typeof b?.id !== 'string' || typeof b?.confirmed !== 'boolean') throw new Error('Choose an entry to confirm.');
474
475
  return changes.confirmReference(project.id, project.path, Number(request.params.number), b.id, b.confirmed);
475
476
  });
477
+ app.get<{ Params: { projectId: string; number: string } }>('/review/:projectId/changes/pdf/:number', changesAccess, (request, reply) => {
478
+ const pdf = changes.pdf(store.project(request.params.projectId).id, Number(request.params.number));
479
+ return reply.type('application/pdf').sendFile(path.basename(pdf), path.dirname(pdf), { cacheControl: false });
480
+ });
476
481
  app.get<{ Params: { projectId: string; number: string; file: string } }>('/review/:projectId/changes/files/:number/:file', changesAccess, (request, reply) => {
477
482
  const file = changes.file(store.project(request.params.projectId).id, Number(request.params.number), request.params.file);
478
483
  return reply.header('Cache-Control', 'private, max-age=31536000, immutable').type(file.endsWith('.svg') ? 'image/svg+xml' : 'image/png').send(fs.createReadStream(file));
@@ -491,6 +496,14 @@ export async function buildApp(options: AppOptions) {
491
496
  return serialGit(project.id, () => workspace.exclusiveCommit(project.id, () => changes.undo(project.id, project.path, csrfFor(request.viewer!), id)));
492
497
  });
493
498
 
499
+ // The complete PDF of a round's new version, for the review's full paper.
500
+ // A round may compile only the changed chapters; this compiles them all once.
501
+ app.get<{ Params: { projectId: string; name: string; round: string } }>('/review/:projectId/pdf/:name/:round', { config: { access: { project: 'projectId' } } }, async (request, reply) => {
502
+ const project = store.project(request.params.projectId), name = request.params.name, round = Number(request.params.round);
503
+ if (!/^[a-z0-9-]+$/.test(name) || !Number.isSafeInteger(round) || round < 1) return reply.code(404).send({ error: 'Review resource not found.' });
504
+ const pdf = await fullPaperPdf(project.path, name, round, 'after');
505
+ return reply.type('application/pdf').sendFile(path.basename(pdf), path.dirname(pdf), { cacheControl: false });
506
+ });
494
507
  app.route<{ Params: { projectId: string; '*': string } }>({
495
508
  // The review page posts with the review engine's token rather than Papilot's.
496
509
  method: ['GET', 'POST'], url: '/review/:projectId/*', config: { access: { project: 'projectId' }, csrf: false }, handler: async (request, reply) => {
@@ -528,6 +541,10 @@ export async function buildApp(options: AppOptions) {
528
541
  await workspace.refresh(task.id);
529
542
  }
530
543
  }
544
+ // A round's new version as one complete PDF, which Papilot compiles,
545
+ // lets the review show the changes in the whole paper.
546
+ const round = request.method === 'GET' && response.ok ? /^\/api\/tasks\/([a-z0-9-]+)\/rounds\/(\d+)$/.exec(route) : null;
547
+ if (round) (value as { pdf?: string }).pdf = `${prefix}/pdf/${round[1]}/${round[2]}`;
531
548
  return rewriteReviewJson(value, prefix);
532
549
  }
533
550
  if (reviewTask && writesFiles) throw new Error('The review engine returned an unexpected response. Select Check status before continuing.');
@@ -17,7 +17,7 @@ type Op = ['equal' | 'replace' | 'insert' | 'delete', number, number, number, nu
17
17
  export interface Unit { id: string; file: string; status: 'added' | 'deleted' | 'modified'; kind: string; whole_file: boolean; ops: Op[]; old_start: number; old_end: number; new_start: number; new_end: number; old_text: string; new_text: string; truncated: boolean }
18
18
  type Decision = 'pending' | 'accepted' | 'rejected' | 'commented';
19
19
  export type ChangeAction = 'stage' | 'unstage' | 'revert';
20
- interface Round { number: number; before: string; after: string; units: Unit[]; folder: string; warnings: string[]; decisions: Record<string, Decision> }
20
+ interface Round { number: number; before: string; after: string; units: Unit[]; folder: string; warnings: string[]; decisions: Record<string, Decision>; pdf?: string }
21
21
  interface Job { key: string; phase: 'build' | 'render'; status: 'running' | 'failed'; created: number; error?: string; details?: string[]; log?: string }
22
22
  interface Snapshot { before: string | null; after: string }
23
23
  interface Entry { mode: string; sha: string }
@@ -139,7 +139,7 @@ export class Changes {
139
139
  const { Builder, BuildError, Repo, loadProject, withUnits, render } = await engine();
140
140
  const project = loadProject(root), repo = new Repo(root), number = comparison.next++;
141
141
  const units = await this.units(projectId, root, snapshot), folder = path.join(this.dir, projectId, String(number));
142
- let warnings: string[] = [];
142
+ let warnings: string[] = [], pdf: string | undefined;
143
143
  fs.rmSync(folder, { recursive: true, force: true }); fs.mkdirSync(folder, { recursive: true });
144
144
  if (units.length) {
145
145
  const builder = new Builder(project, repo);
@@ -154,8 +154,9 @@ export class Changes {
154
154
  await render(folder, { main: project.main, source_dir: project.source_dir, figures: project.figures, old, new: next, units },
155
155
  (subset: Unit[]) => builder.build(withUnits(repo, snapshot.before!, snapshot.after, subset), ['full']));
156
156
  warnings = next.warnings.filter((warning: string) => !old.warnings.includes(warning));
157
+ pdf = next.pdf;
157
158
  } else fs.writeFileSync(path.join(folder, 'round.json'), JSON.stringify({ units: {}, sections: [], loose: [] }));
158
- comparison.rounds.push({ number, before: snapshot.before!, after: snapshot.after, units, folder, warnings, decisions: {} });
159
+ comparison.rounds.push({ number, before: snapshot.before!, after: snapshot.after, units, folder, warnings, decisions: {}, pdf });
159
160
  // The viewer may still show the round before; older ones go.
160
161
  for (const old of comparison.rounds.splice(0, Math.max(0, comparison.rounds.length - 3))) fs.rmSync(old.folder, { recursive: true, force: true });
161
162
  if (comparison.job === job) comparison.job = null;
@@ -203,6 +204,8 @@ export class Changes {
203
204
  task: 'changes', round: round.number, before: round.before, after: round.after,
204
205
  units: round.units.map(unit => ({ ...unit, ...render.units[unit.id] })), sections: render.sections, loose: render.loose,
205
206
  warnings: round.warnings, files: `/review/${projectId}/changes/files/${round.number}/`,
207
+ // The working tree compiles in full, so its PDF shows the whole paper.
208
+ ...(round.pdf ? { pdf: `/review/${projectId}/changes/pdf/${round.number}` } : {}),
206
209
  ...(render.references ? { references: render.references } : {}),
207
210
  };
208
211
  }
@@ -238,6 +241,12 @@ export class Changes {
238
241
  return { places: await more(round.folder, unit) };
239
242
  }
240
243
 
244
+ pdf(projectId: string, number: number) {
245
+ const file = this.comparison(projectId).rounds.find(item => item.number === number)?.pdf;
246
+ if (!file || !fs.existsSync(file)) throw Object.assign(new Error('This comparison is out of date. Refresh the page.'), { statusCode: 404 });
247
+ return file;
248
+ }
249
+
241
250
  file(projectId: string, number: number, name: string) {
242
251
  const round = this.comparison(projectId).rounds.find(item => item.number === number);
243
252
  const file = round && /^[\w.-]+\.(svg|png)$/.test(name) ? path.join(round.folder, name) : '';
@@ -88,18 +88,25 @@ export async function inspectPaper(root: string) {
88
88
  const found = new Set(roots);
89
89
  candidates = [...roots, ...files.filter(file => !found.has(file))];
90
90
  }
91
- try {
92
- // Engine detection also works before Git initialization by reading the source hints.
93
- if (main) {
94
- const text = sourceHead(path.join(root, main));
95
- engine = typeof settings.engine === 'string' ? settings.engine : '';
96
- const rc = [path.join(path.dirname(main), '.latexmkrc'), path.join(path.dirname(main), 'latexmkrc'), '.latexmkrc', 'latexmkrc'].find(file => fs.existsSync(path.join(root, file)));
97
- const mode = rc ? fs.readFileSync(path.join(root, rc), 'utf8').match(/^\s*\$pdf_mode\s*=\s*(\d)/m)?.[1] : undefined;
98
- if (!engine) engine = text.match(/^%\s*!\s*TEX\s+(?:TS-)?program\s*=\s*(pdflatex|xelatex|lualatex)/im)?.[1].toLowerCase() || ({ '1': 'pdflatex', '4': 'lualatex', '5': 'xelatex' } as Record<string, string>)[mode || ''] || (/\\(?:usepackage(?:\[[^\]]*\])?\{[^}]*\b(?:fontspec|xeCJK|unicode-math|polyglossia)\b|documentclass(?:\[[^\]]*\])?\{ctex)/.test(text) ? 'xelatex' : 'pdflatex');
99
- }
100
- } catch (failure) { error = (failure as Error).message; }
91
+ try { if (main) engine = engineFor(root, main, settings); } catch (failure) { error = (failure as Error).message; }
101
92
  return { candidates, main, engine: engine || 'pdflatex', error };
102
93
  }
94
+ // Engine detection also works before Git initialization by reading the source hints.
95
+ function engineFor(root: string, main: string, settings: Record<string, unknown>) {
96
+ if (typeof settings.engine === 'string' && settings.engine) return settings.engine;
97
+ const text = sourceHead(path.join(root, main));
98
+ const rc = [path.join(path.dirname(main), '.latexmkrc'), path.join(path.dirname(main), 'latexmkrc'), '.latexmkrc', 'latexmkrc'].find(file => fs.existsSync(path.join(root, file)));
99
+ const mode = rc ? fs.readFileSync(path.join(root, rc), 'utf8').match(/^\s*\$pdf_mode\s*=\s*(\d)/m)?.[1] : undefined;
100
+ return text.match(/^%\s*!\s*TEX\s+(?:TS-)?program\s*=\s*(pdflatex|xelatex|lualatex)/im)?.[1].toLowerCase() || ({ '1': 'pdflatex', '4': 'lualatex', '5': 'xelatex' } as Record<string, string>)[mode || ''] || (/\\(?:usepackage(?:\[[^\]]*\])?\{[^}]*\b(?:fontspec|xeCJK|unicode-math|polyglossia)\b|documentclass(?:\[[^\]]*\])?\{ctex)/.test(text) ? 'xelatex' : 'pdflatex');
101
+ }
102
+ // Keep the author's settings, including a source folder that still contains
103
+ // the main file; without one, the whole folder compiles.
104
+ function rememberMain(root: string, main: string, engine: string) {
105
+ const settings = config(root), folder = typeof settings.source_dir === 'string' ? settings.source_dir.replace(/\/+$/, '') : null;
106
+ const contains = folder === '' || (folder !== null && main.startsWith(folder + '/'));
107
+ const next = { ...settings, main, engine, ...(contains ? {} : { source_dir: '' }) };
108
+ if (JSON.stringify(next) !== JSON.stringify(settings)) fs.writeFileSync(path.join(root, CONFIG), JSON.stringify(next, null, 2) + '\n');
109
+ }
103
110
  async function repositoryRoot(root: string) {
104
111
  let top: string | undefined;
105
112
  try { top = await git(root, 'rev-parse', '--show-toplevel'); } catch { /* A plain folder needs Git. */ }
@@ -115,6 +122,28 @@ export async function prepareRepository(root: string) {
115
122
  await git(root, 'add', '--all');
116
123
  await git(root, '-c', 'user.name=Papilot', '-c', 'user.email=papilot@localhost', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'Import paper into Papilot');
117
124
  }
125
+ // The desktop app opens a folder the way an editor does. A paper already in
126
+ // Papilot opens again; any other folder with a clear main file is added as it
127
+ // is and compiles in its own view. Only a folder with several possible main
128
+ // files returns them, so the author can pick one.
129
+ export async function openFolder(input: { path?: unknown; main?: unknown }, known: (root: string) => Project | undefined, register: (root: string, name: string) => Promise<Project>): Promise<{ project: Project } | { name: string; candidates: string[] }> {
130
+ if (typeof input.path !== 'string' || !input.path.trim()) throw new Error('Enter a folder path.');
131
+ const root = fs.realpathSync(input.path);
132
+ if (!fs.statSync(root).isDirectory()) throw new Error('Select a folder containing the paper.');
133
+ const project = known(root);
134
+ if (project) return { project };
135
+ await repositoryRoot(root);
136
+ const detected = await inspectPaper(root);
137
+ if (detected.error) throw new Error(detected.error);
138
+ if (input.main === undefined && !detected.main) {
139
+ if (!detected.candidates.length) throw new Error('Select a folder containing the paper.');
140
+ return { name: path.basename(root), candidates: detected.candidates };
141
+ }
142
+ const main = input.main ?? detected.main;
143
+ if (!existingTex(root, main)) throw new Error('Choose an existing TeX main file.');
144
+ rememberMain(root, main, engineFor(root, main, config(root)));
145
+ return { project: await register(root, path.basename(root)) };
146
+ }
118
147
  export async function extractZip(buffer: Buffer, root: string) {
119
148
  if (buffer.length > MAX_BYTES) throw new Error('Choose a ZIP smaller than 64 MB.');
120
149
  const zip = await new Promise<yauzl.ZipFile>((resolve, reject) => yauzl.fromBuffer(buffer, { lazyEntries: true, strictFileNames: true, validateEntrySizes: true }, (error, value) => error ? reject(error) : resolve(value)));
@@ -149,7 +178,9 @@ export class PaperImports {
149
178
  private save(value: PaperImport) {
150
179
  fs.mkdirSync(path.join(this.directory, 'imports'), { recursive: true, mode: 0o700 });
151
180
  const file = path.join(this.directory, 'imports', value.id + '.json');
152
- fs.writeFileSync(file + '.tmp', JSON.stringify(value), { mode: 0o600 }); fs.renameSync(file + '.tmp', file);
181
+ // Progress is live state; a restarted server fails a running import anyway.
182
+ const { progress: _progress, ...stored } = value;
183
+ fs.writeFileSync(file + '.tmp', JSON.stringify(stored), { mode: 0o600 }); fs.renameSync(file + '.tmp', file);
153
184
  this.sessions.set(value.id, value); return { ...value };
154
185
  }
155
186
  get(id: string) {
@@ -207,19 +238,12 @@ export class PaperImports {
207
238
  if (input.name !== undefined && (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 120)) throw new Error('Enter a paper name under 120 characters.');
208
239
  this.active.add(id);
209
240
  const previousError = value.error || '';
210
- Object.assign(value, { main, engine, ...(input.name ? { name: input.name } : {}), phase: repair ? 'repairing' : 'building', error: undefined });
241
+ Object.assign(value, { main, engine, ...(input.name ? { name: input.name } : {}), phase: repair ? 'repairing' : 'building', error: undefined, startedAt: Date.now(), progress: undefined });
211
242
  this.save(value);
212
243
  void (async () => {
213
244
  try {
214
245
  await repositoryRoot(value.path);
215
- if (!repair) {
216
- // Keep the author's settings, including a source folder that still
217
- // contains the main file; without one, the whole folder compiles.
218
- const settings = config(value.path), folder = typeof settings.source_dir === 'string' ? settings.source_dir.replace(/\/+$/, '') : null;
219
- const contains = folder === '' || (folder !== null && (main as string).startsWith(folder + '/'));
220
- const next = { ...settings, main, engine, ...(contains ? {} : { source_dir: '' }) };
221
- if (JSON.stringify(next) !== JSON.stringify(settings)) fs.writeFileSync(path.join(value.path, CONFIG), JSON.stringify(next, null, 2) + '\n');
222
- }
246
+ if (!repair) rememberMain(value.path, main as string, engine as string);
223
247
  await prepareRepository(value.path); value.versioned = true;
224
248
  if (repair) { value.summary = await repair(value.path, previousError); Object.assign(value, await inspectPaper(value.path)); }
225
249
  value.phase = 'building'; this.save(value);
@@ -227,10 +251,13 @@ export class PaperImports {
227
251
  // opened paper finds this PDF in the cache instead of compiling again.
228
252
  this.manuscripts.forgetProject('import-' + id);
229
253
  let result = await this.manuscripts.state('import-' + id, value.path);
230
- while (result.build.status === 'building') { await new Promise(resolve => setTimeout(resolve, 400)); result = await this.manuscripts.state('import-' + id, value.path); }
254
+ while (result.build.status === 'building') {
255
+ value.progress = result.build.progress;
256
+ await new Promise(resolve => setTimeout(resolve, 400)); result = await this.manuscripts.state('import-' + id, value.path);
257
+ }
231
258
  value.phase = result.build.status === 'ready' ? 'ready' : 'failed'; value.error = result.build.error;
232
259
  } catch (error) { value.phase = 'failed'; value.error = (error as Error).message; }
233
- finally { this.active.delete(id); this.save(value); }
260
+ finally { this.active.delete(id); value.progress = undefined; this.save(value); }
234
261
  })();
235
262
  return this.get(id);
236
263
  }
@@ -3,8 +3,9 @@ import { pathToFileURL } from 'node:url';
3
3
  import { ROOT, DATA } from './paths.ts';
4
4
  import { activeInstallation, waitForActivation } from '../../bin/installation.mjs';
5
5
 
6
- await waitForActivation(DATA);
7
- const root = activeInstallation(ROOT, DATA);
6
+ const desktop = process.env.PAPILOT_DESKTOP === '1';
7
+ if (!desktop) await waitForActivation(DATA);
8
+ const root = desktop ? ROOT : activeInstallation(ROOT, DATA);
8
9
  // A source checkout's default data directory must not move with the release.
9
10
  process.env.PAPILOT_DATA = DATA;
10
11
  await import(pathToFileURL(path.join(root, 'src/server/start.ts')).href);
@@ -8,7 +8,7 @@ import path from 'node:path';
8
8
  import { promisify } from 'node:util';
9
9
  import { pathToFileURL } from 'node:url';
10
10
  import { ENGINE } from './paths.ts';
11
- import type { Annotation, BuildProblem, GitFile, GitStatus, ManuscriptBuild, ManuscriptProblems } from '../shared.ts';
11
+ import type { Annotation, BuildProblem, BuildProgress, GitFile, GitStatus, ManuscriptBuild, ManuscriptProblems } from '../shared.ts';
12
12
  import { notesInstructions } from '../notes.ts';
13
13
  import { ignorePattern } from '../commit.ts';
14
14
  import { AnnotationPages, placeAnnotation } from './annotation-placement.ts';
@@ -360,7 +360,7 @@ export async function pdfWords(pdf: string, page: number) {
360
360
  // `problems` come from the last finished build. They are placed on the PDF
361
361
  // named `problemsKey`; a failed build has no PDF of its own to place them on.
362
362
  interface Build {
363
- tree: string; status: ManuscriptBuild['status']; key?: string; pdf?: string; root?: string; sourceDir?: string; pages?: [number, number][]; builtAt?: number; error?: string;
363
+ tree: string; status: ManuscriptBuild['status']; key?: string; pdf?: string; root?: string; sourceDir?: string; pages?: [number, number][]; builtAt?: number; error?: string; progress?: BuildProgress;
364
364
  problems?: StoredProblem[]; problemsKey?: string; log?: string; logFolder?: string;
365
365
  }
366
366
 
@@ -402,7 +402,7 @@ export class Manuscripts extends EventEmitter {
402
402
  let build = this.builds.get(projectId);
403
403
  if (!build || (build.status !== 'building' && (build.tree !== tree || rebuild))) build = this.start(projectId, root, tree, build, rebuild);
404
404
  const { status: state, key, pages, builtAt, error } = build;
405
- return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready', problems: build.problems && countProblems(build.problems) } };
405
+ return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready', problems: build.problems && countProblems(build.problems), ...(state === 'building' && build.progress ? { progress: build.progress } : {}) } };
406
406
  }
407
407
 
408
408
  // Keep the previous pages visible while a newer working tree compiles.
@@ -415,7 +415,8 @@ export class Manuscripts extends EventEmitter {
415
415
  try {
416
416
  const { Builder, Repo, loadProject, mupdf } = await engine();
417
417
  project = loadProject(root);
418
- const result = await new Builder(project, new Repo(root)).build(tree, ['full'], null, { force });
418
+ const startedAt = Date.now();
419
+ const result = await new Builder(project, new Repo(root)).build(tree, ['full'], null, { force, progress: (progress: Omit<BuildProgress, 'startedAt'>) => { if (this.builds.get(projectId) === build) build.progress = { ...progress, startedAt }; } });
419
420
  const document = mupdf.Document.openDocument(fs.readFileSync(result.pdf), 'application/pdf');
420
421
  const pages: [number, number][] = [];
421
422
  for (let i = 0; i < document.countPages(); i++) { const [x0, y0, x1, y1] = document.loadPage(i).getBounds(); pages.push([Math.round(x1 - x0), Math.round(y1 - y0)]); }
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
5
5
  export const DATA = path.resolve(process.env.PAPILOT_DATA || path.join(ROOT, '.papilot'));
6
- export const ENGINE = path.resolve(process.env.PAPILOT_REVIEW_ENGINE || path.join(ROOT, 'vendor/latex-review'));
6
+ export const ENGINE = path.resolve(process.env.PAPILOT_REVIEW_ENGINE || path.join(ROOT, 'engine'));
7
7
  export const VERSION: string = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
8
8
  export const PORT = Number(process.env.PAPILOT_PORT || 7274);
9
9
  export const PUBLIC_ORIGIN = process.env.PAPILOT_PUBLIC_ORIGIN || '';
@@ -10,7 +10,6 @@ import { Manuscripts } from './manuscript.ts';
10
10
  import { Changes } from './changes.ts';
11
11
  import { buildApp, gitQueue } from './app.ts';
12
12
  import { Updates } from './updates.ts';
13
- import { installRelease } from './update-install.ts';
14
13
  import { readJson } from '../../bin/installation.mjs';
15
14
  import { desktopDemo } from './desktop-demo.ts';
16
15
 
@@ -22,16 +21,10 @@ const accounts = new Accounts(store, ACCOUNT_ORIGIN || null);
22
21
  const manuscripts = new Manuscripts(), changes = new Changes();
23
22
  const desktop = process.env.PAPILOT_DESKTOP === '1';
24
23
  if (desktop) await desktopDemo(ROOT, DATA, store, workspace, manuscripts).catch(error => console.error('Could not prepare the demo paper:', error));
25
- // A packaged desktop app installs newer releases and restarts them itself.
26
- const shell = desktop && process.send ? Number(process.env.PAPILOT_DESKTOP_SHELL) || 0 : 0, downloadUrl = 'https://github.com/PapilotAI/papilot/releases/latest';
27
- const updates = new Updates({ root: ROOT, data: DATA, version: VERSION, port: PORT,
28
- ...(shell ? {
29
- desktop: { shell, installer: process.env.PAPILOT_DESKTOP_INSTALLER || VERSION, downloadUrl },
30
- install: (data: string, version: string) => installRelease(data, version, { shell, codex: process.env.PAPILOT_DESKTOP_CODEX }),
31
- restart: async (root: string, version: string) => { process.send!({ type: 'papilot:activate', root, version }); },
32
- } : desktop ? { supported: false, downloadUrl } : {}),
24
+ // Desktop releases are complete installers; npm updates belong to the web server.
25
+ const updates = new Updates({ root: ROOT, data: DATA, version: VERSION, port: PORT, desktop,
33
26
  busy: () => workspace.busyForUpdate || manuscripts.busyForUpdate || changes.busyForUpdate, shutdown: () => shutdown() });
34
- const activation = readJson(path.join(DATA, 'updates/active.json'));
27
+ const activation = desktop ? null : readJson(path.join(DATA, 'updates/active.json'));
35
28
  const updateInstance = typeof activation?.root === 'string' && fs.existsSync(activation.root) && fs.realpathSync(activation.root) === fs.realpathSync(ROOT) ? activation.id : undefined;
36
29
  const shared = {
37
30
  store, workspace, accounts, manuscripts, changes, updates, updateInstance, csrf: randomBytes(32).toString('base64url'), serialGit: gitQueue(),