texlite 0.8.4 → 0.9.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.
Files changed (65) hide show
  1. package/DESIGN.md +75 -8
  2. package/OPERATIONS.md +19 -2
  3. package/dist/client/assets/{AdminUsers-CqOmV9rq.js → AdminUsers-3CHb5012.js} +1 -1
  4. package/dist/client/assets/{CitationLibraryDialog-BmWFXTW2.js → CitationLibraryDialog-zPpsUhWJ.js} +1 -1
  5. package/dist/client/assets/Dashboard-CWfSLzD3.js +2 -0
  6. package/dist/client/assets/{GitDialog-BCfSHuMB.js → GitDialog-BUU2U_LV.js} +2 -2
  7. package/dist/client/assets/HistoryDialog-B6aopsrw.js +7 -0
  8. package/dist/client/assets/{LatexEditor-DSopAHbC.js → LatexEditor-CM445Tk0.js} +1 -1
  9. package/dist/client/assets/{PdfPreview-BDkU20jP.js → PdfPreview-BFVNczSk.js} +1 -1
  10. package/dist/client/assets/{ProjectIconPickerDialog-GAd8HGOx.js → ProjectIconPickerDialog-Cf0zBf8u.js} +1 -1
  11. package/dist/client/assets/{ProjectNavigationDialogs-BTLcRY7i.js → ProjectNavigationDialogs-TPbe11iR.js} +1 -1
  12. package/dist/client/assets/{ProjectSettings-BJWTkoZq.js → ProjectSettings-CDQvIlQ4.js} +1 -1
  13. package/dist/client/assets/ProjectWorkspace-C6GrvKN6.js +27 -0
  14. package/dist/client/assets/SelectionHistoryDialog-CyE0_YDs.js +3 -0
  15. package/dist/client/assets/{SystemMetricsDialog-C9P4TSQl.js → SystemMetricsDialog-CuqHPUnn.js} +1 -1
  16. package/dist/client/assets/{TagManagementDialog-LYbib36_.js → TagManagementDialog-gRLM3lLx.js} +1 -1
  17. package/dist/client/assets/{WordCountDialog-BFg0GeJy.js → WordCountDialog-B4PI0vEk.js} +1 -1
  18. package/dist/client/assets/check-BXvzrdoz.js +1 -0
  19. package/dist/client/assets/chevron-left-BB5MYb7g.js +1 -0
  20. package/dist/client/assets/hard-drive-6uYFyezl.js +1 -0
  21. package/dist/client/assets/index-BRT5O-Hg.js +5 -0
  22. package/dist/client/assets/index-C0t11tlT.js +14 -0
  23. package/dist/client/assets/index-DXWp2bzM.css +1 -0
  24. package/dist/client/assets/{key-round-BaIj7Jch.js → key-round-DOAxDSfz.js} +1 -1
  25. package/dist/client/assets/minimize-2-BWtbag2I.js +1 -0
  26. package/dist/client/assets/{minus-Beso_n-6.js → minus-9eVJnugf.js} +1 -1
  27. package/dist/client/assets/{plus-BfEC7mzf.js → plus-BGXipRoG.js} +1 -1
  28. package/dist/client/assets/{refresh-cw-FZs9MtoA.js → refresh-cw-Dvp3O90f.js} +1 -1
  29. package/dist/client/assets/{search-DL2UGvdj.js → search-b1ORp1k8.js} +1 -1
  30. package/dist/client/assets/{sigma-CSRwPPkk.js → sigma-Bk0_MzWi.js} +1 -1
  31. package/dist/client/assets/{spellCheck-L9gD2d1M.js → spellCheck-BJjWgnef.js} +1 -1
  32. package/dist/client/assets/{tags-CjkWzXqX.js → tags-JAkbmWns.js} +1 -1
  33. package/dist/client/assets/{type-D1mmDdLM.js → type-CE2DYkPH.js} +1 -1
  34. package/dist/client/assets/{user-plus-YJ3ftuL5.js → user-plus-CKalKSsw.js} +1 -1
  35. package/dist/client/assets/x-Bq29njHL.js +1 -0
  36. package/dist/client/index.html +2 -2
  37. package/dist/server/app.js +45 -4
  38. package/dist/server/cli.js +4 -0
  39. package/dist/server/collaboration.js +219 -7
  40. package/dist/server/commentMentions.js +139 -0
  41. package/dist/server/config.js +9 -2
  42. package/dist/server/db.js +59 -0
  43. package/dist/server/editHistory.js +411 -0
  44. package/dist/server/editHistoryRetry.js +90 -0
  45. package/dist/server/history.js +136 -20
  46. package/dist/server/historyRetention.js +37 -0
  47. package/dist/server/i18n.js +4 -0
  48. package/dist/server/index.js +6 -0
  49. package/dist/server/logRotation.js +82 -0
  50. package/dist/server/pm2.js +7 -0
  51. package/dist/server/routes/comments.js +107 -16
  52. package/dist/server/routes/projectHistory.js +93 -7
  53. package/dist/server/routes/projectShared.js +2 -1
  54. package/dist/server/routes/projects.js +3 -1
  55. package/package.json +1 -1
  56. package/texlite.config.example.json +4 -1
  57. package/dist/client/assets/Dashboard-BfN2bCgZ.js +0 -2
  58. package/dist/client/assets/HistoryDialog-BtQhyTr9.js +0 -5
  59. package/dist/client/assets/ProjectWorkspace-WLYA7BSv.js +0 -27
  60. package/dist/client/assets/check-bnimQPun.js +0 -1
  61. package/dist/client/assets/chevron-left-DNM7mhNi.js +0 -1
  62. package/dist/client/assets/hard-drive-CF3WDfkG.js +0 -1
  63. package/dist/client/assets/index-CzVDvC5h.js +0 -14
  64. package/dist/client/assets/index-RjUuWk8F.css +0 -1
  65. package/dist/client/assets/x-BDSEosRE.js +0 -1
