papilot 0.6.0 → 0.8.0
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 +58 -30
- package/README.zh-CN.md +58 -30
- package/bin/doctor.mjs +53 -6
- package/bin/installation.d.mts +8 -0
- package/bin/installation.mjs +54 -0
- package/bin/papilot.mjs +6 -2
- package/dist/assets/_virtual_papilot-licenses-D7ro6ecN.js +7597 -0
- package/dist/assets/index-Cc-awdNn.css +1 -0
- package/dist/assets/index-D_VX51no.js +265 -0
- package/dist/assets/latex-BarqJFtQ.svg +1 -0
- package/dist/assets/{math-DY-H5SfX.js → math-V9uUaUzA.js} +1 -1
- package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
- package/dist/index.html +2 -2
- package/examples/papilot-paper/main.tex +1 -1
- package/examples/papilot-paper/sections/02-using.tex +3 -1
- package/examples/papilot-paper/sections/09-agents.tex +8 -5
- package/package.json +15 -7
- package/scripts/service.ts +1 -0
- package/skills/ai-review.md +18 -0
- package/skills/latex-warnings.md +1 -1
- package/src/conversation.ts +6 -0
- package/src/note-groups.ts +30 -0
- package/src/notes.ts +34 -10
- package/src/server/acp.ts +354 -0
- package/src/server/agents.ts +237 -47
- package/src/server/ai-review-routes.ts +72 -0
- package/src/server/ai-review.ts +38 -0
- package/src/server/annotation-placement.ts +99 -0
- package/src/server/app.ts +216 -21
- package/src/server/attachments.ts +128 -0
- package/src/server/build-problems.ts +329 -0
- package/src/server/changes.ts +28 -2
- package/src/server/codex.ts +2 -1
- package/src/server/cursor.ts +153 -0
- package/src/server/diagnostics.ts +45 -0
- package/src/server/git-overview.ts +69 -0
- package/src/server/imports.ts +205 -0
- package/src/server/latex-installation.ts +43 -0
- package/src/server/main.ts +8 -42
- package/src/server/manuscript.ts +212 -12
- package/src/server/note-results.ts +65 -0
- package/src/server/paths.ts +1 -1
- package/src/server/references.ts +70 -0
- package/src/server/review.ts +6 -5
- package/src/server/skill-library.ts +113 -0
- package/src/server/skill-routes.ts +19 -0
- package/src/server/skills.ts +29 -19
- package/src/server/start.ts +51 -0
- package/src/server/store.ts +44 -4
- package/src/server/update-install.ts +124 -0
- package/src/server/update-worker.ts +78 -0
- package/src/server/updates.ts +152 -0
- package/src/server/workspace.ts +450 -60
- package/src/shared.ts +120 -10
- package/src/skill-icons.ts +18 -0
- package/src/skill-metadata.ts +45 -0
- package/src/task-caption.ts +20 -0
- package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
- package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
- package/vendor/latex-review/dist/web/index.html +2 -2
- package/vendor/latex-review/src/core/bib.ts +692 -0
- package/vendor/latex-review/src/core/build.ts +5 -1
- package/vendor/latex-review/src/core/changes.ts +39 -0
- package/vendor/latex-review/src/core/delivery.ts +109 -0
- package/vendor/latex-review/src/core/review.ts +12 -4
- package/vendor/latex-review/src/core/store.ts +3 -0
- package/vendor/latex-review/src/core/tasks.ts +5 -2
- package/vendor/latex-review/src/core/verify.ts +784 -0
- package/vendor/latex-review/src/core/view.ts +204 -0
- package/vendor/latex-review/src/render/round.ts +11 -0
- package/vendor/latex-review/src/server.ts +81 -1
- package/vendor/latex-review/src/types.ts +141 -0
- package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
- package/vendor/latex-review/src/web/i18n.ts +1 -1
- package/vendor/latex-review/src/web/overview.tsx +1 -5
- package/vendor/latex-review/src/web/references.tsx +499 -0
- package/vendor/latex-review/src/web/review.tsx +381 -230
- package/vendor/latex-review/src/web/shortcuts.ts +53 -0
- package/vendor/latex-review/src/web/style.css +295 -0
- package/vendor/latex-review/src/web/zh-CN.ts +95 -26
- package/dist/assets/index-BNGqA0_L.css +0 -1
- package/dist/assets/index-Bt9UXsGX.js +0 -222
- package/dist/assets/pdf-view-BGp3W-RG.js +0 -598
- package/vendor/latex-review/dist/web/assets/index-AFBj4gQ9.js +0 -19
- package/vendor/latex-review/dist/web/assets/index-DfC0GURl.css +0 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import type { GitGraphCommit, GitOverview, GitWorktree, Task } from '../shared.ts';
|
|
5
|
+
import { gitStatus } from './manuscript.ts';
|
|
6
|
+
|
|
7
|
+
const exec = promisify(execFile);
|
|
8
|
+
async function git(root: string, args: string[]) {
|
|
9
|
+
return (await exec('git', args, { cwd: root, timeout: 15_000, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } })).stdout;
|
|
10
|
+
}
|
|
11
|
+
const realPath = (value: string) => { try { return fs.realpathSync(value); } catch { return value; } };
|
|
12
|
+
|
|
13
|
+
// -z preserves spaces, newlines and backslashes in paths and lock reasons.
|
|
14
|
+
export function parseWorktrees(output: string, root: string): GitWorktree[] {
|
|
15
|
+
const current = realPath(root);
|
|
16
|
+
return output.split('\0\0').filter(Boolean).map(record => {
|
|
17
|
+
const fields = new Map(record.split('\0').map(field => {
|
|
18
|
+
const space = field.indexOf(' ');
|
|
19
|
+
return space < 0 ? [field, ''] : [field.slice(0, space), field.slice(space + 1)];
|
|
20
|
+
}));
|
|
21
|
+
const path = fields.get('worktree') || '';
|
|
22
|
+
const head = fields.get('HEAD');
|
|
23
|
+
return {
|
|
24
|
+
path, branch: fields.get('branch')?.replace(/^refs\/heads\//, '') || null,
|
|
25
|
+
head: head && !/^0+$/.test(head) ? head : null,
|
|
26
|
+
current: realPath(path) === current, bare: fields.has('bare'), status: null,
|
|
27
|
+
...(fields.has('locked') ? { locked: fields.get('locked')! } : {}),
|
|
28
|
+
...(fields.has('prunable') ? { prunable: fields.get('prunable')! } : {}),
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function gitOverview(root: string, tasks: Pick<Task, 'id' | 'worktree'>[] = []): Promise<GitOverview> {
|
|
34
|
+
const worktrees = parseWorktrees(await git(root, ['worktree', 'list', '--porcelain', '-z']), root);
|
|
35
|
+
// Tasks can share a HEAD and use opaque branch names. Match their actual
|
|
36
|
+
// checkout paths, including /var vs /private/var and other symlink aliases.
|
|
37
|
+
const taskPaths = new Map(tasks.map(task => [realPath(task.worktree), task.id]));
|
|
38
|
+
for (const tree of worktrees) {
|
|
39
|
+
const taskId = taskPaths.get(realPath(tree.path));
|
|
40
|
+
if (taskId) tree.taskId = taskId;
|
|
41
|
+
}
|
|
42
|
+
// Bound the number of simultaneous status processes for repositories with many tasks.
|
|
43
|
+
let next = 0;
|
|
44
|
+
await Promise.all(Array.from({ length: Math.min(4, worktrees.length) }, async () => {
|
|
45
|
+
while (next < worktrees.length) {
|
|
46
|
+
const tree = worktrees[next++];
|
|
47
|
+
if (tree.bare) continue;
|
|
48
|
+
try { tree.status = await gitStatus(tree.path); }
|
|
49
|
+
catch { tree.error = 'Could not read this worktree.'; }
|
|
50
|
+
}
|
|
51
|
+
}));
|
|
52
|
+
worktrees.sort((a, b) => Number(b.current) - Number(a.current));
|
|
53
|
+
const refs = new Map<string, string[]>();
|
|
54
|
+
const refOutput = await git(root, ['for-each-ref', '--format=%(objectname)%00%(refname:short)%00%(*objectname)', 'refs/heads', 'refs/remotes', 'refs/tags']);
|
|
55
|
+
for (const line of refOutput.trim().split('\n').filter(Boolean)) {
|
|
56
|
+
const [object, name, peeled] = line.split('\0'), hash = peeled || object;
|
|
57
|
+
refs.set(hash, [...(refs.get(hash) || []), name]);
|
|
58
|
+
}
|
|
59
|
+
// Include detached worktree tips, which --branches alone would miss.
|
|
60
|
+
const heads = [...new Set(worktrees.flatMap(tree => tree.head ? [tree.head] : []))];
|
|
61
|
+
if (!heads.length && !refs.size) return { worktrees, commits: [], truncated: false };
|
|
62
|
+
const limit = 80;
|
|
63
|
+
const log = await git(root, ['log', '--topo-order', `-${limit + 1}`, '--format=%H%x00%P%x00%s%x00%an%x00%ct', '--branches', '--remotes', '--tags', ...heads, '--']);
|
|
64
|
+
const commits: GitGraphCommit[] = log.trim().split('\n').filter(Boolean).map(line => {
|
|
65
|
+
const [hash, parents, subject, author, date] = line.split('\0');
|
|
66
|
+
return { hash, parents: parents ? parents.split(' ') : [], subject, author, date: Number(date) * 1000, refs: refs.get(hash) || [] };
|
|
67
|
+
});
|
|
68
|
+
return { worktrees, commits: commits.slice(0, limit), truncated: commits.length > limit };
|
|
69
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
|
+
import yauzl from 'yauzl';
|
|
10
|
+
import { DATA, ENGINE } from './paths.ts';
|
|
11
|
+
import type { PaperImport, Project } from '../shared.ts';
|
|
12
|
+
import type { Manuscripts } from './manuscript.ts';
|
|
13
|
+
|
|
14
|
+
const execute = promisify(execFile);
|
|
15
|
+
const MAX_BYTES = 64 * 1024 * 1024, MAX_FILES = 5000;
|
|
16
|
+
const CONFIG = '.latex-review.json';
|
|
17
|
+
const skip = (name: string) => ['.git', '__macosx', '.ds_store', 'node_modules', '.papilot'].includes(name.toLowerCase()) || name.startsWith('._');
|
|
18
|
+
const git = async (root: string, ...args: string[]) => (await execute('git', ['-C', root, ...args], { timeout: 30000, maxBuffer: 8 * 1024 * 1024 })).stdout.trim();
|
|
19
|
+
function safeName(name: unknown): string {
|
|
20
|
+
if (typeof name !== 'string' || !name || name.length > 4000 || /[\\\x00-\x1f:]/.test(name) || name.startsWith('/') || name.split('/').some(part => !part || part === '.' || part === '..')) throw new Error('Invalid file path in upload.');
|
|
21
|
+
return name;
|
|
22
|
+
}
|
|
23
|
+
function bytes(value: unknown) {
|
|
24
|
+
if (typeof value !== 'string' || value.length > Math.ceil(MAX_BYTES / 3) * 4 || value.length % 4 || /[^A-Za-z0-9+/=]/.test(value) || /=/.test(value.slice(0, -2)) || /=[^=]$/.test(value)) throw new Error('Invalid upload data.');
|
|
25
|
+
return Buffer.from(value, 'base64');
|
|
26
|
+
}
|
|
27
|
+
function config(root: string): Record<string, unknown> {
|
|
28
|
+
const file = path.join(root, CONFIG);
|
|
29
|
+
if (!fs.existsSync(file)) return {};
|
|
30
|
+
if (fs.lstatSync(file).isSymbolicLink()) throw new Error('The paper configuration must not be a symbolic link.');
|
|
31
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
32
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('The paper configuration must contain a JSON object.');
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
export function texFiles(root: string) {
|
|
36
|
+
const files: string[] = []; let count = 0;
|
|
37
|
+
function walk(folder: string, depth: number) {
|
|
38
|
+
if (depth > 20) return;
|
|
39
|
+
for (const item of fs.readdirSync(path.join(root, folder), { withFileTypes: true })) {
|
|
40
|
+
if (++count > MAX_FILES) throw new Error('This folder has too many files. Select the folder containing just the paper.');
|
|
41
|
+
if (skip(item.name) || ['build', 'out', 'output'].includes(item.name)) continue;
|
|
42
|
+
const file = path.posix.join(folder, item.name);
|
|
43
|
+
if (item.isDirectory()) walk(file, depth + 1);
|
|
44
|
+
else if (item.isFile() && /\.tex$/i.test(file)) files.push(file);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
walk('', 0); return files.sort();
|
|
48
|
+
}
|
|
49
|
+
export async function inspectPaper(root: string) {
|
|
50
|
+
const files = texFiles(root), roots = files.filter(file => {
|
|
51
|
+
const text = fs.readFileSync(path.join(root, file), 'utf8').slice(0, 300000).replace(/(?<!\\)%[^\n]*/g, '');
|
|
52
|
+
return /\\documentclass\b/.test(text) && /\\begin\s*\{document\}/.test(text);
|
|
53
|
+
});
|
|
54
|
+
let main = roots.length === 1 ? roots[0] : '', engine = '', error: string | undefined;
|
|
55
|
+
try {
|
|
56
|
+
const settings = config(root);
|
|
57
|
+
if (typeof settings.main === 'string' && files.includes(settings.main)) main = settings.main;
|
|
58
|
+
const module = await import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href);
|
|
59
|
+
if (!main) { try { main = module.detectMain(root); } catch { /* The author chooses among all TeX files. */ } }
|
|
60
|
+
// Engine detection also works before Git initialization by reading the source hints.
|
|
61
|
+
if (main) {
|
|
62
|
+
const text = fs.readFileSync(path.join(root, main), 'utf8');
|
|
63
|
+
engine = typeof settings.engine === 'string' ? settings.engine : '';
|
|
64
|
+
const rc = [path.join(path.dirname(main), '.latexmkrc'), path.join(path.dirname(main), 'latexmkrc'), '.latexmkrc', 'latexmkrc'].find(file => fs.existsSync(path.join(root, file)));
|
|
65
|
+
const mode = rc ? fs.readFileSync(path.join(root, rc), 'utf8').match(/^\s*\$pdf_mode\s*=\s*(\d)/m)?.[1] : undefined;
|
|
66
|
+
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');
|
|
67
|
+
}
|
|
68
|
+
} catch (failure) { error = (failure as Error).message; }
|
|
69
|
+
return { candidates: [...roots, ...files.filter(file => !roots.includes(file))], main, engine: engine || 'pdflatex', error };
|
|
70
|
+
}
|
|
71
|
+
async function repositoryRoot(root: string) {
|
|
72
|
+
let top: string | undefined;
|
|
73
|
+
try { top = await git(root, 'rev-parse', '--show-toplevel'); } catch { /* A plain folder needs Git. */ }
|
|
74
|
+
if (top && fs.realpathSync(top) !== root) throw new Error('This folder belongs to a parent Git repository. Select its root folder, or upload a copy.');
|
|
75
|
+
return top;
|
|
76
|
+
}
|
|
77
|
+
export async function prepareRepository(root: string) {
|
|
78
|
+
const top = await repositoryRoot(root);
|
|
79
|
+
if (!top) await git(root, 'init', '-q');
|
|
80
|
+
try { await git(root, 'rev-parse', '--verify', 'HEAD'); return; } catch { /* Empty repositories need their first snapshot. */ }
|
|
81
|
+
// Do not change the author's global identity or overwrite their ignore rules.
|
|
82
|
+
if (!fs.existsSync(path.join(root, '.gitignore'))) fs.writeFileSync(path.join(root, '.gitignore'), '*.aux\n*.log\n*.fls\n*.fdb_latexmk\n*.synctex.gz\n*.toc\n*.out\n.DS_Store\n');
|
|
83
|
+
await git(root, 'add', '--all');
|
|
84
|
+
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');
|
|
85
|
+
}
|
|
86
|
+
export async function extractZip(buffer: Buffer, root: string) {
|
|
87
|
+
if (buffer.length > MAX_BYTES) throw new Error('Choose a ZIP smaller than 64 MB.');
|
|
88
|
+
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)));
|
|
89
|
+
await new Promise<void>((resolve, reject) => {
|
|
90
|
+
let count = 0, total = 0;
|
|
91
|
+
const fail = (error: unknown) => { zip.close(); reject(error); };
|
|
92
|
+
zip.on('error', fail); zip.on('end', resolve);
|
|
93
|
+
zip.on('entry', (entry: yauzl.Entry) => { void (async () => {
|
|
94
|
+
if (++count > MAX_FILES || (total += entry.uncompressedSize) > MAX_BYTES) throw new Error('The ZIP expands beyond 64 MB or 5,000 files.');
|
|
95
|
+
const name = safeName(entry.fileName.replace(/\/$/, ''));
|
|
96
|
+
const mode = (entry.externalFileAttributes >>> 16) & 0o170000;
|
|
97
|
+
if (mode && mode !== 0o100000 && mode !== 0o040000) throw new Error('ZIP links and special files are not supported.');
|
|
98
|
+
if (name.split('/').some(skip) || entry.fileName.endsWith('/')) { zip.readEntry(); return; }
|
|
99
|
+
const target = path.join(root, name); fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
100
|
+
const stream = await new Promise<NodeJS.ReadableStream>((resolve, reject) => zip.openReadStream(entry, (error, stream) => error ? reject(error) : resolve(stream)));
|
|
101
|
+
await pipeline(stream, fs.createWriteStream(target, { flags: 'wx', mode: 0o600 })); zip.readEntry();
|
|
102
|
+
})().catch(fail); });
|
|
103
|
+
zip.readEntry();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class PaperImports {
|
|
108
|
+
private sessions = new Map<string, PaperImport>();
|
|
109
|
+
private active = new Set<string>();
|
|
110
|
+
get busy() { return this.active.size > 0; }
|
|
111
|
+
constructor(private manuscripts: Manuscripts, private directory = DATA, private registered: (root: string) => boolean = () => false) {
|
|
112
|
+
const folder = path.join(directory, 'imports');
|
|
113
|
+
if (fs.existsSync(folder)) for (const file of fs.readdirSync(folder)) {
|
|
114
|
+
if (/^[a-f0-9-]{36}\.json$/.test(file)) try { this.get(file.slice(0, -5)); } catch { /* An unreadable receipt must not prevent other papers from opening. */ }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
private save(value: PaperImport) {
|
|
118
|
+
fs.mkdirSync(path.join(this.directory, 'imports'), { recursive: true, mode: 0o700 });
|
|
119
|
+
const file = path.join(this.directory, 'imports', value.id + '.json');
|
|
120
|
+
fs.writeFileSync(file + '.tmp', JSON.stringify(value), { mode: 0o600 }); fs.renameSync(file + '.tmp', file);
|
|
121
|
+
this.sessions.set(value.id, value); return { ...value };
|
|
122
|
+
}
|
|
123
|
+
get(id: string) {
|
|
124
|
+
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error('Import not found.');
|
|
125
|
+
let value = this.sessions.get(id);
|
|
126
|
+
if (!value) {
|
|
127
|
+
value = JSON.parse(fs.readFileSync(path.join(this.directory, 'imports', id + '.json'), 'utf8')) as PaperImport;
|
|
128
|
+
if (['building', 'repairing'].includes(value.phase)) Object.assign(value, { phase: 'failed', error: 'Papilot restarted. Retry compilation to continue this import.' });
|
|
129
|
+
this.sessions.set(id, value);
|
|
130
|
+
}
|
|
131
|
+
return { ...value };
|
|
132
|
+
}
|
|
133
|
+
async create(input: { path?: unknown; name?: unknown; zip?: unknown; files?: unknown }) {
|
|
134
|
+
const id = randomUUID(); let root = '', copied = false;
|
|
135
|
+
if ([input.path, input.zip, input.files].filter(value => value !== undefined).length !== 1) throw new Error('Choose one folder or ZIP.');
|
|
136
|
+
if (input.name !== undefined && (typeof input.name !== 'string' || input.name.length > 120)) throw new Error('Paper name is too long.');
|
|
137
|
+
try {
|
|
138
|
+
if (input.path !== undefined) {
|
|
139
|
+
if (typeof input.path !== 'string' || !input.path.trim()) throw new Error('Enter a folder path.');
|
|
140
|
+
root = fs.realpathSync(input.path.startsWith('~/') ? path.join(os.homedir(), input.path.slice(2)) : input.path);
|
|
141
|
+
if (!fs.statSync(root).isDirectory()) throw new Error('Select a folder containing the paper.');
|
|
142
|
+
} else {
|
|
143
|
+
root = path.join(this.directory, 'papers', id); copied = true; fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
144
|
+
if (input.zip !== undefined) await extractZip(bytes(input.zip), root);
|
|
145
|
+
else {
|
|
146
|
+
if (!Array.isArray(input.files) || !input.files.length || input.files.length > MAX_FILES) throw new Error('Choose between 1 and 5,000 files.');
|
|
147
|
+
let total = 0;
|
|
148
|
+
for (const file of input.files) {
|
|
149
|
+
const name = safeName(file.path);
|
|
150
|
+
if (name.split('/').some(skip)) continue;
|
|
151
|
+
const data = bytes(file.data); total += data.length;
|
|
152
|
+
if (total > MAX_BYTES) throw new Error('Choose a folder smaller than 64 MB.');
|
|
153
|
+
const target = path.join(root, name); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, data, { flag: 'wx', mode: 0o600 });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Unwrap export folders (Overleaf and GitHub ZIPs commonly have one).
|
|
157
|
+
let children = fs.readdirSync(root).filter(name => !skip(name));
|
|
158
|
+
while (children.length === 1 && fs.statSync(path.join(root, children[0])).isDirectory()) { root = path.join(root, children[0]); children = fs.readdirSync(root).filter(name => !skip(name)); }
|
|
159
|
+
}
|
|
160
|
+
if (this.registered(root)) throw new Error('This paper is already added. Open it from the paper list.');
|
|
161
|
+
if (!copied && [...this.sessions.values()].some(item => item.path === root)) return this.get([...this.sessions.values()].find(item => item.path === root)!.id);
|
|
162
|
+
const detected = await inspectPaper(root);
|
|
163
|
+
const existing = !copied && [...this.sessions.values()].find(item => item.path === root);
|
|
164
|
+
if (existing) return this.get(existing.id);
|
|
165
|
+
return this.save({ id, path: root, copied, name: (input.name as string)?.trim() || path.basename(root), ...detected, phase: 'choose' });
|
|
166
|
+
} catch (error) { if (copied) fs.rmSync(path.join(this.directory, 'papers', id), { recursive: true, force: true }); throw error; }
|
|
167
|
+
}
|
|
168
|
+
async build(id: string, input: { main?: unknown; engine?: unknown; name?: unknown }, repair?: (root: string, error: string) => Promise<string>) {
|
|
169
|
+
const value = this.get(id);
|
|
170
|
+
if (this.active.has(id)) return value;
|
|
171
|
+
if (this.registered(value.path)) throw new Error('This paper is already added. Open it from the paper list.');
|
|
172
|
+
const main = input.main ?? value.main, engine = input.engine ?? value.engine;
|
|
173
|
+
if (typeof main !== 'string' || !texFiles(value.path).includes(main)) throw new Error('Choose an existing TeX main file.');
|
|
174
|
+
if (!['pdflatex', 'xelatex', 'lualatex'].includes(engine as string)) throw new Error('Choose a supported LaTeX engine.');
|
|
175
|
+
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.');
|
|
176
|
+
this.active.add(id);
|
|
177
|
+
const previousError = value.error || '';
|
|
178
|
+
Object.assign(value, { main, engine, ...(input.name ? { name: input.name } : {}), phase: repair ? 'repairing' : 'building', error: undefined });
|
|
179
|
+
this.save(value);
|
|
180
|
+
void (async () => {
|
|
181
|
+
try {
|
|
182
|
+
await repositoryRoot(value.path);
|
|
183
|
+
if (!repair) {
|
|
184
|
+
const settings = config(value.path);
|
|
185
|
+
fs.writeFileSync(path.join(value.path, CONFIG), JSON.stringify({ ...settings, main, engine, source_dir: '' }, null, 2) + '\n');
|
|
186
|
+
}
|
|
187
|
+
await prepareRepository(value.path); value.versioned = true;
|
|
188
|
+
if (repair) { value.summary = await repair(value.path, previousError); Object.assign(value, await inspectPaper(value.path)); }
|
|
189
|
+
value.phase = 'building'; this.save(value);
|
|
190
|
+
let result = await this.manuscripts.state('import-' + id, value.path, true);
|
|
191
|
+
while (result.build.status === 'building') { await new Promise(resolve => setTimeout(resolve, 400)); result = await this.manuscripts.state('import-' + id, value.path); }
|
|
192
|
+
value.phase = result.build.status === 'ready' ? 'ready' : 'failed'; value.error = result.build.error;
|
|
193
|
+
} catch (error) { value.phase = 'failed'; value.error = (error as Error).message; }
|
|
194
|
+
finally { this.active.delete(id); this.save(value); }
|
|
195
|
+
})();
|
|
196
|
+
return this.get(id);
|
|
197
|
+
}
|
|
198
|
+
async finish(id: string, register: (root: string, name: string) => Promise<Project>) {
|
|
199
|
+
const value = this.get(id);
|
|
200
|
+
if (this.active.has(id) || !value.versioned) throw new Error('Wait for repository preparation to finish.');
|
|
201
|
+
const project = await register(value.path, value.name);
|
|
202
|
+
// Keep the receipt so a lost HTTP response can safely retry finalization.
|
|
203
|
+
this.manuscripts.forgetProject('import-' + id); return project;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import type { LatexInstallation } from '../shared.ts';
|
|
6
|
+
|
|
7
|
+
type Probe = (file: string, args: string[], env: NodeJS.ProcessEnv) => Promise<string | null>;
|
|
8
|
+
const command: Probe = (file, args, env) => new Promise(resolve => {
|
|
9
|
+
const child = execFile(file, args, { env, cwd: os.homedir(), timeout: 5000, killSignal: 'SIGKILL', maxBuffer: 128 * 1024 }, (error, stdout) => resolve(error ? null : stdout.trim()));
|
|
10
|
+
child.stdin?.end();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Identify the installation actually found on the server's PATH. In particular,
|
|
14
|
+
// a MacTeX receipt elsewhere on the machine must not relabel another TeX Live.
|
|
15
|
+
export async function detectLatex(env: NodeJS.ProcessEnv = process.env, platform = process.platform, probe: Probe = command): Promise<LatexInstallation | null> {
|
|
16
|
+
const suffix = platform === 'win32' ? '.exe' : '';
|
|
17
|
+
const executable = (name: string) => (env.PATH || '').split(path.delimiter).filter(Boolean).map(directory => path.resolve(directory, name + suffix)).find(candidate => {
|
|
18
|
+
try { fs.accessSync(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK); return fs.statSync(candidate).isFile(); } catch { return false; }
|
|
19
|
+
});
|
|
20
|
+
let found = false;
|
|
21
|
+
for (const engine of ['pdflatex', 'xelatex', 'lualatex']) {
|
|
22
|
+
const file = executable(engine);
|
|
23
|
+
if (!file) continue;
|
|
24
|
+
found = true;
|
|
25
|
+
const banner = await probe(file, ['--version'], env);
|
|
26
|
+
if (!banner) continue;
|
|
27
|
+
const texlive = banner.match(/\bTeX Live (\d{4})\b/i);
|
|
28
|
+
const miktex = banner.match(/\bMiKTeX[ \t]+([\d.]+)/i);
|
|
29
|
+
let distribution: LatexInstallation['distribution'] = texlive ? 'TeX Live' : /\bMiKTeX\b/i.test(banner) ? 'MiKTeX' : null;
|
|
30
|
+
const resolved = fs.realpathSync(file);
|
|
31
|
+
const root = await probe(path.join(path.dirname(file), 'kpsewhich' + suffix), ['-var-value=TEXMFROOT'], env);
|
|
32
|
+
const location = root && path.isAbsolute(root) && !/[\r\n]/.test(root) ? root : path.dirname(resolved);
|
|
33
|
+
if (platform === 'darwin' && texlive) {
|
|
34
|
+
const receipt = await probe('/usr/sbin/pkgutil', ['--file-info', resolved], env);
|
|
35
|
+
const ids = [...(receipt || '').matchAll(/^pkgid:\s*(\S+)/gm)].map(match => match[1]);
|
|
36
|
+
if (ids.some(id => /^org\.tug\.mactex\.basictex\d*$/i.test(id))) distribution = 'BasicTeX';
|
|
37
|
+
else if (ids.some(id => /^org\.tug\.mactex\.texlive\d*$/i.test(id))) distribution = 'MacTeX';
|
|
38
|
+
}
|
|
39
|
+
return { distribution, version: texlive?.[1] || miktex?.[1] || null, path: location };
|
|
40
|
+
}
|
|
41
|
+
if (found) throw new Error('Could not read the LaTeX installation.');
|
|
42
|
+
return null;
|
|
43
|
+
}
|
package/src/server/main.ts
CHANGED
|
@@ -1,44 +1,10 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
1
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { Accounts } from './accounts.ts';
|
|
7
|
-
import { ROOT, DATA, PORT, PUBLIC_ORIGIN, OWNER, VERSION, ACCOUNT_ORIGIN, ACCOUNT_PORT } from './paths.ts';
|
|
8
|
-
import { accountHsts, accountProblem } from './security.ts';
|
|
9
|
-
import { Manuscripts } from './manuscript.ts';
|
|
10
|
-
import { Changes } from './changes.ts';
|
|
11
|
-
import { buildApp, gitQueue } from './app.ts';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { ROOT, DATA } from './paths.ts';
|
|
4
|
+
import { activeInstallation, waitForActivation } from '../../bin/installation.mjs';
|
|
12
5
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const shared = {
|
|
19
|
-
store, workspace, accounts, manuscripts: new Manuscripts(), changes: new Changes(), csrf: randomBytes(32).toString('base64url'), serialGit: gitQueue(),
|
|
20
|
-
dist: fs.existsSync(path.join(ROOT, 'dist/index.html')) ? path.join(ROOT, 'dist') : null, version: VERSION,
|
|
21
|
-
};
|
|
22
|
-
const apps = [await buildApp({ ...shared, listener: { kind: 'local', port: PORT, publicOrigin: PUBLIC_ORIGIN, owner: OWNER } })];
|
|
23
|
-
if (ACCOUNT_ORIGIN) apps.push(await buildApp({ ...shared, listener: { kind: 'account', origin: ACCOUNT_ORIGIN, hsts: accountHsts(ACCOUNT_ORIGIN, PUBLIC_ORIGIN) } }));
|
|
24
|
-
|
|
25
|
-
await apps[0].listen({ host: '127.0.0.1', port: PORT });
|
|
26
|
-
if (apps[1]) await apps[1].listen({ host: '127.0.0.1', port: ACCOUNT_PORT });
|
|
27
|
-
store.recover();
|
|
28
|
-
console.log(`Papilot is running at http://127.0.0.1:${PORT}${PUBLIC_ORIGIN ? `\nRemote: ${PUBLIC_ORIGIN}` : ''}${ACCOUNT_ORIGIN ? `\nSign-in for invited people: ${ACCOUNT_ORIGIN} (forwarded to 127.0.0.1:${ACCOUNT_PORT})` : ''}`);
|
|
29
|
-
void workspace.connect();
|
|
30
|
-
// Recompute cached feedback counts, including decisions from before this restart.
|
|
31
|
-
for (const task of store.tasks()) {
|
|
32
|
-
if (task.status === 'building') {
|
|
33
|
-
store.updateTask(task.id, { status: 'interrupted', error: 'The server restarted during the PDF build. Check the review and rebuild it if needed.' });
|
|
34
|
-
void workspace.refresh(task.id);
|
|
35
|
-
} else if (task.review?.round && task.status !== 'deleting') void workspace.refresh(task.id);
|
|
36
|
-
}
|
|
37
|
-
// Expired invites and sessions are removed as they lapse.
|
|
38
|
-
setInterval(() => accounts.purge(), 3600000).unref();
|
|
39
|
-
let shuttingDown = false;
|
|
40
|
-
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
|
|
41
|
-
if (shuttingDown) return; shuttingDown = true; workspace.close();
|
|
42
|
-
void Promise.all(apps.map(app => app.close())).finally(() => { store.close(); process.exit(0); });
|
|
43
|
-
setTimeout(() => process.exit(0), 2500).unref();
|
|
44
|
-
});
|
|
6
|
+
await waitForActivation(DATA);
|
|
7
|
+
const root = activeInstallation(ROOT, DATA);
|
|
8
|
+
// A source checkout's default data directory must not move with the release.
|
|
9
|
+
process.env.PAPILOT_DATA = DATA;
|
|
10
|
+
await import(pathToFileURL(path.join(root, 'src/server/start.ts')).href);
|