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,379 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { listProjectFiles, outputRoot, projectRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
|
|
5
|
+
const AUTOSAVE_COALESCE_MS = 2 * 60_000;
|
|
6
|
+
export class ProjectHistoryService {
|
|
7
|
+
config;
|
|
8
|
+
db;
|
|
9
|
+
constructor(config, db) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.db = db;
|
|
12
|
+
}
|
|
13
|
+
record(projectId, authorId, reason, changedRoots) {
|
|
14
|
+
const project = this.project(projectId);
|
|
15
|
+
const previous = this.latestRow(projectId);
|
|
16
|
+
const previousManifest = this.baseline(projectId) ?? (previous ? parseManifest(previous.manifest_json) : null);
|
|
17
|
+
const manifest = previousManifest ? cloneManifest(previousManifest) : this.emptyManifest(project);
|
|
18
|
+
manifest.settings = settings(project);
|
|
19
|
+
if (!previousManifest || changedRoots === undefined) {
|
|
20
|
+
manifest.files = this.snapshotAllFiles(projectId);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
for (const rootInput of changedRoots) {
|
|
24
|
+
const root = safeRelativePath(rootInput);
|
|
25
|
+
for (const filePath of Object.keys(manifest.files)) {
|
|
26
|
+
if (filePath === root || filePath.startsWith(`${root}/`))
|
|
27
|
+
delete manifest.files[filePath];
|
|
28
|
+
}
|
|
29
|
+
for (const [filePath, file] of Object.entries(this.snapshotPath(projectId, root)))
|
|
30
|
+
manifest.files[filePath] = file;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const changedPaths = previousManifest ? changedManifestPaths(previousManifest, manifest) : Object.keys(manifest.files).sort();
|
|
34
|
+
if (previousManifest && changedPaths.length === 0 && JSON.stringify(previousManifest.settings) === JSON.stringify(manifest.settings)) {
|
|
35
|
+
this.saveBaseline(projectId, manifest);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const createdAt = new Date().toISOString();
|
|
39
|
+
if (reason === "autosave" && previous?.reason === "autosave" && !previous.label && previous.author_id === authorId
|
|
40
|
+
&& Date.parse(createdAt) - Date.parse(previous.created_at) < AUTOSAVE_COALESCE_MS) {
|
|
41
|
+
const merged = [...new Set([...parseStringArray(previous.changed_paths_json), ...changedPaths])].sort();
|
|
42
|
+
this.db.prepare(`UPDATE project_history_versions
|
|
43
|
+
SET manifest_json = ?, changed_paths_json = ? WHERE id = ?`)
|
|
44
|
+
.run(JSON.stringify(manifest), JSON.stringify(merged), previous.id);
|
|
45
|
+
this.saveBaseline(projectId, manifest);
|
|
46
|
+
this.removeUnreferencedObjects(projectId, new Set(previousManifest
|
|
47
|
+
? Object.values(previousManifest.files).map((file) => file.digest)
|
|
48
|
+
: []));
|
|
49
|
+
this.pruneVersions(projectId);
|
|
50
|
+
return this.version(previous.id);
|
|
51
|
+
}
|
|
52
|
+
const id = randomUUID();
|
|
53
|
+
this.db.prepare(`INSERT INTO project_history_versions
|
|
54
|
+
(id, project_id, author_id, reason, manifest_json, changed_paths_json, created_at)
|
|
55
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
56
|
+
.run(id, projectId, authorId, reason, JSON.stringify(manifest), JSON.stringify(changedPaths), createdAt);
|
|
57
|
+
this.saveBaseline(projectId, manifest);
|
|
58
|
+
this.pruneVersions(projectId);
|
|
59
|
+
return this.version(id);
|
|
60
|
+
}
|
|
61
|
+
list(projectId, limit = 100) {
|
|
62
|
+
const rows = this.db.prepare(`SELECT history.*, user.username AS author_username, user.display_name AS author_name
|
|
63
|
+
FROM project_history_versions history LEFT JOIN users user ON user.id = history.author_id
|
|
64
|
+
WHERE history.project_id = ? ORDER BY history.created_at DESC, history.rowid DESC LIMIT ?`)
|
|
65
|
+
.all(projectId, Math.min(200, Math.max(1, limit)));
|
|
66
|
+
return rows.map((row) => versionJson(row));
|
|
67
|
+
}
|
|
68
|
+
stats(projectId) {
|
|
69
|
+
const rows = this.db.prepare(`SELECT reason, label, manifest_json FROM project_history_versions
|
|
70
|
+
WHERE project_id = ?`).all(projectId);
|
|
71
|
+
const objects = new Map();
|
|
72
|
+
let ordinaryVersionCount = 0;
|
|
73
|
+
let labeledVersionCount = 0;
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
if (row.label)
|
|
76
|
+
labeledVersionCount += 1;
|
|
77
|
+
else if (row.reason !== "initial")
|
|
78
|
+
ordinaryVersionCount += 1;
|
|
79
|
+
for (const file of Object.values(parseManifest(row.manifest_json).files)) {
|
|
80
|
+
if (!objects.has(file.digest))
|
|
81
|
+
objects.set(file.digest, file.size);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const baseline = this.baseline(projectId);
|
|
85
|
+
if (baseline) {
|
|
86
|
+
for (const file of Object.values(baseline.files)) {
|
|
87
|
+
if (!objects.has(file.digest))
|
|
88
|
+
objects.set(file.digest, file.size);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const objectBytes = [...objects.values()].reduce((sum, size) => sum + size, 0);
|
|
92
|
+
return {
|
|
93
|
+
versionCount: rows.length,
|
|
94
|
+
ordinaryVersionCount,
|
|
95
|
+
labeledVersionCount,
|
|
96
|
+
objectCount: objects.size,
|
|
97
|
+
objectBytes,
|
|
98
|
+
maxVersions: this.config.historyMaxVersions,
|
|
99
|
+
maxStorageBytes: this.config.historyMaxStorageBytes,
|
|
100
|
+
storageLimitExceeded: objectBytes > this.config.historyMaxStorageBytes
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
enforceRetention(projectId) {
|
|
104
|
+
this.pruneVersions(projectId);
|
|
105
|
+
}
|
|
106
|
+
deleteVersion(projectId, versionId) {
|
|
107
|
+
const row = this.db.prepare("SELECT id, manifest_json FROM project_history_versions WHERE id = ? AND project_id = ?")
|
|
108
|
+
.get(versionId, projectId);
|
|
109
|
+
if (!row)
|
|
110
|
+
return false;
|
|
111
|
+
this.saveBaseline(projectId, this.snapshotCurrent(projectId));
|
|
112
|
+
this.deleteVersions(projectId, [row]);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
clear(projectId) {
|
|
116
|
+
this.db.transaction(() => {
|
|
117
|
+
this.db.prepare("DELETE FROM project_history_state WHERE project_id = ?").run(projectId);
|
|
118
|
+
this.db.prepare("DELETE FROM project_history_versions WHERE project_id = ?").run(projectId);
|
|
119
|
+
})();
|
|
120
|
+
fs.rmSync(path.join(outputRoot(this.config, projectId), ".texlite", "history"), { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
version(id, projectId) {
|
|
123
|
+
const row = this.db.prepare(`SELECT history.*, user.username AS author_username, user.display_name AS author_name
|
|
124
|
+
FROM project_history_versions history LEFT JOIN users user ON user.id = history.author_id
|
|
125
|
+
WHERE history.id = ? ${projectId ? "AND history.project_id = ?" : ""}`)
|
|
126
|
+
.get(...(projectId ? [id, projectId] : [id]));
|
|
127
|
+
return row ? versionJson(row) : null;
|
|
128
|
+
}
|
|
129
|
+
manifest(projectId, versionId) {
|
|
130
|
+
const row = this.db.prepare("SELECT manifest_json FROM project_history_versions WHERE id = ? AND project_id = ?")
|
|
131
|
+
.get(versionId, projectId);
|
|
132
|
+
return row ? parseManifest(row.manifest_json) : null;
|
|
133
|
+
}
|
|
134
|
+
setLabel(projectId, versionId, label) {
|
|
135
|
+
const result = this.db.prepare("UPDATE project_history_versions SET label = ? WHERE id = ? AND project_id = ?")
|
|
136
|
+
.run(label, versionId, projectId);
|
|
137
|
+
return result.changes ? this.version(versionId, projectId) : null;
|
|
138
|
+
}
|
|
139
|
+
readTextFile(projectId, versionId, filePathInput) {
|
|
140
|
+
const filePath = safeRelativePath(filePathInput);
|
|
141
|
+
const entry = this.manifest(projectId, versionId)?.files[filePath];
|
|
142
|
+
if (!entry || entry.size > 2 * 1024 * 1024)
|
|
143
|
+
return null;
|
|
144
|
+
return fs.readFileSync(this.objectPath(projectId, entry.digest), "utf8");
|
|
145
|
+
}
|
|
146
|
+
restore(projectId, versionId, filePathInput) {
|
|
147
|
+
const manifest = this.manifest(projectId, versionId);
|
|
148
|
+
if (!manifest)
|
|
149
|
+
throw Object.assign(new Error("历史版本不存在"), { code: "HISTORY_VERSION_NOT_FOUND", statusCode: 404 });
|
|
150
|
+
if (filePathInput) {
|
|
151
|
+
const filePath = safeRelativePath(filePathInput);
|
|
152
|
+
const entry = manifest.files[filePath];
|
|
153
|
+
if (!entry)
|
|
154
|
+
throw Object.assign(new Error("该历史版本中不存在此文件"), { code: "HISTORY_FILE_NOT_FOUND", statusCode: 404 });
|
|
155
|
+
const target = resolveSourcePath(this.config, projectId, filePath);
|
|
156
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
157
|
+
const temporary = `${target}.history-${randomUUID()}.tmp`;
|
|
158
|
+
fs.copyFileSync(this.objectPath(projectId, entry.digest), temporary);
|
|
159
|
+
fs.chmodSync(temporary, 0o600);
|
|
160
|
+
fs.renameSync(temporary, target);
|
|
161
|
+
return { restoredPaths: [filePath], manifest };
|
|
162
|
+
}
|
|
163
|
+
this.restoreProjectTree(projectId, manifest);
|
|
164
|
+
this.db.prepare("UPDATE projects SET main_file = ?, engine = ?, latexmkrc = ? WHERE id = ?")
|
|
165
|
+
.run(manifest.settings.mainFile, manifest.settings.engine, manifest.settings.latexmkrc, projectId);
|
|
166
|
+
return { restoredPaths: Object.keys(manifest.files).sort(), manifest };
|
|
167
|
+
}
|
|
168
|
+
latestRow(projectId) {
|
|
169
|
+
return this.db.prepare(`SELECT * FROM project_history_versions WHERE project_id = ?
|
|
170
|
+
ORDER BY created_at DESC, rowid DESC LIMIT 1`).get(projectId) ?? null;
|
|
171
|
+
}
|
|
172
|
+
project(projectId) {
|
|
173
|
+
const project = this.db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId);
|
|
174
|
+
if (!project)
|
|
175
|
+
throw new Error("项目不存在");
|
|
176
|
+
return project;
|
|
177
|
+
}
|
|
178
|
+
emptyManifest(project) {
|
|
179
|
+
return { version: 1, files: {}, settings: settings(project) };
|
|
180
|
+
}
|
|
181
|
+
snapshotCurrent(projectId) {
|
|
182
|
+
const project = this.project(projectId);
|
|
183
|
+
return { version: 1, files: this.snapshotAllFiles(projectId), settings: settings(project) };
|
|
184
|
+
}
|
|
185
|
+
baseline(projectId) {
|
|
186
|
+
const row = this.db.prepare("SELECT manifest_json FROM project_history_state WHERE project_id = ?")
|
|
187
|
+
.get(projectId);
|
|
188
|
+
return row ? parseManifest(row.manifest_json) : null;
|
|
189
|
+
}
|
|
190
|
+
saveBaseline(projectId, manifest) {
|
|
191
|
+
this.db.prepare(`INSERT INTO project_history_state (project_id, manifest_json, updated_at) VALUES (?, ?, ?)
|
|
192
|
+
ON CONFLICT(project_id) DO UPDATE SET manifest_json = excluded.manifest_json, updated_at = excluded.updated_at`)
|
|
193
|
+
.run(projectId, JSON.stringify(manifest), new Date().toISOString());
|
|
194
|
+
}
|
|
195
|
+
snapshotAllFiles(projectId) {
|
|
196
|
+
const files = {};
|
|
197
|
+
for (const entry of listProjectFiles(this.config, projectId)) {
|
|
198
|
+
if (entry.type === "file")
|
|
199
|
+
files[entry.path] = this.storeFile(projectId, entry.path);
|
|
200
|
+
}
|
|
201
|
+
return files;
|
|
202
|
+
}
|
|
203
|
+
snapshotPath(projectId, root) {
|
|
204
|
+
const files = {};
|
|
205
|
+
const absolute = resolveSourcePath(this.config, projectId, root);
|
|
206
|
+
if (!fs.existsSync(absolute))
|
|
207
|
+
return files;
|
|
208
|
+
if (fs.statSync(absolute).isFile()) {
|
|
209
|
+
files[root] = this.storeFile(projectId, root);
|
|
210
|
+
return files;
|
|
211
|
+
}
|
|
212
|
+
for (const entry of listProjectFiles(this.config, projectId)) {
|
|
213
|
+
if (entry.type === "file" && entry.path.startsWith(`${root}/`))
|
|
214
|
+
files[entry.path] = this.storeFile(projectId, entry.path);
|
|
215
|
+
}
|
|
216
|
+
return files;
|
|
217
|
+
}
|
|
218
|
+
storeFile(projectId, filePath) {
|
|
219
|
+
const content = fs.readFileSync(resolveSourcePath(this.config, projectId, filePath));
|
|
220
|
+
const digest = createHash("sha256").update(content).digest("hex");
|
|
221
|
+
const target = this.objectPath(projectId, digest);
|
|
222
|
+
if (!fs.existsSync(target)) {
|
|
223
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
224
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
225
|
+
fs.writeFileSync(temporary, content, { mode: 0o600 });
|
|
226
|
+
try {
|
|
227
|
+
fs.renameSync(temporary, target);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
fs.rmSync(temporary, { force: true });
|
|
231
|
+
if (!fs.existsSync(target))
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { digest, size: content.length };
|
|
236
|
+
}
|
|
237
|
+
objectPath(projectId, digest) {
|
|
238
|
+
if (!/^[a-f0-9]{64}$/.test(digest))
|
|
239
|
+
throw new Error("历史对象摘要无效");
|
|
240
|
+
return path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects", digest.slice(0, 2), digest);
|
|
241
|
+
}
|
|
242
|
+
pruneVersions(projectId) {
|
|
243
|
+
const obsolete = this.db.prepare(`SELECT id, manifest_json FROM project_history_versions
|
|
244
|
+
WHERE project_id = ? AND label IS NULL AND reason <> 'initial'
|
|
245
|
+
ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET ?`)
|
|
246
|
+
.all(projectId, this.config.historyMaxVersions);
|
|
247
|
+
this.deleteVersions(projectId, obsolete);
|
|
248
|
+
// The latest version is always retained as the baseline for subsequent
|
|
249
|
+
// incremental snapshots. Initial and labeled versions are also protected,
|
|
250
|
+
// so explicitly preserved work can make the project exceed the soft cap.
|
|
251
|
+
while (this.stats(projectId).objectBytes > this.config.historyMaxStorageBytes) {
|
|
252
|
+
const candidate = this.db.prepare(`SELECT id, manifest_json FROM project_history_versions
|
|
253
|
+
WHERE project_id = ? AND label IS NULL AND reason <> 'initial'
|
|
254
|
+
AND id <> (SELECT id FROM project_history_versions WHERE project_id = ?
|
|
255
|
+
ORDER BY created_at DESC, rowid DESC LIMIT 1)
|
|
256
|
+
ORDER BY created_at ASC, rowid ASC LIMIT 1`).get(projectId, projectId);
|
|
257
|
+
if (!candidate)
|
|
258
|
+
break;
|
|
259
|
+
this.deleteVersions(projectId, [candidate]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
deleteVersions(projectId, rows) {
|
|
263
|
+
if (!rows.length)
|
|
264
|
+
return;
|
|
265
|
+
const candidates = new Set();
|
|
266
|
+
for (const row of rows) {
|
|
267
|
+
for (const file of Object.values(parseManifest(row.manifest_json).files))
|
|
268
|
+
candidates.add(file.digest);
|
|
269
|
+
}
|
|
270
|
+
const remove = this.db.prepare("DELETE FROM project_history_versions WHERE id = ? AND project_id = ?");
|
|
271
|
+
this.db.transaction(() => { for (const row of rows)
|
|
272
|
+
remove.run(row.id, projectId); })();
|
|
273
|
+
this.removeUnreferencedObjects(projectId, candidates);
|
|
274
|
+
}
|
|
275
|
+
removeUnreferencedObjects(projectId, candidates) {
|
|
276
|
+
if (!candidates.size)
|
|
277
|
+
return;
|
|
278
|
+
const rows = this.db.prepare("SELECT manifest_json FROM project_history_versions WHERE project_id = ?")
|
|
279
|
+
.all(projectId);
|
|
280
|
+
for (const row of rows) {
|
|
281
|
+
for (const file of Object.values(parseManifest(row.manifest_json).files))
|
|
282
|
+
candidates.delete(file.digest);
|
|
283
|
+
if (!candidates.size)
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const baseline = this.baseline(projectId);
|
|
287
|
+
if (baseline) {
|
|
288
|
+
for (const file of Object.values(baseline.files))
|
|
289
|
+
candidates.delete(file.digest);
|
|
290
|
+
}
|
|
291
|
+
for (const digest of candidates)
|
|
292
|
+
fs.rmSync(this.objectPath(projectId, digest), { force: true });
|
|
293
|
+
}
|
|
294
|
+
restoreProjectTree(projectId, manifest) {
|
|
295
|
+
const root = projectRoot(this.config, projectId);
|
|
296
|
+
const live = sourceRoot(this.config, projectId);
|
|
297
|
+
const temporary = path.join(root, `.history-restore-${randomUUID()}`);
|
|
298
|
+
const backup = path.join(root, `.history-backup-${randomUUID()}`);
|
|
299
|
+
fs.mkdirSync(temporary, { recursive: true, mode: 0o700 });
|
|
300
|
+
let liveMoved = false;
|
|
301
|
+
let replacementInstalled = false;
|
|
302
|
+
let gitMoved = false;
|
|
303
|
+
try {
|
|
304
|
+
for (const [filePath, entry] of Object.entries(manifest.files)) {
|
|
305
|
+
const target = path.join(temporary, safeRelativePath(filePath));
|
|
306
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
307
|
+
fs.copyFileSync(this.objectPath(projectId, entry.digest), target);
|
|
308
|
+
fs.chmodSync(target, 0o600);
|
|
309
|
+
}
|
|
310
|
+
fs.renameSync(live, backup);
|
|
311
|
+
liveMoved = true;
|
|
312
|
+
fs.renameSync(temporary, live);
|
|
313
|
+
replacementInstalled = true;
|
|
314
|
+
const git = path.join(backup, ".git");
|
|
315
|
+
if (fs.existsSync(git)) {
|
|
316
|
+
fs.renameSync(git, path.join(live, ".git"));
|
|
317
|
+
gitMoved = true;
|
|
318
|
+
}
|
|
319
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
// A failed restore must leave the original tree, including its Git data,
|
|
323
|
+
// intact. The replacement is entirely reconstructible from history.
|
|
324
|
+
if (replacementInstalled && fs.existsSync(live)) {
|
|
325
|
+
if (gitMoved && fs.existsSync(path.join(live, ".git"))) {
|
|
326
|
+
fs.renameSync(path.join(live, ".git"), path.join(backup, ".git"));
|
|
327
|
+
}
|
|
328
|
+
fs.rmSync(live, { recursive: true, force: true });
|
|
329
|
+
}
|
|
330
|
+
if (liveMoved && fs.existsSync(backup))
|
|
331
|
+
fs.renameSync(backup, live);
|
|
332
|
+
fs.rmSync(temporary, { recursive: true, force: true });
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function settings(project) {
|
|
338
|
+
return { mainFile: project.main_file, engine: project.engine, latexmkrc: project.latexmkrc };
|
|
339
|
+
}
|
|
340
|
+
function cloneManifest(manifest) {
|
|
341
|
+
return { version: 1, files: { ...manifest.files }, settings: { ...manifest.settings } };
|
|
342
|
+
}
|
|
343
|
+
function parseManifest(value) {
|
|
344
|
+
const parsed = JSON.parse(value);
|
|
345
|
+
if (parsed.version !== 1 || !parsed.files || !parsed.settings)
|
|
346
|
+
throw new Error("历史版本清单损坏");
|
|
347
|
+
return parsed;
|
|
348
|
+
}
|
|
349
|
+
function parseStringArray(value) {
|
|
350
|
+
try {
|
|
351
|
+
const parsed = JSON.parse(value);
|
|
352
|
+
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function changedManifestPaths(previous, next) {
|
|
359
|
+
const paths = new Set([...Object.keys(previous.files), ...Object.keys(next.files)]);
|
|
360
|
+
return [...paths].filter((filePath) => previous.files[filePath]?.digest !== next.files[filePath]?.digest).sort();
|
|
361
|
+
}
|
|
362
|
+
function versionJson(row) {
|
|
363
|
+
const manifest = parseManifest(row.manifest_json);
|
|
364
|
+
const files = Object.values(manifest.files);
|
|
365
|
+
return {
|
|
366
|
+
id: row.id,
|
|
367
|
+
reason: row.reason,
|
|
368
|
+
label: row.label,
|
|
369
|
+
createdAt: row.created_at,
|
|
370
|
+
author: row.author_id ? {
|
|
371
|
+
id: row.author_id,
|
|
372
|
+
username: row.author_username ?? "deleted-user",
|
|
373
|
+
name: row.author_name ?? "Deleted User"
|
|
374
|
+
} : null,
|
|
375
|
+
changedPaths: parseStringArray(row.changed_paths_json),
|
|
376
|
+
fileCount: files.length,
|
|
377
|
+
totalSize: files.reduce((sum, file) => sum + file.size, 0)
|
|
378
|
+
};
|
|
379
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function apiError(reply, status, code, message, details = {}) {
|
|
2
|
+
return reply.code(status).send({ code, error: message, ...details });
|
|
3
|
+
}
|
|
4
|
+
export function contentDisposition(filename, mode) {
|
|
5
|
+
const fallback = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
|
6
|
+
return `${mode}; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { loadConfig } from "./config.js";
|
|
4
|
+
import { activeAdminCount, openDatabase } from "./db.js";
|
|
5
|
+
import { buildApp } from "./app.js";
|
|
6
|
+
import { assertEnvironment } from "./environment.js";
|
|
7
|
+
export async function startServer(configPath) {
|
|
8
|
+
const config = loadConfig(configPath);
|
|
9
|
+
const environment = await assertEnvironment(config);
|
|
10
|
+
const db = openDatabase(config);
|
|
11
|
+
if (activeAdminCount(db) === 0) {
|
|
12
|
+
db.close();
|
|
13
|
+
throw new Error("No active administrator found. Run `texlite init` first (or `npm run init` from a source checkout); the server will not start.");
|
|
14
|
+
}
|
|
15
|
+
let app;
|
|
16
|
+
try {
|
|
17
|
+
app = await buildApp(config, db);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
db.close();
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
await app.listen({ host: config.host, port: config.port });
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
db.close();
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
app.log.info(`Environment ready: ${environment.map((item) => `${item.name} ${item.version}`).join(", ")}`);
|
|
31
|
+
app.log.info(`${config.siteName} running at http://${config.host}:${config.port}`);
|
|
32
|
+
return {
|
|
33
|
+
address: `http://${config.host}:${config.port}`,
|
|
34
|
+
close: async () => {
|
|
35
|
+
try {
|
|
36
|
+
await app.close();
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
db.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export async function serve(configPath) {
|
|
45
|
+
const running = await startServer(configPath);
|
|
46
|
+
let closed = false;
|
|
47
|
+
const shutdown = async () => {
|
|
48
|
+
if (closed)
|
|
49
|
+
return;
|
|
50
|
+
closed = true;
|
|
51
|
+
await running.close();
|
|
52
|
+
};
|
|
53
|
+
await new Promise((resolve) => {
|
|
54
|
+
const complete = () => { void shutdown().finally(resolve); };
|
|
55
|
+
process.once("SIGINT", complete);
|
|
56
|
+
process.once("SIGTERM", complete);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export async function main() {
|
|
60
|
+
await serve();
|
|
61
|
+
}
|
|
62
|
+
if (isMainModule()) {
|
|
63
|
+
main().catch((error) => {
|
|
64
|
+
console.error(error instanceof Error ? error.message : error);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function isMainModule() {
|
|
69
|
+
try {
|
|
70
|
+
return fs.realpathSync(process.argv[1] ?? "") === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|