texlite 0.2.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/LICENSE +661 -0
- package/README.md +403 -0
- package/README.zh-CN.md +391 -0
- package/dist/client/assets/GitDialog-BHHxSBn3.js +3 -0
- package/dist/client/assets/HistoryDialog-Dlv57tYf.js +5 -0
- package/dist/client/assets/LatexEditor-B-0VRUPv.js +55 -0
- package/dist/client/assets/PdfPreview-617TWFJN.js +1 -0
- package/dist/client/assets/ProjectNavigationDialogs-Cpt2LVEt.js +1 -0
- package/dist/client/assets/SystemMetricsDialog-fkFHVVzJ.js +1 -0
- package/dist/client/assets/editor-JxT6uSYX.js +14 -0
- package/dist/client/assets/index-Bc5CmFMl.js +15 -0
- package/dist/client/assets/index-CMqYAbDx.css +1 -0
- package/dist/client/assets/pdf-DPnwNQHb.js +55 -0
- package/dist/client/assets/pdf.worker.min-CHFwMXne.mjs +29 -0
- package/dist/client/assets/plus-DzbLkp6l.js +1 -0
- package/dist/client/assets/react-CsO_xvbl.js +49 -0
- package/dist/client/assets/spellCheck-R6fUM9oA.js +49783 -0
- package/dist/client/index.html +20 -0
- package/dist/client/logo.svg +4 -0
- package/dist/server/anchors.js +95 -0
- package/dist/server/app.js +1624 -0
- package/dist/server/archive.js +26 -0
- package/dist/server/auth.js +42 -0
- package/dist/server/cli.js +318 -0
- package/dist/server/collaboration.js +635 -0
- package/dist/server/compileArtifacts.js +86 -0
- package/dist/server/compileDiagnostics.js +227 -0
- package/dist/server/compiler.js +514 -0
- package/dist/server/config.js +336 -0
- package/dist/server/db.js +280 -0
- package/dist/server/environment.js +57 -0
- package/dist/server/files.js +121 -0
- package/dist/server/git.js +414 -0
- package/dist/server/history.js +379 -0
- package/dist/server/http.js +7 -0
- package/dist/server/index.js +75 -0
- package/dist/server/latexCompletion.js +296 -0
- package/dist/server/metrics.js +34 -0
- package/dist/server/pm2.js +234 -0
- package/dist/server/projectOutline.js +101 -0
- package/dist/server/projectSearch.js +112 -0
- package/dist/server/projects.js +29 -0
- package/dist/server/routes/compile.js +343 -0
- package/dist/server/runtimePaths.js +42 -0
- package/dist/server/security.js +29 -0
- package/dist/server/spellCheck.js +195 -0
- package/dist/server/synctex.js +56 -0
- package/dist/server/zip.js +185 -0
- package/package.json +110 -0
- package/preview.png +0 -0
- package/texlite.config.example.json +33 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveSourcePath, safeRelativePath } from "./files.js";
|
|
4
|
+
const levels = { part: 0, chapter: 0, section: 1, subsection: 2, subsubsection: 3, paragraph: 4 };
|
|
5
|
+
const commandPattern = /\\(part|chapter|section|subsection|subsubsection|paragraph)\*?(?:\s*\[[^\]]*\])?\s*\{|\\(?:input|include|subfile)\s*\{/g;
|
|
6
|
+
export function buildProjectOutline(config, projectId, mainFileInput) {
|
|
7
|
+
const mainFile = safeRelativePath(mainFileInput);
|
|
8
|
+
const result = [];
|
|
9
|
+
const visited = new Set();
|
|
10
|
+
const visit = (filePath) => {
|
|
11
|
+
if (visited.has(filePath) || visited.size >= 200)
|
|
12
|
+
return;
|
|
13
|
+
visited.add(filePath);
|
|
14
|
+
const absolute = resolveSourcePath(config, projectId, filePath);
|
|
15
|
+
if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size > 3 * 1024 * 1024)
|
|
16
|
+
return;
|
|
17
|
+
const content = stripCommentsPreserveLines(fs.readFileSync(absolute, "utf8"));
|
|
18
|
+
const lineStarts = sourceLineStarts(content);
|
|
19
|
+
commandPattern.lastIndex = 0;
|
|
20
|
+
for (const command of content.matchAll(commandPattern)) {
|
|
21
|
+
const start = (command.index ?? 0) + command[0].length;
|
|
22
|
+
const argument = balancedArgument(content, start);
|
|
23
|
+
if (!argument)
|
|
24
|
+
continue;
|
|
25
|
+
if (command[1]) {
|
|
26
|
+
result.push({ path: filePath, line: lineAtOffset(lineStarts, command.index ?? 0), level: levels[command[1]], title: cleanTitle(argument.value) });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const included = resolveIncludedFile(config, projectId, filePath, argument.value);
|
|
30
|
+
if (included)
|
|
31
|
+
visit(included);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
visit(mainFile);
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
function resolveIncludedFile(config, projectId, currentFile, value) {
|
|
38
|
+
const raw = value.trim();
|
|
39
|
+
if (!raw || /[\\#]/.test(raw))
|
|
40
|
+
return null;
|
|
41
|
+
const withExtension = path.posix.extname(raw) ? raw : `${raw}.tex`;
|
|
42
|
+
const candidates = [withExtension, path.posix.join(path.posix.dirname(currentFile), withExtension)];
|
|
43
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
44
|
+
try {
|
|
45
|
+
const safe = safeRelativePath(candidate);
|
|
46
|
+
if (fs.existsSync(resolveSourcePath(config, projectId, safe)))
|
|
47
|
+
return safe;
|
|
48
|
+
}
|
|
49
|
+
catch { /* Ignore includes outside the project. */ }
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
function balancedArgument(source, start) {
|
|
54
|
+
let depth = 1;
|
|
55
|
+
for (let index = start; index < source.length; index += 1) {
|
|
56
|
+
if (source[index] === "{" && !escapedAt(source, index))
|
|
57
|
+
depth += 1;
|
|
58
|
+
if (source[index] === "}" && !escapedAt(source, index))
|
|
59
|
+
depth -= 1;
|
|
60
|
+
if (depth === 0)
|
|
61
|
+
return { value: source.slice(start, index), end: index };
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function stripCommentsPreserveLines(source) {
|
|
66
|
+
return source.split(/(?<=\n)/).map((line) => {
|
|
67
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
68
|
+
if (line[index] === "%" && !escapedAt(line, index))
|
|
69
|
+
return `${line.slice(0, index)}${line.endsWith("\n") ? "\n" : ""}`;
|
|
70
|
+
}
|
|
71
|
+
return line;
|
|
72
|
+
}).join("");
|
|
73
|
+
}
|
|
74
|
+
function escapedAt(source, index) {
|
|
75
|
+
let slashes = 0;
|
|
76
|
+
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1)
|
|
77
|
+
slashes += 1;
|
|
78
|
+
return slashes % 2 === 1;
|
|
79
|
+
}
|
|
80
|
+
function sourceLineStarts(source) {
|
|
81
|
+
const starts = [0];
|
|
82
|
+
for (let index = 0; index < source.length; index += 1)
|
|
83
|
+
if (source[index] === "\n")
|
|
84
|
+
starts.push(index + 1);
|
|
85
|
+
return starts;
|
|
86
|
+
}
|
|
87
|
+
function lineAtOffset(starts, offset) {
|
|
88
|
+
let low = 0;
|
|
89
|
+
let high = starts.length;
|
|
90
|
+
while (low < high) {
|
|
91
|
+
const middle = Math.floor((low + high) / 2);
|
|
92
|
+
if (starts[middle] <= offset)
|
|
93
|
+
low = middle + 1;
|
|
94
|
+
else
|
|
95
|
+
high = middle;
|
|
96
|
+
}
|
|
97
|
+
return Math.max(1, low);
|
|
98
|
+
}
|
|
99
|
+
function cleanTitle(value) {
|
|
100
|
+
return value.replace(/\\(?:texorpdfstring|MakeUppercase)\s*\{([^{}]*)\}(?:\{[^{}]*\})?/g, "$1").replace(/\\[a-zA-Z@]+\*?/g, "").replace(/[{}]/g, "").trim() || value.trim();
|
|
101
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { listProjectFilesAsync, resolveSourcePath } from "./files.js";
|
|
4
|
+
const textExtension = /(?:\.(?:tex|bib|sty|cls|txt|md)|latexmkrc)$/i;
|
|
5
|
+
const maxFiles = 500;
|
|
6
|
+
const maxFileBytes = 3 * 1024 * 1024;
|
|
7
|
+
const maxTotalBytes = 24 * 1024 * 1024;
|
|
8
|
+
const maxMatches = 500;
|
|
9
|
+
export async function searchProject(config, projectId, options) {
|
|
10
|
+
const pattern = searchPattern(options);
|
|
11
|
+
const matches = [];
|
|
12
|
+
let total = 0;
|
|
13
|
+
let indexedBytes = 0;
|
|
14
|
+
const entries = (await listProjectFilesAsync(config, projectId)).filter((entry) => entry.type === "file" && textExtension.test(entry.path)).slice(0, maxFiles);
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
if ((entry.size ?? 0) > maxFileBytes || indexedBytes + (entry.size ?? 0) > maxTotalBytes)
|
|
17
|
+
continue;
|
|
18
|
+
indexedBytes += entry.size ?? 0;
|
|
19
|
+
let content;
|
|
20
|
+
try {
|
|
21
|
+
content = await fs.promises.readFile(resolveSourcePath(config, projectId, entry.path), "utf8");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
pattern.lastIndex = 0;
|
|
27
|
+
let line = 1;
|
|
28
|
+
let lineStart = 0;
|
|
29
|
+
let nextLineBreak = content.indexOf("\n");
|
|
30
|
+
for (const match of content.matchAll(pattern)) {
|
|
31
|
+
total += 1;
|
|
32
|
+
if (matches.length >= maxMatches)
|
|
33
|
+
continue;
|
|
34
|
+
const offset = match.index;
|
|
35
|
+
while (nextLineBreak >= 0 && nextLineBreak < offset) {
|
|
36
|
+
line += 1;
|
|
37
|
+
lineStart = nextLineBreak + 1;
|
|
38
|
+
nextLineBreak = content.indexOf("\n", lineStart);
|
|
39
|
+
}
|
|
40
|
+
const lineEndCandidate = content.indexOf("\n", offset);
|
|
41
|
+
const lineEnd = lineEndCandidate < 0 ? content.length : lineEndCandidate;
|
|
42
|
+
const preview = content.slice(lineStart, lineEnd).slice(0, 500);
|
|
43
|
+
const previewOffset = Math.min(offset - lineStart, 500);
|
|
44
|
+
matches.push({
|
|
45
|
+
path: entry.path,
|
|
46
|
+
line,
|
|
47
|
+
column: offset - lineStart + 1,
|
|
48
|
+
preview,
|
|
49
|
+
matchStart: previewOffset,
|
|
50
|
+
matchEnd: Math.min(preview.length, previewOffset + match[0].length)
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { matches, total, truncated: total > matches.length };
|
|
55
|
+
}
|
|
56
|
+
export async function replaceProject(config, projectId, options, replacement) {
|
|
57
|
+
const pattern = searchPattern(options);
|
|
58
|
+
const staged = [];
|
|
59
|
+
let indexedBytes = 0;
|
|
60
|
+
const entries = (await listProjectFilesAsync(config, projectId)).filter((entry) => entry.type === "file" && textExtension.test(entry.path)).slice(0, maxFiles);
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if ((entry.size ?? 0) > maxFileBytes || indexedBytes + (entry.size ?? 0) > maxTotalBytes)
|
|
63
|
+
continue;
|
|
64
|
+
indexedBytes += entry.size ?? 0;
|
|
65
|
+
const absolute = resolveSourcePath(config, projectId, entry.path);
|
|
66
|
+
let previous;
|
|
67
|
+
try {
|
|
68
|
+
previous = await fs.promises.readFile(absolute, "utf8");
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
pattern.lastIndex = 0;
|
|
74
|
+
let count = 0;
|
|
75
|
+
const content = previous.replace(pattern, () => { count += 1; return replacement; });
|
|
76
|
+
if (!count || content === previous)
|
|
77
|
+
continue;
|
|
78
|
+
const temporary = `${absolute}.search-${process.pid}-${Date.now()}-${staged.length}.tmp`;
|
|
79
|
+
await fs.promises.writeFile(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
80
|
+
staged.push({ path: entry.path, absolute, temporary, previous, content, count });
|
|
81
|
+
}
|
|
82
|
+
const installed = [];
|
|
83
|
+
try {
|
|
84
|
+
for (const entry of staged) {
|
|
85
|
+
await fs.promises.rename(entry.temporary, entry.absolute);
|
|
86
|
+
installed.push(entry);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
// A project-wide replacement is presented as one operation. Restore every
|
|
91
|
+
// file already installed if a later rename fails, and remove unused stages.
|
|
92
|
+
for (const entry of installed.reverse()) {
|
|
93
|
+
const rollback = `${entry.absolute}.search-rollback-${process.pid}-${Date.now()}.tmp`;
|
|
94
|
+
await fs.promises.writeFile(rollback, entry.previous, { encoding: "utf8", mode: 0o600 });
|
|
95
|
+
await fs.promises.rename(rollback, entry.absolute);
|
|
96
|
+
}
|
|
97
|
+
await Promise.allSettled(staged.map((entry) => fs.promises.rm(entry.temporary, { force: true })));
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
return staged.map(({ path: filePath, previous, content, count }) => ({ path: filePath, previous, content, count }));
|
|
101
|
+
}
|
|
102
|
+
function searchPattern(options) {
|
|
103
|
+
const query = options.query.trim();
|
|
104
|
+
if (!query || query.length > 500)
|
|
105
|
+
throw Object.assign(new Error("搜索内容格式不正确"), { code: "SEARCH_QUERY_INVALID", statusCode: 400 });
|
|
106
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
107
|
+
const source = options.wholeWord ? `(?<![\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])` : escaped;
|
|
108
|
+
return new RegExp(source, `${options.caseSensitive ? "" : "i"}gu`);
|
|
109
|
+
}
|
|
110
|
+
export function projectSearchTextFile(filePath) {
|
|
111
|
+
return textExtension.test(path.basename(filePath));
|
|
112
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function accessibleProject(db, projectId, user) {
|
|
2
|
+
if (user.role === "admin") {
|
|
3
|
+
const row = db.prepare(`
|
|
4
|
+
SELECT p.*, 'owner' AS permission, owner.username AS owner_username,
|
|
5
|
+
owner.display_name AS owner_display_name,
|
|
6
|
+
modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name
|
|
7
|
+
FROM projects p
|
|
8
|
+
JOIN users owner ON owner.id = p.owner_id
|
|
9
|
+
LEFT JOIN users modifier ON modifier.id = p.last_modified_by
|
|
10
|
+
WHERE p.id = ?
|
|
11
|
+
`).get(projectId);
|
|
12
|
+
return row ?? null;
|
|
13
|
+
}
|
|
14
|
+
const row = db.prepare(`
|
|
15
|
+
SELECT p.*,
|
|
16
|
+
CASE WHEN p.owner_id = ? THEN 'owner' ELSE pm.permission END AS permission,
|
|
17
|
+
owner.username AS owner_username, owner.display_name AS owner_display_name,
|
|
18
|
+
modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name
|
|
19
|
+
FROM projects p
|
|
20
|
+
JOIN users owner ON owner.id = p.owner_id
|
|
21
|
+
LEFT JOIN users modifier ON modifier.id = p.last_modified_by
|
|
22
|
+
LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = ?
|
|
23
|
+
WHERE p.id = ? AND (p.owner_id = ? OR pm.user_id IS NOT NULL)
|
|
24
|
+
`).get(user.id, user.id, projectId, user.id);
|
|
25
|
+
return row ?? null;
|
|
26
|
+
}
|
|
27
|
+
export function canEdit(project) {
|
|
28
|
+
return project.permission === "owner" || project.permission === "edit";
|
|
29
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
5
|
+
import { requireUser } from "../auth.js";
|
|
6
|
+
import { accessibleProject, canEdit } from "../projects.js";
|
|
7
|
+
import { resolveSourcePath, safeRelativePath } from "../files.js";
|
|
8
|
+
import { captureCompileSnapshot, compileProject, discardCompileSnapshot, publishCompileArtifacts, publishedCompileArtifacts } from "../compiler.js";
|
|
9
|
+
import { parseCompileDiagnostics } from "../compileDiagnostics.js";
|
|
10
|
+
import { pdfToSource, sourceToPdf } from "../synctex.js";
|
|
11
|
+
import { availablePdf, compileMainFile, compilePdfUrl, isTextCompileArtifact, listCompileArtifacts, syncArtifacts } from "../compileArtifacts.js";
|
|
12
|
+
import { apiError, contentDisposition } from "../http.js";
|
|
13
|
+
const now = () => new Date().toISOString();
|
|
14
|
+
const timingDuration = (milliseconds) => Math.round(milliseconds * 10) / 10;
|
|
15
|
+
export function registerCompileRoutes(app, context) {
|
|
16
|
+
const { config, db, collaboration, compileCoordinator, metrics, pruneCompileRuns } = context;
|
|
17
|
+
app.get("/api/projects/:id/compile/latest", async (request, reply) => {
|
|
18
|
+
const user = requireUser(request, reply, db);
|
|
19
|
+
if (!user)
|
|
20
|
+
return;
|
|
21
|
+
const { id } = request.params;
|
|
22
|
+
const project = accessibleProject(db, id, user);
|
|
23
|
+
if (!project)
|
|
24
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
25
|
+
const query = request.query;
|
|
26
|
+
const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
|
|
27
|
+
if (!mainFile)
|
|
28
|
+
return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
|
|
29
|
+
const latest = db.prepare(`SELECT run.id, run.status, run.log, run.created_at, run.finished_at,
|
|
30
|
+
run.requested_by, user.username AS requested_by_username, user.display_name AS requested_by_name
|
|
31
|
+
FROM compile_runs run LEFT JOIN users user ON user.id = run.requested_by
|
|
32
|
+
WHERE run.project_id = ? AND run.main_file = ?
|
|
33
|
+
ORDER BY CASE run.status WHEN 'running' THEN 0 WHEN 'queued' THEN 1 ELSE 2 END, run.created_at DESC LIMIT 1`).get(id, mainFile);
|
|
34
|
+
const latestSuccess = db.prepare(`SELECT id, finished_at FROM compile_runs
|
|
35
|
+
WHERE project_id = ? AND main_file = ? AND status = 'succeeded' ORDER BY created_at DESC LIMIT 1`).get(id, mainFile);
|
|
36
|
+
const pdf = availablePdf(config, id, mainFile, project.main_file);
|
|
37
|
+
const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
|
|
38
|
+
const publishedRun = published ? db.prepare(`SELECT id, finished_at FROM compile_runs
|
|
39
|
+
WHERE id = ? AND project_id = ? AND status = 'succeeded'`).get(published.runId, id) : undefined;
|
|
40
|
+
const pdfVersion = published?.runId ?? latestSuccess?.id ?? pdf?.version;
|
|
41
|
+
return {
|
|
42
|
+
mainFile,
|
|
43
|
+
latestRun: latest ? {
|
|
44
|
+
id: latest.id, status: latest.status, log: latest.log,
|
|
45
|
+
diagnostics: parseCompileDiagnostics(latest.log, latest.status === "succeeded" || latest.status === "failed" ? latest.status : null),
|
|
46
|
+
createdAt: latest.created_at, finishedAt: latest.finished_at,
|
|
47
|
+
requestedBy: latest.requested_by ? {
|
|
48
|
+
id: latest.requested_by,
|
|
49
|
+
username: latest.requested_by_username ?? "deleted-user",
|
|
50
|
+
name: latest.requested_by_name ?? "Deleted User"
|
|
51
|
+
} : null
|
|
52
|
+
} : null,
|
|
53
|
+
hasPdf: Boolean(pdf),
|
|
54
|
+
pdfUrl: pdf ? compilePdfUrl(id, mainFile, pdfVersion ?? pdf.version) : null,
|
|
55
|
+
pdfCompiledAt: publishedRun?.finished_at ?? latestSuccess?.finished_at ?? null
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
app.get("/api/projects/:id/compile/artifacts", async (request, reply) => {
|
|
59
|
+
const user = requireUser(request, reply, db);
|
|
60
|
+
if (!user)
|
|
61
|
+
return;
|
|
62
|
+
const { id } = request.params;
|
|
63
|
+
const project = accessibleProject(db, id, user);
|
|
64
|
+
if (!project)
|
|
65
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
66
|
+
const query = request.query;
|
|
67
|
+
const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
|
|
68
|
+
if (!mainFile)
|
|
69
|
+
return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
|
|
70
|
+
const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
|
|
71
|
+
if (!query.path)
|
|
72
|
+
return { mainFile, runId: published?.runId ?? null, artifacts: published ? listCompileArtifacts(published.output) : [] };
|
|
73
|
+
if (!published)
|
|
74
|
+
return apiError(reply, 404, "COMPILE_ARTIFACTS_NOT_FOUND", "尚无可用的编译产物");
|
|
75
|
+
const relative = safeRelativePath(query.path);
|
|
76
|
+
const absolute = path.join(published.output, relative);
|
|
77
|
+
const outputDirectory = path.resolve(published.output);
|
|
78
|
+
const resolved = path.resolve(absolute);
|
|
79
|
+
if (!resolved.startsWith(`${outputDirectory}${path.sep}`)
|
|
80
|
+
|| !fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
|
81
|
+
return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
|
|
82
|
+
}
|
|
83
|
+
const stat = fs.statSync(resolved);
|
|
84
|
+
if (query.download === "1") {
|
|
85
|
+
reply.header("Content-Type", "application/octet-stream");
|
|
86
|
+
reply.header("Content-Disposition", contentDisposition(path.basename(relative), "attachment"));
|
|
87
|
+
reply.header("Content-Length", stat.size);
|
|
88
|
+
return reply.send(fs.createReadStream(resolved));
|
|
89
|
+
}
|
|
90
|
+
if (stat.size > 2 * 1024 * 1024 || !isTextCompileArtifact(relative)) {
|
|
91
|
+
return apiError(reply, 415, "ARTIFACT_PREVIEW_UNSUPPORTED", "该产物不能作为文本预览,请下载后查看");
|
|
92
|
+
}
|
|
93
|
+
return { path: relative, content: fs.readFileSync(resolved, "utf8") };
|
|
94
|
+
});
|
|
95
|
+
app.get("/api/projects/:id/sync/pdf", async (request, reply) => {
|
|
96
|
+
const user = requireUser(request, reply, db);
|
|
97
|
+
if (!user)
|
|
98
|
+
return;
|
|
99
|
+
const { id } = request.params;
|
|
100
|
+
const project = accessibleProject(db, id, user);
|
|
101
|
+
if (!project)
|
|
102
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
103
|
+
const query = request.query;
|
|
104
|
+
const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
|
|
105
|
+
if (!mainFile)
|
|
106
|
+
return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
|
|
107
|
+
const sourcePath = safeRelativePath(query.path ?? "");
|
|
108
|
+
const line = Number(query.line);
|
|
109
|
+
const column = Number(query.column ?? 1);
|
|
110
|
+
if (!Number.isInteger(line) || line < 1 || !Number.isInteger(column) || column < 1) {
|
|
111
|
+
return apiError(reply, 400, "SYNC_SOURCE_INVALID", "源码位置无效");
|
|
112
|
+
}
|
|
113
|
+
if (!fs.existsSync(resolveSourcePath(config, id, sourcePath))) {
|
|
114
|
+
return apiError(reply, 404, "SOURCE_FILE_NOT_FOUND", "源码文件不存在");
|
|
115
|
+
}
|
|
116
|
+
const artifacts = syncArtifacts(config, id, mainFile, project.main_file);
|
|
117
|
+
if (!artifacts)
|
|
118
|
+
return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE", "项目尚无可用的 SyncTeX 数据,请重新编译");
|
|
119
|
+
return await sourceToPdf(artifacts.source, artifacts.pdf, sourcePath, line, column);
|
|
120
|
+
});
|
|
121
|
+
app.get("/api/projects/:id/sync/source", async (request, reply) => {
|
|
122
|
+
const user = requireUser(request, reply, db);
|
|
123
|
+
if (!user)
|
|
124
|
+
return;
|
|
125
|
+
const { id } = request.params;
|
|
126
|
+
const project = accessibleProject(db, id, user);
|
|
127
|
+
if (!project)
|
|
128
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
129
|
+
const query = request.query;
|
|
130
|
+
const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
|
|
131
|
+
if (!mainFile)
|
|
132
|
+
return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
|
|
133
|
+
const page = Number(query.page);
|
|
134
|
+
const x = Number(query.x);
|
|
135
|
+
const y = Number(query.y);
|
|
136
|
+
if (!Number.isInteger(page) || page < 1 || !Number.isFinite(x) || x < 0 || !Number.isFinite(y) || y < 0) {
|
|
137
|
+
return apiError(reply, 400, "SYNC_PDF_INVALID", "PDF 位置无效");
|
|
138
|
+
}
|
|
139
|
+
const artifacts = syncArtifacts(config, id, mainFile, project.main_file);
|
|
140
|
+
if (!artifacts)
|
|
141
|
+
return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE", "项目尚无可用的 SyncTeX 数据,请重新编译");
|
|
142
|
+
const location = await pdfToSource(artifacts.source, artifacts.pdf, page, x, y);
|
|
143
|
+
const sourceDirectory = path.resolve(artifacts.source);
|
|
144
|
+
const relative = path.relative(sourceDirectory, path.resolve(location.input));
|
|
145
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
146
|
+
return apiError(reply, 400, "SYNCTEX_EXTERNAL_PATH", "SyncTeX 返回了项目外部的源码路径");
|
|
147
|
+
}
|
|
148
|
+
return { path: safeRelativePath(relative.replaceAll(path.sep, "/")), line: location.line, column: location.column };
|
|
149
|
+
});
|
|
150
|
+
app.post("/api/projects/:id/compile", async (request, reply) => {
|
|
151
|
+
const requestStartedAt = performance.now();
|
|
152
|
+
const user = requireUser(request, reply, db);
|
|
153
|
+
if (!user)
|
|
154
|
+
return;
|
|
155
|
+
const { id } = request.params;
|
|
156
|
+
const project = accessibleProject(db, id, user);
|
|
157
|
+
if (!project || !canEdit(project))
|
|
158
|
+
return apiError(reply, 403, "COMPILE_FORBIDDEN", "没有编译权限");
|
|
159
|
+
const body = (request.body ?? {});
|
|
160
|
+
const mainFile = compileMainFile(config, id, project.main_file, body.mainFile);
|
|
161
|
+
if (!mainFile)
|
|
162
|
+
return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
|
|
163
|
+
collaboration.flushProject(id);
|
|
164
|
+
const runId = randomUUID();
|
|
165
|
+
const snapshotStartedAt = performance.now();
|
|
166
|
+
const snapshot = await captureCompileSnapshot(config, id, runId, {
|
|
167
|
+
mainFile,
|
|
168
|
+
engine: project.engine,
|
|
169
|
+
latexmkrc: project.latexmkrc,
|
|
170
|
+
extraArgs: config.extraArgs
|
|
171
|
+
});
|
|
172
|
+
const snapshotMs = performance.now() - snapshotStartedAt;
|
|
173
|
+
metrics.record("compile.snapshot", snapshotMs);
|
|
174
|
+
const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
|
|
175
|
+
const publishedRun = published ? db.prepare(`SELECT id, status, log, finished_at
|
|
176
|
+
FROM compile_runs WHERE id = ? AND project_id = ?`).get(published.runId, id) : undefined;
|
|
177
|
+
const latestRun = db.prepare(`SELECT id, status FROM compile_runs
|
|
178
|
+
WHERE project_id = ? AND main_file = ? ORDER BY created_at DESC LIMIT 1`).get(id, mainFile);
|
|
179
|
+
const activeRun = db.prepare(`SELECT 1 AS active FROM compile_runs
|
|
180
|
+
WHERE project_id = ? AND main_file = ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile);
|
|
181
|
+
if (!activeRun && published && publishedRun?.status === "succeeded"
|
|
182
|
+
&& latestRun?.id === publishedRun.id && snapshot.revision === published.revision) {
|
|
183
|
+
discardCompileSnapshot(snapshot);
|
|
184
|
+
const requestMs = performance.now() - requestStartedAt;
|
|
185
|
+
metrics.record("compile.request", requestMs);
|
|
186
|
+
reply.header("Server-Timing", `snapshot;dur=${timingDuration(snapshotMs)}, total;dur=${timingDuration(requestMs)}`);
|
|
187
|
+
return {
|
|
188
|
+
runId: published.runId,
|
|
189
|
+
ok: true,
|
|
190
|
+
skipped: true,
|
|
191
|
+
log: publishedRun.log ?? "",
|
|
192
|
+
diagnostics: parseCompileDiagnostics(publishedRun.log ?? "", "succeeded"),
|
|
193
|
+
mainFile,
|
|
194
|
+
pdfUrl: compilePdfUrl(id, mainFile, published.runId),
|
|
195
|
+
pdfCompiledAt: publishedRun.finished_at,
|
|
196
|
+
timings: { snapshotMs, requestMs }
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
db.prepare("INSERT INTO compile_runs (id, project_id, requested_by, main_file, status, created_at) VALUES (?, ?, ?, ?, 'queued', ?)")
|
|
200
|
+
.run(runId, id, user.id, mainFile, now());
|
|
201
|
+
const requestedBy = { id: user.id, username: user.username, name: user.display_name };
|
|
202
|
+
let phase = "queued";
|
|
203
|
+
const broadcast = () => collaboration.signalCompileState(id, { mainFile, runId, status: phase, requestedBy, updatedAt: now() });
|
|
204
|
+
const result = await compileCoordinator.request({
|
|
205
|
+
projectId: id,
|
|
206
|
+
target: mainFile,
|
|
207
|
+
runId,
|
|
208
|
+
revision: snapshot.revision,
|
|
209
|
+
onQueued: broadcast,
|
|
210
|
+
onSelected: broadcast,
|
|
211
|
+
onDiscarded: () => {
|
|
212
|
+
db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
|
|
213
|
+
discardCompileSnapshot(snapshot);
|
|
214
|
+
},
|
|
215
|
+
execute: async () => {
|
|
216
|
+
phase = "running";
|
|
217
|
+
db.prepare("UPDATE compile_runs SET status = 'running' WHERE id = ?").run(runId);
|
|
218
|
+
broadcast();
|
|
219
|
+
let compiled;
|
|
220
|
+
try {
|
|
221
|
+
compiled = await compileProject(config, snapshot, mainFile, project.engine, project.latexmkrc);
|
|
222
|
+
if (compiled.ok && compiled.pdfPath) {
|
|
223
|
+
if (!db.prepare("SELECT 1 FROM projects WHERE id = ?").get(id))
|
|
224
|
+
throw new Error("项目已被删除");
|
|
225
|
+
const publishStartedAt = performance.now();
|
|
226
|
+
publishCompileArtifacts(config, id, snapshot, compiled);
|
|
227
|
+
if (compiled.timings) {
|
|
228
|
+
compiled.timings.publishMs = performance.now() - publishStartedAt;
|
|
229
|
+
compiled.timings.totalMs += compiled.timings.publishMs;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
discardCompileSnapshot(snapshot);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
discardCompileSnapshot(snapshot);
|
|
238
|
+
compiled = {
|
|
239
|
+
ok: false,
|
|
240
|
+
log: error instanceof Error ? error.message : String(error),
|
|
241
|
+
diagnostics: parseCompileDiagnostics(error instanceof Error ? error.message : String(error), "failed"),
|
|
242
|
+
pdfPath: null,
|
|
243
|
+
synctexPath: null
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
phase = compiled.ok ? "succeeded" : "failed";
|
|
247
|
+
db.prepare("UPDATE compile_runs SET status = ?, log = ?, finished_at = ? WHERE id = ?")
|
|
248
|
+
.run(phase, compiled.log, now(), runId);
|
|
249
|
+
broadcast();
|
|
250
|
+
return { ...compiled, runId, revision: snapshot.revision };
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
const completed = db.prepare("SELECT finished_at FROM compile_runs WHERE id = ?").get(result.runId);
|
|
254
|
+
const requestMs = performance.now() - requestStartedAt;
|
|
255
|
+
metrics.record("compile.request", requestMs);
|
|
256
|
+
if (result.timings) {
|
|
257
|
+
metrics.record("compile.cacheSync", result.timings.cacheSyncMs);
|
|
258
|
+
metrics.record("compile.latexmk", result.timings.latexmkMs);
|
|
259
|
+
metrics.record("compile.artifactCopy", result.timings.artifactCopyMs);
|
|
260
|
+
}
|
|
261
|
+
pruneCompileRuns(id);
|
|
262
|
+
const timings = result.timings ? { snapshotMs, ...result.timings, requestMs } : { snapshotMs, requestMs };
|
|
263
|
+
reply.header("Server-Timing", [
|
|
264
|
+
`snapshot;dur=${timingDuration(snapshotMs)}`,
|
|
265
|
+
result.timings ? `cache;dur=${timingDuration(result.timings.cacheSyncMs)}` : "",
|
|
266
|
+
result.timings ? `latexmk;dur=${timingDuration(result.timings.latexmkMs)}` : "",
|
|
267
|
+
result.timings ? `artifacts;dur=${timingDuration(result.timings.artifactCopyMs)}` : "",
|
|
268
|
+
result.timings?.publishMs !== undefined ? `publish;dur=${timingDuration(result.timings.publishMs)}` : "",
|
|
269
|
+
`total;dur=${timingDuration(requestMs)}`
|
|
270
|
+
].filter(Boolean).join(", "));
|
|
271
|
+
return {
|
|
272
|
+
mainFile, runId: result.runId, ok: result.ok, skipped: false, log: result.log, diagnostics: result.diagnostics,
|
|
273
|
+
pdfUrl: result.ok ? compilePdfUrl(id, mainFile, result.runId) : null,
|
|
274
|
+
pdfCompiledAt: result.ok ? completed?.finished_at ?? null : null,
|
|
275
|
+
timings
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
app.get("/api/projects/:id/pdf", async (request, reply) => {
|
|
279
|
+
const user = requireUser(request, reply, db);
|
|
280
|
+
if (!user)
|
|
281
|
+
return;
|
|
282
|
+
const { id } = request.params;
|
|
283
|
+
const project = accessibleProject(db, id, user);
|
|
284
|
+
if (!project)
|
|
285
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
|
|
286
|
+
const query = request.query;
|
|
287
|
+
const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
|
|
288
|
+
if (!mainFile)
|
|
289
|
+
return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
|
|
290
|
+
const downloading = query.download === "1";
|
|
291
|
+
const artifact = availablePdf(config, id, mainFile, project.main_file);
|
|
292
|
+
if (!artifact)
|
|
293
|
+
return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
|
|
294
|
+
const pdf = artifact.path;
|
|
295
|
+
const stat = fs.statSync(pdf);
|
|
296
|
+
const etag = `"${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}"`;
|
|
297
|
+
reply.header("Content-Type", "application/pdf");
|
|
298
|
+
const targetSuffix = mainFile === project.main_file ? "" : `-${path.basename(mainFile, ".tex")}`;
|
|
299
|
+
const filename = downloading ? `${project.name}${targetSuffix}-${pdfDownloadTimestamp()}.pdf` : `${project.name}${targetSuffix}.pdf`;
|
|
300
|
+
reply.header("Content-Disposition", contentDisposition(filename, downloading ? "attachment" : "inline"));
|
|
301
|
+
reply.header("Cache-Control", "private, no-cache");
|
|
302
|
+
reply.header("Accept-Ranges", "bytes");
|
|
303
|
+
reply.header("ETag", etag);
|
|
304
|
+
reply.header("Last-Modified", stat.mtime.toUTCString());
|
|
305
|
+
if (request.headers["if-none-match"]?.split(",").some((candidate) => candidate.trim() === etag)) {
|
|
306
|
+
return reply.code(304).send();
|
|
307
|
+
}
|
|
308
|
+
const range = parseByteRange(request.headers.range, stat.size);
|
|
309
|
+
if (range === "invalid") {
|
|
310
|
+
reply.header("Content-Range", `bytes */${stat.size}`);
|
|
311
|
+
return reply.code(416).send();
|
|
312
|
+
}
|
|
313
|
+
if (range) {
|
|
314
|
+
reply.header("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
|
|
315
|
+
reply.header("Content-Length", range.end - range.start + 1);
|
|
316
|
+
return reply.code(206).send(fs.createReadStream(pdf, range));
|
|
317
|
+
}
|
|
318
|
+
reply.header("Content-Length", stat.size);
|
|
319
|
+
return reply.send(fs.createReadStream(pdf));
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
function pdfDownloadTimestamp(date = new Date()) {
|
|
323
|
+
return date.toISOString().slice(0, 19).replace("T", "-").replaceAll(":", "");
|
|
324
|
+
}
|
|
325
|
+
function parseByteRange(value, size) {
|
|
326
|
+
if (!value)
|
|
327
|
+
return null;
|
|
328
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim());
|
|
329
|
+
if (!match || (!match[1] && !match[2]) || size < 1)
|
|
330
|
+
return "invalid";
|
|
331
|
+
if (!match[1]) {
|
|
332
|
+
const suffixLength = Number(match[2]);
|
|
333
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0)
|
|
334
|
+
return "invalid";
|
|
335
|
+
return { start: Math.max(0, size - suffixLength), end: size - 1 };
|
|
336
|
+
}
|
|
337
|
+
const start = Number(match[1]);
|
|
338
|
+
const requestedEnd = match[2] ? Number(match[2]) : size - 1;
|
|
339
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd) || start >= size || requestedEnd < start) {
|
|
340
|
+
return "invalid";
|
|
341
|
+
}
|
|
342
|
+
return { start, end: Math.min(requestedEnd, size - 1) };
|
|
343
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
export const CONFIG_FILE_NAME = "texlite.config.json";
|
|
5
|
+
function nonEmpty(value) {
|
|
6
|
+
const trimmed = value?.trim();
|
|
7
|
+
return trimmed ? trimmed : undefined;
|
|
8
|
+
}
|
|
9
|
+
export function xdgConfigHome(environment = process.env, home = os.homedir()) {
|
|
10
|
+
return path.resolve(nonEmpty(environment.XDG_CONFIG_HOME) ?? path.join(home, ".config"));
|
|
11
|
+
}
|
|
12
|
+
export function xdgDataHome(environment = process.env, home = os.homedir()) {
|
|
13
|
+
return path.resolve(nonEmpty(environment.XDG_DATA_HOME) ?? path.join(home, ".local", "share"));
|
|
14
|
+
}
|
|
15
|
+
export function texliteConfigDirectory(environment = process.env, home = os.homedir()) {
|
|
16
|
+
return path.join(xdgConfigHome(environment, home), "texlite");
|
|
17
|
+
}
|
|
18
|
+
export function defaultConfigPath(environment = process.env, home = os.homedir()) {
|
|
19
|
+
return path.join(texliteConfigDirectory(environment, home), CONFIG_FILE_NAME);
|
|
20
|
+
}
|
|
21
|
+
export function defaultDataDirectory(environment = process.env, home = os.homedir()) {
|
|
22
|
+
return path.join(xdgDataHome(environment, home), "texlite");
|
|
23
|
+
}
|
|
24
|
+
export function resolveConfigPath(explicitPath, environment = process.env) {
|
|
25
|
+
const configured = nonEmpty(explicitPath) ?? nonEmpty(environment.TEXLITE_CONFIG);
|
|
26
|
+
return path.resolve(configured ?? defaultConfigPath(environment));
|
|
27
|
+
}
|
|
28
|
+
/** The directory containing the installed package's compiled assets. */
|
|
29
|
+
export function packageRootDirectory(moduleUrl = import.meta.url) {
|
|
30
|
+
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..", "..");
|
|
31
|
+
}
|
|
32
|
+
/** The Vite output beside dist/server in a production npm package. */
|
|
33
|
+
export function packageClientDirectory(moduleUrl = import.meta.url) {
|
|
34
|
+
// Resolve from the package root rather than from the source/compiled module
|
|
35
|
+
// directory. This keeps source execution (`tsx src/server/...`) pointed at
|
|
36
|
+
// the repository's `dist/client` after a build, while compiled npm installs
|
|
37
|
+
// resolve to `<package>/dist/client` as well.
|
|
38
|
+
return path.join(packageRootDirectory(moduleUrl), "dist", "client");
|
|
39
|
+
}
|
|
40
|
+
export function packageServerEntry(moduleUrl = import.meta.url) {
|
|
41
|
+
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "index.js");
|
|
42
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const scrypt = promisify(scryptCallback);
|
|
4
|
+
const KEY_LENGTH = 64;
|
|
5
|
+
export const MIN_PASSWORD_LENGTH = 8;
|
|
6
|
+
export async function hashPassword(password) {
|
|
7
|
+
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
8
|
+
throw new Error(`密码至少需要 ${MIN_PASSWORD_LENGTH} 个字符`);
|
|
9
|
+
}
|
|
10
|
+
const salt = randomBytes(16);
|
|
11
|
+
const key = (await scrypt(password, salt, KEY_LENGTH));
|
|
12
|
+
return `scrypt$${salt.toString("base64url")}$${key.toString("base64url")}`;
|
|
13
|
+
}
|
|
14
|
+
export async function verifyPassword(password, encoded) {
|
|
15
|
+
const [algorithm, saltText, keyText] = encoded.split("$");
|
|
16
|
+
if (algorithm !== "scrypt" || !saltText || !keyText)
|
|
17
|
+
return false;
|
|
18
|
+
const salt = Buffer.from(saltText, "base64url");
|
|
19
|
+
const expected = Buffer.from(keyText, "base64url");
|
|
20
|
+
const actual = (await scrypt(password, salt, expected.length));
|
|
21
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
22
|
+
}
|
|
23
|
+
export function createSessionToken() {
|
|
24
|
+
const token = randomBytes(32).toString("base64url");
|
|
25
|
+
return { token, digest: digestToken(token) };
|
|
26
|
+
}
|
|
27
|
+
export function digestToken(token) {
|
|
28
|
+
return createHash("sha256").update(token).digest("hex");
|
|
29
|
+
}
|