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.
- package/README.md +2 -2
- package/bin/doctor.mjs +1 -1
- package/dist/assets/{_virtual_papilot-licenses-CMfCtEYQ.js → _virtual_papilot-licenses-YJbh9c1G.js} +1 -1
- package/dist/assets/index-CwFvCo6r.css +1 -0
- package/dist/assets/index-dByEATYE.js +267 -0
- package/dist/assets/{math-CEU4m4-H.js → math-UelUOI-R.js} +1 -1
- package/dist/assets/{pdf-view-HWRGBLsZ.js → pdf-view-ChJvfsD8.js} +1 -1
- package/dist/index.html +2 -2
- package/engine/dist/web/assets/FullPaper-DbT25OhQ.js +54 -0
- package/engine/dist/web/assets/index-C_KwLmXQ.js +19 -0
- package/engine/dist/web/assets/index-D3_ZFdT8.css +1 -0
- package/engine/dist/web/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
- package/{vendor/latex-review → engine}/dist/web/index.html +2 -2
- package/engine/package-lock.json +3390 -0
- package/engine/package.json +50 -0
- package/{vendor/latex-review → engine}/src/core/build.ts +22 -6
- package/engine/src/core/progress.ts +67 -0
- package/{vendor/latex-review → engine}/src/core/review.ts +32 -0
- package/{vendor/latex-review → engine}/src/render/inline.ts +1 -0
- package/{vendor/latex-review → engine}/src/render/locate.ts +26 -16
- package/engine/src/render/positions.ts +34 -0
- package/engine/src/render/synctex.ts +188 -0
- package/engine/src/render/synctex.wasm +0 -0
- package/engine/src/render/synctex.wasm.LICENSE +50 -0
- package/{vendor/latex-review → engine}/src/server.ts +5 -3
- package/{vendor/latex-review → engine}/src/types.ts +13 -0
- package/engine/src/web/FullPaper.tsx +318 -0
- package/{vendor/latex-review → engine}/src/web/locales/de.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/es.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/fr.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/it.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/ja.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/ko.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/pt.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/sv.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/zh-Hant.json +3 -0
- package/engine/src/web/paper.ts +13 -0
- package/{vendor/latex-review → engine}/src/web/review.tsx +94 -1
- package/{vendor/latex-review → engine}/src/web/style.css +70 -0
- package/engine/src/web/view-preferences.ts +15 -0
- package/{vendor/latex-review → engine}/src/web/zh-CN.ts +3 -0
- package/package.json +10 -8
- package/src/conversation.ts +1 -1
- package/src/server/app.ts +18 -1
- package/src/server/changes.ts +12 -3
- package/src/server/imports.ts +49 -22
- package/src/server/main.ts +3 -2
- package/src/server/manuscript.ts +5 -4
- package/src/server/paths.ts +1 -1
- package/src/server/start.ts +3 -10
- package/src/server/update-install.ts +8 -25
- package/src/server/updates.ts +43 -25
- package/src/shared.ts +6 -3
- package/vendor/latex-review/package-lock.json +4 -3106
- package/vendor/latex-review/package.json +3 -46
- package/README.de.md +0 -110
- package/README.es.md +0 -110
- package/README.fr.md +0 -110
- package/README.it.md +0 -110
- package/README.ja.md +0 -110
- package/README.ko.md +0 -110
- package/README.pt.md +0 -110
- package/README.sv.md +0 -110
- package/README.zh-CN.md +0 -110
- package/README.zh-Hant.md +0 -110
- package/dist/assets/index-oAIkxhfp.js +0 -265
- package/dist/assets/index-t_qCOF_B.css +0 -1
- package/vendor/latex-review/dist/web/assets/index-DVs7cTBl.js +0 -19
- package/vendor/latex-review/dist/web/assets/index-ysOpu05D.css +0 -1
- /package/{vendor/latex-review → engine}/LICENSE +0 -0
- /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
- /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
- /package/{vendor/latex-review → engine}/src/cli.ts +0 -0
- /package/{vendor/latex-review → engine}/src/client.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/bib.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/changes.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/config.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/current-commit.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/delivery.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/diff.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/files.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/git.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/outline.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/process.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/publish.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/reconcile.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/store.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/tasks.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/verify.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/view.ts +0 -0
- /package/{vendor/latex-review → engine}/src/locales.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/effects.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/pdf.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/pdfdiff.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/round.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/worker.ts +0 -0
- /package/{vendor/latex-review → engine}/src/runtime.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/ChangeMap.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/PdfStrip.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/effect-preferences.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/GUST-FONT-LICENSE.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/LPPL.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/MANIFEST-newpx.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/README-newpx.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/README.md +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
- /package/{vendor/latex-review → engine}/src/web/i18n.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/index.html +0 -0
- /package/{vendor/latex-review → engine}/src/web/locales/index.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/main.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/model.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/overview.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/references.tsx +0 -0
- /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.
|
|
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
|
-
"
|
|
52
|
-
"
|
|
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":
|
|
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
|
|
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
|
|
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",
|
package/src/conversation.ts
CHANGED
|
@@ -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
|
|
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.');
|
package/src/server/changes.ts
CHANGED
|
@@ -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) : '';
|
package/src/server/imports.ts
CHANGED
|
@@ -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
|
-
|
|
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') {
|
|
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
|
}
|
package/src/server/main.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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);
|
package/src/server/manuscript.ts
CHANGED
|
@@ -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
|
|
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)]); }
|
package/src/server/paths.ts
CHANGED
|
@@ -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, '
|
|
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 || '';
|
package/src/server/start.ts
CHANGED
|
@@ -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
|
-
//
|
|
26
|
-
const
|
|
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(),
|