papilot 0.8.5 → 0.8.6

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/dist/index.html CHANGED
@@ -8,6 +8,6 @@
8
8
  document.documentElement.dataset.theme = dark ? 'dark' : 'light';
9
9
  document.querySelector('meta[name="theme-color"]').content = dark ? '#26272b' : '#ffffff';
10
10
  })();
11
- </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-DSWizoaI.js"></script>
11
+ </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-Dm7AYM9e.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-PFQDdu53.css">
13
13
  </head><body><div id="root"></div></body></html>
@@ -263,12 +263,13 @@ export class Builder {
263
263
  fs.utimesSync(folder, new Date(), new Date());
264
264
  return cached;
265
265
  }
266
- // A full build continues from the latest completed one, or from the
267
- // author's own latexmk output, as latexmk does when a folder is
268
- // compiled again: unchanged files are cloned and LaTeX keeps its state,
269
- // so it reruns only as often as the edit requires. Manual recompilation
270
- // starts clean, and so does a retry when leftover state broke a build.
271
- const start = partial || options.force ? null : this.latest(context) ?? "author";
266
+ // A build continues from the latest completed one with the same
267
+ // targets, or a full build from the author's own latexmk output, as
268
+ // latexmk does when a folder is compiled again: unchanged files are
269
+ // cloned and LaTeX keeps its state, so it reruns only as often as the
270
+ // edit requires. Manual recompilation starts clean, and so does a retry
271
+ // when leftover state broke a build.
272
+ const start = options.force ? null : this.latest(context, targets) ?? (partial ? null : "author");
272
273
  let result: BuildInfo;