@@ -5,6 +5,12 @@ import { assertNoSourceSymlinks, listProjectFiles, outputRoot, projectRoot, reso
5
5
  import { httpError } from "./http.js";
6
6
  import { MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
7
7
  const AUTOSAVE_COALESCE_MS = 2 * 60_000;
8
+ // Logical stored payload; SQLite pages, indexes and WAL are shared database overhead.
9
+ function historyMetadataBytes(row) {
10
+ return Object.values(row).reduce((sum, value) => sum + (typeof value === "string" ? Buffer.byteLength(value, "utf8") : 0), 0);
11
+ }
12
+ const DEFAULT_HISTORY_PAGE_SIZE = 100;
13
+ const MAX_HISTORY_PAGE_SIZE = 100;
8
14
  export class ProjectHistoryService {
9
15
  config;
10
16
  db;
@@ -12,7 +18,7 @@ export class ProjectHistoryService {
12
18
  this.config = config;
13
19
  this.db = db;
14
20
  }
15
- record(projectId, authorId, reason, changedRoots) {
21
+ record(projectId, authorId, reason, changedRoots, options = {}) {
16
22
  const project = this.project(projectId);
17
23
  const previous = this.latestRow(projectId);
18
24
  const previousManifest = this.baseline(projectId) ?? (previous ? parseManifest(previous.manifest_json) : null);
@@ -38,17 +44,19 @@ export class ProjectHistoryService {
38
44
  return null;
39
45
  }
40
46
  const createdAt = new Date().toISOString();
41
- if (reason === "autosave" && previous?.reason === "autosave" && !previous.label && previous.author_id === authorId
47
+ if (reason === "autosave" && previous?.reason === "autosave" && !previous.label
42
48
  && Date.parse(createdAt) - Date.parse(previous.created_at) < AUTOSAVE_COALESCE_MS) {
43
49
  const merged = [...new Set([...parseStringArray(previous.changed_paths_json), ...changedPaths])].sort();
44
50
  this.db.prepare(`UPDATE project_history_versions
45
- SET manifest_json = ?, changed_paths_json = ? WHERE id = ?`)
46
- .run(JSON.stringify(manifest), JSON.stringify(merged), previous.id);
51
+ SET manifest_json = ?, changed_paths_json = ?, author_id = ? WHERE id = ?`)
52
+ .run(JSON.stringify(manifest), JSON.stringify(merged), previous.author_id === authorId ? authorId : null, previous.id);
47
53
  this.saveBaseline(projectId, manifest);
48
- this.removeUnreferencedObjects(projectId, new Set(previousManifest
49
- ? Object.values(previousManifest.files).map((file) => file.digest)
50
- : []));
51
- this.pruneVersions(projectId);
54
+ if (!options.deferRetention) {
55
+ this.removeUnreferencedObjects(projectId, new Set(previousManifest
56
+ ? Object.values(previousManifest.files).map((file) => file.digest)
57
+ : []));
58
+ this.pruneVersions(projectId);
59
+ }
52
60
  return this.version(previous.id);
53
61
  }
54
62
  const id = randomUUID();
@@ -57,7 +65,8 @@ export class ProjectHistoryService {
57
65
  VALUES (?, ?, ?, ?, ?, ?, ?)`)
58
66
  .run(id, projectId, authorId, reason, JSON.stringify(manifest), JSON.stringify(changedPaths), createdAt);
59
67
  this.saveBaseline(projectId, manifest);
60
- this.pruneVersions(projectId);
68
+ if (!options.deferRetention)
69
+ this.pruneVersions(projectId);
61
70
  return this.version(id);
62
71
  }
63
72
  list(projectId, limit = 100) {
@@ -67,13 +76,50 @@ export class ProjectHistoryService {
67
76
  .all(projectId, Math.min(200, Math.max(1, limit)));
68
77
  return rows.map((row) => versionJson(row));
69
78
  }
79
+ /**
80
+ * List immutable recovery snapshots from newest to oldest. Cursor pagination
81
+ * is preferable to offsets here: concurrent saves/deletions cannot shift an
82
+ * already loaded page or make the next request repeat a snapshot.
83
+ */
84
+ listPage(projectId, limit = DEFAULT_HISTORY_PAGE_SIZE, cursorInput) {
85
+ const requestedLimit = Number.isFinite(limit) ? Math.floor(limit) : DEFAULT_HISTORY_PAGE_SIZE;
86
+ const limitValue = Math.min(MAX_HISTORY_PAGE_SIZE, Math.max(1, requestedLimit));
87
+ const cursor = cursorInput === undefined ? null : decodeHistoryCursor(cursorInput);
88
+ if (cursorInput !== undefined && !cursor)
89
+ throw httpError(400, "REQUEST_INVALID");
90
+ const values = [projectId];
91
+ let cursorFilter = "";
92
+ if (cursor) {
93
+ cursorFilter = "AND (history.created_at < ? OR (history.created_at = ? AND history.rowid < ?))";
94
+ values.push(cursor.createdAt, cursor.createdAt, cursor.rowId);
95
+ }
96
+ values.push(limitValue + 1);
97
+ const rows = this.db.prepare(`SELECT history.*, history.rowid AS history_rowid,
98
+ user.username AS author_username, user.display_name AS author_name
99
+ FROM project_history_versions history LEFT JOIN users user ON user.id = history.author_id
100
+ WHERE history.project_id = ? ${cursorFilter}
101
+ ORDER BY history.created_at DESC, history.rowid DESC LIMIT ?`)
102
+ .all(...values);
103
+ const pageRows = rows.slice(0, limitValue);
104
+ return {
105
+ versions: pageRows.map((row) => versionJson(row)),
106
+ nextCursor: rows.length > limitValue && pageRows.length > 0 ? encodeHistoryCursor(pageRows.at(-1)) : null
107
+ };
108
+ }
70
109
  stats(projectId) {
71
- const rows = this.db.prepare(`SELECT reason, label, manifest_json FROM project_history_versions
72
- WHERE project_id = ?`).all(projectId);
110
+ const rows = this.db.prepare(`SELECT * FROM project_history_versions
111
+ WHERE project_id = ? ORDER BY created_at DESC, rowid DESC`).all(projectId);
73
112
  const objects = new Map();
113
+ const protectedObjects = new Map();
114
+ let metadataBytes = 0;
115
+ let protectedMetadataBytes = 0;
74
116
  let ordinaryVersionCount = 0;
75
117
  let labeledVersionCount = 0;
76
118
  for (const row of rows) {
119
+ const protectedVersion = row === rows[0] || row.reason === "initial" || Boolean(row.label);
120
+ metadataBytes += historyMetadataBytes(row);
121
+ if (protectedVersion)
122
+ protectedMetadataBytes += historyMetadataBytes(row);
77
123
  if (row.label)
78
124
  labeledVersionCount += 1;
79
125
  else if (row.reason !== "initial")
@@ -81,13 +127,19 @@ export class ProjectHistoryService {
81
127
  for (const file of Object.values(parseManifest(row.manifest_json).files)) {
82
128
  if (!objects.has(file.digest))
83
129
  objects.set(file.digest, file.size);
130
+ if (protectedVersion)
131
+ protectedObjects.set(file.digest, file.size);
84
132
  }
85
133
  }
86
134
  const baseline = this.baseline(projectId);
87
135
  if (baseline) {
136
+ const baselineBytes = Buffer.byteLength(JSON.stringify(baseline), "utf8");
137
+ metadataBytes += baselineBytes;
138
+ protectedMetadataBytes += baselineBytes;
88
139
  for (const file of Object.values(baseline.files)) {
89
140
  if (!objects.has(file.digest))
90
141
  objects.set(file.digest, file.size);
142
+ protectedObjects.set(file.digest, file.size);
91
143
  }
92
144
  }
93
145
  const objectBytes = [...objects.values()].reduce((sum, size) => sum + size, 0);
@@ -97,13 +149,21 @@ export class ProjectHistoryService {
97
149
  labeledVersionCount,
98
150
  objectCount: objects.size,
99
151
  objectBytes,
152
+ metadataBytes,
153
+ totalBytes: objectBytes + metadataBytes,
154
+ protectedBytes: [...protectedObjects.values()].reduce((sum, size) => sum + size, protectedMetadataBytes),
100
155
  maxVersions: this.config.historyMaxVersions,
101
156
  maxStorageBytes: this.config.historyMaxStorageBytes,
102
- storageLimitExceeded: objectBytes > this.config.historyMaxStorageBytes
157
+ storageLimitExceeded: objectBytes + metadataBytes > this.config.historyMaxStorageBytes
103
158
  };
104
159
  }
105
160
  enforceRetention(projectId) {
106
161
  this.pruneVersions(projectId);
162
+ // Deferred autosave maintenance can leave an object from a coalesced
163
+ // version without any manifest referring to it. Scan the small
164
+ // content-addressed object store here (never in the save path) so a
165
+ // restart also recovers objects left behind before a timer could run.
166
+ this.removeUnreferencedObjects(projectId, this.storedObjectDigests(projectId));
107
167
  }
108
168
  deleteVersion(projectId, versionId) {
109
169
  const row = this.db.prepare("SELECT id, manifest_json FROM project_history_versions WHERE id = ? AND project_id = ?")
@@ -151,6 +211,14 @@ export class ProjectHistoryService {
151
211
  }
152
212
  this.assertRestoreTargetIsFile(projectId, filePath);
153
213
  }
214
+ previousVersion(projectId, versionId) {
215
+ const row = this.db.prepare(`SELECT older.id FROM project_history_versions older
216
+ JOIN project_history_versions selected ON selected.id = ? AND selected.project_id = older.project_id
217
+ WHERE older.project_id = ? AND (older.created_at < selected.created_at
218
+ OR (older.created_at = selected.created_at AND older.rowid < selected.rowid))
219
+ ORDER BY older.created_at DESC, older.rowid DESC LIMIT 1`).get(versionId, projectId);
220
+ return row ? this.version(row.id, projectId) : null;
221
+ }
154
222
  setLabel(projectId, versionId, label) {
155
223
  const result = this.db.prepare("UPDATE project_history_versions SET label = ? WHERE id = ? AND project_id = ?")
156
224
  .run(label, versionId, projectId);
@@ -272,8 +340,25 @@ export class ProjectHistoryService {
272
340
  throw new Error("Invalid history object digest");
273
341
  return path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects", digest.slice(0, 2), digest);
274
342
  }
343
+ storedObjectDigests(projectId) {
344
+ const objectsRoot = path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects");
345
+ if (!fs.existsSync(objectsRoot))
346
+ return new Set();
347
+ const digests = new Set();
348
+ for (const prefix of fs.readdirSync(objectsRoot, { withFileTypes: true })) {
349
+ if (!prefix.isDirectory() || !/^[a-f0-9]{2}$/.test(prefix.name))
350
+ continue;
351
+ const prefixPath = path.join(objectsRoot, prefix.name);
352
+ for (const entry of fs.readdirSync(prefixPath, { withFileTypes: true })) {
353
+ if (entry.isFile() && /^[a-f0-9]{64}$/.test(entry.name) && entry.name.startsWith(prefix.name)) {
354
+ digests.add(entry.name);
355
+ }
356
+ }
357
+ }
358
+ return digests;
359
+ }
275
360
  pruneVersions(projectId) {
276
- const rows = this.db.prepare(`SELECT id, reason, label, manifest_json, created_at
361
+ const rows = this.db.prepare(`SELECT *
277
362
  FROM project_history_versions WHERE project_id = ?
278
363
  ORDER BY created_at DESC, rowid DESC`).all(projectId);
279
364
  if (!rows.length)
@@ -299,19 +384,22 @@ export class ProjectHistoryService {
299
384
  sizeMap.set(file.digest, file.size);
300
385
  }
301
386
  }
302
- let currentObjectBytes = 0;
387
+ let retainedBytes = rows.reduce((sum, row) => sum + historyMetadataBytes(row), 0)
388
+ + (baseline ? Buffer.byteLength(JSON.stringify(baseline), "utf8") : 0);
303
389
  for (const [digest, count] of refCounts.entries()) {
304
390
  if (count > 0)
305
- currentObjectBytes += sizeMap.get(digest) ?? 0;
391
+ retainedBytes += sizeMap.get(digest) ?? 0;
306
392
  }
307
393
  const latestId = rows[0]?.id;
308
394
  // Ordinary versions eligible for version count capping (ordered newest to oldest)
309
395
  const ordinaryDesc = rows.filter((row) => row.label === null && row.reason !== "initial");
310
396
  const toDeleteIds = new Set();
397
+ const rowsById = new Map(rows.map((row) => [row.id, row]));
311
398
  const removeVersionRefs = (versionId) => {
312
399
  if (toDeleteIds.has(versionId))
313
400
  return;
314
401
  toDeleteIds.add(versionId);
402
+ retainedBytes -= historyMetadataBytes(rowsById.get(versionId));
315
403
  const manifest = manifests.get(versionId);
316
404
  if (!manifest)
317
405
  return;
@@ -320,7 +408,7 @@ export class ProjectHistoryService {
320
408
  if (count !== undefined) {
321
409
  if (count === 1) {
322
410
  refCounts.set(file.digest, 0);
323
- currentObjectBytes -= sizeMap.get(file.digest) ?? 0;
411
+ retainedBytes -= sizeMap.get(file.digest) ?? 0;
324
412
  }
325
413
  else if (count > 1) {
326
414
  refCounts.set(file.digest, count - 1);
@@ -328,19 +416,19 @@ export class ProjectHistoryService {
328
416
  }
329
417
  }
330
418
  };
331
- // 1. Cap by historyMaxVersions (prune excess ordinary versions from newest to oldest offset)
332
- if (ordinaryDesc.length > this.config.historyMaxVersions) {
419
+ // 1. Cap by historyMaxVersions (prune excess ordinary versions when configured; 0 = unlimited)
420
+ if (this.config.historyMaxVersions > 0 && ordinaryDesc.length > this.config.historyMaxVersions) {
333
421
  const obsolete = ordinaryDesc.slice(this.config.historyMaxVersions);
334
422
  for (const row of obsolete)
335
423
  removeVersionRefs(row.id);
336
424
  }
337
425
  // 2. Cap by historyMaxStorageBytes (prune remaining eligible versions from oldest to newest)
338
- if (currentObjectBytes > this.config.historyMaxStorageBytes) {
426
+ if (retainedBytes > this.config.historyMaxStorageBytes) {
339
427
  const remainingEligibleAsc = ordinaryDesc
340
428
  .filter((row) => row.id !== latestId && !toDeleteIds.has(row.id))
341
429
  .reverse();
342
430
  for (const row of remainingEligibleAsc) {
343
- if (currentObjectBytes <= this.config.historyMaxStorageBytes)
431
+ if (retainedBytes <= this.config.historyMaxStorageBytes)
344
432
  break;
345
433
  removeVersionRefs(row.id);
346
434
  }
@@ -456,6 +544,34 @@ function parseStringArray(value) {
456
544
  return [];
457
545
  }
458
546
  }
547
+ /**
548
+ * Keep cursors opaque so the public API is free to change its physical
549
+ * ordering later. `rowid` breaks ties for snapshots written in the same
550
+ * millisecond, which is common for file operations touching multiple paths.
551
+ */
552
+ function encodeHistoryCursor(row) {
553
+ return Buffer.from(JSON.stringify({ createdAt: row.created_at, rowId: row.history_rowid }), "utf8").toString("base64url");
554
+ }
555
+ function decodeHistoryCursor(value) {
556
+ // Cursors are generated by this service and normally only a few dozen
557
+ // characters long. Bound input before decoding to keep malformed query
558
+ // strings cheap to reject.
559
+ if (value.length === 0 || value.length > 256)
560
+ return null;
561
+ try {
562
+ const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
563
+ const { createdAt, rowId } = parsed;
564
+ if (typeof createdAt !== "string" || typeof rowId !== "number" || !Number.isSafeInteger(rowId) || rowId <= 0)
565
+ return null;
566
+ const timestamp = Date.parse(createdAt);
567
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === createdAt
568
+ ? { createdAt, rowId }
569
+ : null;
570
+ }
571
+ catch {
572
+ return null;
573
+ }
574
+ }
459
575
  function changedManifestPaths(previous, next) {
460
576
  const paths = new Set([...Object.keys(previous.files), ...Object.keys(next.files)]);
461
577
  return [...paths].filter((filePath) => previous.files[filePath]?.digest !== next.files[filePath]?.digest).sort();
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Coalesces non-critical history retention work. A history version is already
3
+ * durable before this scheduler runs; delaying maintenance can only leave
4
+ * extra old versions/objects temporarily, never lose a recoverable version.
5
+ */
6
+ export const HISTORY_RETENTION_DELAY_MS = 30_000;
7
+ export class HistoryRetentionScheduler {
8
+ history;
9
+ timers = new Map();
10
+ delayMs;
11
+ onError;
12
+ constructor(history, options = {}) {
13
+ this.history = history;
14
+ this.delayMs = options.delayMs ?? HISTORY_RETENTION_DELAY_MS;
15
+ this.onError = options.onError;
16
+ }
17
+ schedule(projectId) {
18
+ if (this.timers.has(projectId))
19
+ return;
20
+ const timer = setTimeout(() => {
21
+ this.timers.delete(projectId);
22
+ try {
23
+ this.history.enforceRetention(projectId);
24
+ }
25
+ catch (error) {
26
+ this.onError?.(error, projectId);
27
+ }
28
+ }, this.delayMs);
29
+ timer.unref?.();
30
+ this.timers.set(projectId, timer);
31
+ }
32
+ dispose() {
33
+ for (const timer of this.timers.values())
34
+ clearTimeout(timer);
35
+ this.timers.clear();
36
+ }
37
+ }
@@ -48,6 +48,7 @@ const enMessages = {
48
48
  HISTORY_FILE_PREVIEW_UNSUPPORTED: "This historical file cannot be compared as text.",
49
49
  HISTORY_TARGET_CONFLICT: "The historical version cannot be restored to the requested path.",
50
50
  HISTORY_VERSION_NOT_FOUND: "The history version does not exist.",
51
+ SELECTION_HISTORY_SOURCE_CHANGED: "The source changed while opening selection history. Wait for synchronization, then select the passage and open its history again.",
51
52
  INVALID_PATH: "The file path is invalid.",
52
53
  LAST_ADMIN: "At least one active administrator must remain.",
53
54
  LATEXMKRC_DISABLED: "Project latexmkrc files are disabled by the administrator.",
@@ -59,6 +60,7 @@ const enMessages = {
59
60
  MAIN_FILE_INVALID: "The main document must be an existing .tex file.",
60
61
  MAIN_FILE_NOT_FOUND: "The selected main document does not exist.",
61
62
  MEMBERS_MANAGE_FORBIDDEN: "Only the project owner can manage members.",
63
+ MENTION_NOT_FOUND: "The mention notification does not exist.",
62
64
  MOVE_INTO_SELF: "A folder cannot be moved into itself.",
63
65
  OWNER_MEMBER_FORBIDDEN: "The project owner cannot be added as a member.",
64
66
  PASSWORD_TOO_SHORT: "The password must contain at least {{minLength}} characters.",
@@ -174,6 +176,7 @@ const zhMessages = {
174
176
  HISTORY_FILE_PREVIEW_UNSUPPORTED: "该历史文件不能作为文本比较。",
175
177
  HISTORY_TARGET_CONFLICT: "无法将历史版本恢复到所请求的路径。",
176
178
  HISTORY_VERSION_NOT_FOUND: "历史版本不存在。",
179
+ SELECTION_HISTORY_SOURCE_CHANGED: "打开选区历史时源码已发生变化。请等待同步完成,重新选择文本后再打开历史。",
177
180
  INVALID_PATH: "文件路径无效。",
178
181
  LAST_ADMIN: "系统必须保留至少一名可用管理员。",
179
182
  LATEXMKRC_DISABLED: "管理员已禁用项目级 latexmkrc。",
@@ -185,6 +188,7 @@ const zhMessages = {
185
188
  MAIN_FILE_INVALID: "主文档必须是存在的 .tex 文件。",
186
189
  MAIN_FILE_NOT_FOUND: "所选主文档不存在。",
187
190
  MEMBERS_MANAGE_FORBIDDEN: "只有项目所有者可以管理成员。",
191
+ MENTION_NOT_FOUND: "@ 提醒不存在。",
188
192
  MOVE_INTO_SELF: "不能把文件夹移动到自身内部。",
189
193
  OWNER_MEMBER_FORBIDDEN: "项目所有者不能作为成员添加。",
190
194
  PASSWORD_TOO_SHORT: "密码至少需要 {{minLength}} 个字符。",
@@ -5,12 +5,16 @@ import { activeAdminCount, openDatabase } from "./db.js";
5
5
  import { buildApp } from "./app.js";
6
6
  import { assertEnvironment } from "./environment.js";
7
7
  import { acquireDataDirectoryLock } from "./instanceLock.js";
8
+ import { LogRotation } from "./logRotation.js";
8
9
  export async function startServer(configPath) {
9
10
  const config = loadConfig(configPath);
10
11
  const lock = acquireDataDirectoryLock(config);
11
12
  let db = null;
12
13
  let app = null;
14
+ const logs = process.env.TEXLITE_MANAGED_LOGS === "1"
15
+ ? new LogRotation(config.dataDir, (error) => console.error("TexLite log rotation failed:", error)) : null;
13
16
  try {
17
+ await logs?.start();
14
18
  const environment = await assertEnvironment(config);
15
19
  db = openDatabase(config);
16
20
  if (activeAdminCount(db) === 0) {
@@ -34,6 +38,7 @@ export async function startServer(configPath) {
34
38
  db?.close();
35
39
  }
36
40
  finally {
41
+ await logs?.stop();
37
42
  lock.release();
38
43
  }
39
44
  }
@@ -50,6 +55,7 @@ export async function startServer(configPath) {
50
55
  db?.close();
51
56
  }
52
57
  finally {
58
+ await logs?.stop();
53
59
  lock.release();
54
60
  }
55
61
  }
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ export const LOG_MAX_BYTES = 10 * 1024 * 1024;
4
+ export const LOG_CHECK_INTERVAL_MS = 60_000;
5
+ const LOG_BACKUPS = 3;
6
+ export function managedLogPaths(dataDir) {
7
+ const directory = path.resolve(dataDir, "logs");
8
+ return { stdout: path.join(directory, "stdout.log"), stderr: path.join(directory, "stderr.log") };
9
+ }
10
+ /** PM2 owns the open descriptors: copy/truncate keeps them valid across rotation. */
11
+ export class LogRotation {
12
+ dataDir;
13
+ onError;
14
+ maxBytes;
15
+ pending = null;
16
+ timer = null;
17
+ stopped = false;
18
+ constructor(dataDir, onError, maxBytes = LOG_MAX_BYTES) {
19
+ this.dataDir = dataDir;
20
+ this.onError = onError;
21
+ this.maxBytes = maxBytes;
22
+ }
23
+ async start() {
24
+ await this.check();
25
+ if (!this.stopped && !this.timer) {
26
+ this.timer = setInterval(() => { void this.check(); }, LOG_CHECK_INTERVAL_MS);
27
+ this.timer.unref();
28
+ }
29
+ }
30
+ check() {
31
+ if (this.stopped)
32
+ return Promise.resolve();
33
+ if (this.pending)
34
+ return this.pending;
35
+ this.pending = this.rotateLogs().finally(() => { this.pending = null; });
36
+ return this.pending;
37
+ }
38
+ async stop() {
39
+ this.stopped = true;
40
+ if (this.timer)
41
+ clearInterval(this.timer);
42
+ this.timer = null;
43
+ await this.pending;
44
+ }
45
+ async rotateLogs() {
46
+ for (const file of Object.values(managedLogPaths(this.dataDir))) {
47
+ try {
48
+ await this.rotate(file);
49
+ }
50
+ catch (error) {
51
+ this.onError(error);
52
+ }
53
+ }
54
+ }
55
+ async rotate(file) {
56
+ const stat = await fs.lstat(file).catch((error) => {
57
+ if (error.code === "ENOENT")
58
+ return null;
59
+ throw error;
60
+ });
61
+ if (!stat?.isFile() || stat.size < this.maxBytes)
62
+ return;
63
+ const temporary = `${file}.rotate.tmp`;
64
+ try {
65
+ // Do not truncate until a complete backup has been successfully published.
66
+ await fs.copyFile(file, temporary);
67
+ await fs.rm(`${file}.${LOG_BACKUPS}`, { force: true });
68
+ for (let index = LOG_BACKUPS - 1; index >= 1; index--) {
69
+ await fs.rename(`${file}.${index}`, `${file}.${index + 1}`).catch((error) => {
70
+ if (error.code !== "ENOENT")
71
+ throw error;
72
+ });
73
+ }
74
+ await fs.rename(temporary, `${file}.1`);
75
+ // Writes between the copy and truncate can be lost; these are diagnostic logs.
76
+ await fs.truncate(file, 0);
77
+ }
78
+ finally {
79
+ await fs.rm(temporary, { force: true });
80
+ }
81
+ }
82
+ }
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import http from "node:http";
4
4
  import path from "node:path";
5
+ import { managedLogPaths } from "./logRotation.js";
5
6
  import { defaultConfigPath, packageRootDirectory, packageServerEntry } from "./runtimePaths.js";
6
7
  let pm2Client = null;
7
8
  async function loadPm2() {
@@ -64,12 +65,15 @@ export async function managedProcess(configPath) {
64
65
  }
65
66
  }
66
67
  function startOptions(config) {
68
+ const logs = managedLogPaths(config.dataDir);
69
+ fs.mkdirSync(path.dirname(logs.stdout), { recursive: true, mode: 0o700 });
67
70
  const packageRoot = packageRootDirectory();
68
71
  const script = packageServerEntry();
69
72
  const environment = {
70
73
  ...Object.fromEntries(Object.entries(process.env).filter((entry) => typeof entry[1] === "string")),
71
74
  NODE_ENV: "production",
72
75
  TEXLITE_CONFIG: path.resolve(config.configPath),
76
+ TEXLITE_MANAGED_LOGS: "1",
73
77
  TEXLITE_CLIENT_DIR: path.resolve(config.clientDir)
74
78
  };
75
79
  return {
@@ -78,6 +82,9 @@ function startOptions(config) {
78
82
  cwd: packageRoot,
79
83
  exec_mode: "fork",
80
84
  instances: 1,
85
+ output: logs.stdout,
86
+ error: logs.stderr,
87
+ merge_logs: true,
81
88
  autorestart: true,
82
89
  watch: false,
83
90
  wait_ready: true,