273
274
  try {
274
275
  result = await this.compile(rev, tree, key, targets, seed, date, start, options.progress);
@@ -276,15 +277,21 @@ export class Builder {
276
277
  if (!start || (error instanceof BuildError && !error.details.some(line => /(?:^|\s|\()(?:\.\/)?build\//.test(line)))) throw error;
277
278
  result = await this.compile(rev, tree, key, targets, seed, date, null, options.progress);
278
279
  }
279
- if (!partial) writeJson(path.join(this.cache, "latest.json"), { key, context, date: date.SOURCE_DATE_EPOCH });
280
+ writeJson(this.latestFile(targets), { key, context, date: date.SOURCE_DATE_EPOCH });
280
281
  return result;
281
282
  });
282
283
  }
283
- private latest(context: string): { info: BuildInfo; date: unknown } | null {
284
- const latest = readJson<{ key?: unknown; context?: unknown; date?: unknown } | null>(path.join(this.cache, "latest.json"), null);
284
+ // Review rounds build the same chapters again and again (\includeonly), so
285
+ // each set of targets continues from its own latest build.
286
+ private latestFile(targets: string[]) {
287
+ const name = JSON.stringify(targets) === '["full"]' ? "latest.json" : `latest-${hash(JSON.stringify(targets)).slice(0, 16)}.json`;
288
+ return path.join(this.cache, name);
289
+ }
290
+ private latest(context: string, targets: string[]): { info: BuildInfo; date: unknown } | null {
291
+ const latest = readJson<{ key?: unknown; context?: unknown; date?: unknown } | null>(this.latestFile(targets), null);
285
292
  if (typeof latest?.key !== "string" || !/^[0-9a-f]{32}$/.test(latest.key) || latest.context !== context) return null;
286
293
  const info = readJson<BuildInfo | null>(path.join(this.cache, latest.key, "result.json"), null);
287
- return info && JSON.stringify(info.targets) === '["full"]' && fs.existsSync(info.pdf) && fs.existsSync(info.root) ? { info, date: latest.date } : null;
294
+ return info && JSON.stringify(info.targets) === JSON.stringify(targets) && fs.existsSync(info.pdf) && fs.existsSync(info.root) ? { info, date: latest.date } : null;
288
295
  }
289
296
  private async compile(
290
297
  rev: string,
@@ -353,12 +360,16 @@ export class Builder {
353
360
  "-file-line-error",
354
361
  ];
355
362
  if (partial) {
356
- const seedOut = path.dirname(seed!.pdf);
363
+ // Chapters outside \includeonly keep the seed's auxiliary files, also
364
+ // when this build continues from an earlier one built on another seed.
365
+ // latexmk compares contents, so an unchanged chapter causes no rerun.
366
+ const seedOut = path.dirname(seed!.pdf),
367
+ others = new Set(included.filter((target) => !targets.includes(target)).map((target) => path.join(...target.split("/")) + ".aux"));
357
368
  for (const file of listFiles(seedOut))
358
369
  if (AUXILIARY.has(path.extname(file))) {
359
- const target = path.join(out, path.relative(seedOut, file));
370
+ const relative = path.relative(seedOut, file), target = path.join(out, relative);
360
371
  fs.mkdirSync(path.dirname(target), { recursive: true });
361
- if (!fs.existsSync(target)) fs.copyFileSync(file, target);
372
+ if (!fs.existsSync(target) || others.has(relative)) fs.copyFileSync(file, target);
362
373
  }
363
374
  const preload = Object.entries(p.preload)
364
375
  .filter(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
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",
@@ -106,7 +106,10 @@ async function installDev(data: string, version: string) {
106
106
  const INSTALL_FAILED = 'The new version could not be installed. Check the connection and available disk space, then retry.';
107
107
  // Release assets redirect from github.com to GitHub's download hosts. Each hop
108
108
  // stays on HTTPS and on those hosts (or on the smoke test's local source).
109
- export async function download(url: string, fetcher: typeof fetch, limit: number) {
109
+ // With missing, a file that does not exist resolves to null.
110
+ export async function download(url: string, fetcher: typeof fetch, limit: number, missing: true): Promise<Buffer | null>;
111
+ export async function download(url: string, fetcher: typeof fetch, limit: number): Promise<Buffer>;
112
+ export async function download(url: string, fetcher: typeof fetch, limit: number, missing = false): Promise<Buffer | null> {
110
113
  const source = process.env.PAPILOT_DESKTOP_UPDATE_SOURCE ? new URL(process.env.PAPILOT_DESKTOP_UPDATE_SOURCE).origin : null;
111
114
  const trusted = (address: URL) => address.origin === source
112
115
  || address.protocol === 'https:' && (address.hostname === 'github.com' || address.hostname.endsWith('.githubusercontent.com'));
@@ -116,6 +119,7 @@ export async function download(url: string, fetcher: typeof fetch, limit: number
116
119
  const response = await fetcher(address.href, { signal: AbortSignal.timeout(300000), redirect: 'manual', headers: { Accept: 'application/octet-stream', 'User-Agent': 'Papilot' } });
117
120
  const location = response.headers.get('location');
118
121
  if (response.status >= 300 && response.status < 400 && location) { address = new URL(location, address); continue; }
122
+ if (missing && response.status === 404) return null;
119
123
  if (!response.ok || Number(response.headers.get('content-length') || 0) > limit) throw new Error(INSTALL_FAILED);
120
124
  const bytes = Buffer.from(await response.arrayBuffer());
121
125
  if (bytes.length > limit) throw new Error(INSTALL_FAILED);
@@ -8,9 +8,8 @@ import type { UpdateChannel, UpdateStatus } from '../shared.ts';
8
8
 
9
9
  const activePhases = new Set(['installing', 'waiting', 'restarting']);
10
10
  export const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
11
- async function releaseInfo(url: string, fetcher: typeof fetch, limit = 256000, missing?: null) {
11
+ async function releaseInfo(url: string, fetcher: typeof fetch, limit = 256000) {
12
12
  const response = await fetcher(url, { signal: AbortSignal.timeout(10000), redirect: 'error', headers: { Accept: 'application/json', 'User-Agent': 'Papilot' } });
13
- if (response.status === 404 && missing === null) return null;
14
13
  if (!response.ok) throw new Error('Could not check for updates.');
15
14
  const text = await response.text();
16
15
  if (text.length > limit) throw new Error('Invalid release information.');
@@ -34,53 +33,29 @@ export const latestRelease = async (channel: UpdateChannel = 'stable', fetcher =
34
33
  export const DESKTOP_RELEASES = 'https://github.com/PapilotAI/papilot/releases';
35
34
  // Nightly builds of main replace the assets of this one prerelease.
36
35
  export const DESKTOP_DEV_TAG = 'desktop-dev';
37
- const DESKTOP_API = 'https://api.github.com/repos/PapilotAI/papilot';
38
- // The smoke test serves releases locally.
39
- const desktopApi = () => process.env.PAPILOT_DESKTOP_UPDATE_SOURCE ? process.env.PAPILOT_DESKTOP_UPDATE_SOURCE.replace(/\/+$/, '') + '/repos/PapilotAI/papilot' : DESKTOP_API;
36
+ // Release downloads, not the rate-limited REST API. The smoke test serves
37
+ // them locally.
38
+ const desktopDownloads = () => (process.env.PAPILOT_DESKTOP_UPDATE_SOURCE ? process.env.PAPILOT_DESKTOP_UPDATE_SOURCE.replace(/\/+$/, '') + '/PapilotAI/papilot/releases' : DESKTOP_RELEASES);
40
39
  export interface DesktopRelease { version: string; downloadUrl: string; layer?: DesktopLayer }
41
- const asset = (release: any, name: unknown) => release.assets.find((asset: any) => asset.name === name && asset.state === 'uploaded' && asset.size > 0 && typeof asset.browser_download_url === 'string');
42
- // desktop.json (written by scripts/desktop-layer.mjs) names the release's
43
- // version, its update layer and the installer shell that layer runs on.
44
- async function desktopManifest(release: any, fetcher: typeof fetch) {
45
- const listed = asset(release, 'desktop.json');
46
- if (!listed) return null;
47
- try { return JSON.parse((await download(listed.browser_download_url, fetcher, 64 * 1024)).toString('utf8')); }
48
- catch { throw new Error('Could not check for updates.'); }
49
- }
50
- function desktopLayer(release: any, manifest: any, version: string, fingerprint: string): DesktopLayer | undefined {
51
- const layer = manifest?.layer, file = layer && asset(release, layer.name);
52
- if (!fingerprint || manifest?.format !== 1 || manifest.version !== version || manifest.fingerprint !== fingerprint || !file || file.size !== layer.size || !/^[a-f0-9]{64}$/.test(layer.sha256)) return undefined;
53
- return { url: file.browser_download_url, size: layer.size, sha256: layer.sha256 };
54
- }
55
- // Stable: npm can be published before desktop builds finish. Only advertise
56
- // releases with an uploaded installer for this OS and CPU, and link to that
57
- // exact release. Dev: the latest nightly build of main. Either includes the update layer
58
- // when it was built for this installer's shell (fingerprint).
59
- export async function latestDesktopRelease(platform = process.platform, arch = process.arch, fetcher = fetch, channel: UpdateChannel = 'stable', fingerprint = ''): Promise<DesktopRelease | null> {
60
- if (channel === 'dev') {
61
- const release = await releaseInfo(`${desktopApi()}/releases/tags/${DESKTOP_DEV_TAG}`, fetcher, 4 * 1024 * 1024, null);
62
- if (!release) return null;
63
- if (!Array.isArray(release.assets) || release.draft !== false) throw new Error('Invalid release information.');
64
- const manifest = await desktopManifest(release, fetcher);
65
- if (!manifest) return null;
66
- if (!devRevision(manifest.version)) throw new Error('Invalid release information.');
67
- const layer = desktopLayer(release, manifest, manifest.version, fingerprint);
68
- return { version: manifest.version, downloadUrl: `${DESKTOP_RELEASES}/tag/${DESKTOP_DEV_TAG}`, ...(layer ? { layer } : {}) };
69
- }
70
- const releases = await releaseInfo(`${desktopApi()}/releases?per_page=100`, fetcher, 4 * 1024 * 1024);
71
- if (!Array.isArray(releases)) throw new Error('Invalid release information.');
72
- let latest: { version: string; downloadUrl: string; release: any } | null = null;
73
- for (const release of releases) {
74
- const version = typeof release.tag_name === 'string' ? release.tag_name.replace(/^v/, '') : '';
75
- if (release.draft !== false || release.prerelease !== false || !stableVersion(version) || !Array.isArray(release.assets)) continue;
76
- const names = platform === 'darwin' && ['arm64', 'x64'].includes(arch) ? [`Papilot-${version}-mac-${arch}.dmg`, `Papilot-${version}-mac-${arch}.zip`]
77
- : platform === 'linux' && ['arm64', 'x64'].includes(arch) ? [`Papilot-${version}-linux-${arch === 'x64' ? 'x86_64' : arch}.AppImage`, `Papilot-${version}-linux-${arch === 'x64' ? 'amd64' : arch}.deb`] : [];
78
- if (!release.assets.some((asset: any) => asset.state === 'uploaded' && asset.size > 0 && names.includes(asset.name))) continue;
79
- if (!latest || newerVersion(version, latest.version)) latest = { version, downloadUrl: `${DESKTOP_RELEASES}/tag/${release.tag_name}`, release };
80
- }
81
- if (!latest) return null;
82
- const layer = fingerprint ? desktopLayer(latest.release, await desktopManifest(latest.release, fetcher), latest.version, fingerprint) : undefined;
83
- return { version: latest.version, downloadUrl: latest.downloadUrl, ...(layer ? { layer } : {}) };
40
+ // desktop.json (written by scripts/desktop-layer.mjs, uploaded after every
41
+ // other asset of its release) names the version, its update layer and the
42
+ // installer shell that layer runs on. Stable: the latest release. Dev: the
43
+ // latest nightly build of main. The layer is offered only to that shell.
44
+ export async function latestDesktopRelease(fetcher = fetch, channel: UpdateChannel = 'stable', fingerprint = ''): Promise<DesktopRelease | null> {
45
+ const folder = channel === 'dev' ? `download/${DESKTOP_DEV_TAG}` : 'latest/download';
46
+ let manifest: any;
47
+ try {
48
+ const bytes = await download(`${desktopDownloads()}/${folder}/desktop.json`, fetcher, 64 * 1024, true);
49
+ if (!bytes) return null;
50
+ manifest = JSON.parse(bytes.toString('utf8'));
51
+ } catch { throw new Error('Could not check for updates.'); }
52
+ const version = manifest?.version;
53
+ if (manifest?.format !== 1 || (channel === 'dev' ? !devRevision(version) : !stableVersion(version))) throw new Error('Invalid release information.');
54
+ const tag = channel === 'dev' ? DESKTOP_DEV_TAG : `v${version}`, layer = manifest.layer;
55
+ const usable = fingerprint && manifest.fingerprint === fingerprint && typeof layer?.name === 'string' && /^papilot-app-[\w.-]+\.tgz$/.test(layer.name)
56
+ && Number.isSafeInteger(layer.size) && layer.size > 0 && /^[a-f0-9]{64}$/.test(layer.sha256);
57
+ return { version, downloadUrl: `${DESKTOP_RELEASES}/tag/${tag}`,
58
+ ...(usable ? { layer: { url: `${desktopDownloads()}/download/${tag}/${layer.name}`, size: layer.size, sha256: layer.sha256 } } : {}) };
84
59
  }
85
60
  const validTarget = (version: unknown, channel: UpdateChannel): version is string => channel === 'dev' ? Boolean(devRevision(version)) : stableVersion(version);
86
61
  const available = (next: string, current: string, channel: UpdateChannel) => validTarget(next, channel)
@@ -197,7 +172,7 @@ export class Updates {
197
172
  return this.checking;
198
173
  }
199
174
  private desktopRelease(channel: UpdateChannel) {
200
- return (this.options.latestDesktop || (channel => latestDesktopRelease(process.platform, process.arch, fetch, channel, this.options.bundle?.fingerprint)))(channel);
175
+ return (this.options.latestDesktop || (channel => latestDesktopRelease(fetch, channel, this.options.bundle?.fingerprint)))(channel);
201
176
  }
202
177
  // A retry after a restart finds the release again; the check that offered it
203
178
  // happened in the previous process.