texlite 0.10.0 → 0.12.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 (75) hide show
  1. package/DESIGN.md +44 -26
  2. package/OPERATIONS.md +12 -6
  3. package/README.md +9 -4
  4. package/README.zh-CN.md +7 -4
  5. package/dist/client/assets/AdminUsers-DMsBqC48.js +1 -0
  6. package/dist/client/assets/{CitationLibraryDialog-CU2b0xtm.js → CitationLibraryDialog-C_333dTc.js} +1 -1
  7. package/dist/client/assets/Dashboard-EOaNnS1A.js +2 -0
  8. package/dist/client/assets/GitDialog-DU-sVP-o.js +3 -0
  9. package/dist/client/assets/GitHubAccountDialog-IrB3M7nt.js +1 -0
  10. package/dist/client/assets/HistoryDialog-CDUbT7yG.js +7 -0
  11. package/dist/client/assets/LatexEditor-BYgW2otE.js +138 -0
  12. package/dist/client/assets/PdfPreview--HsIb8Fn.js +1 -0
  13. package/dist/client/assets/ProjectIconPickerDialog-CaF7Kkda.js +1 -0
  14. package/dist/client/assets/{ProjectNavigationDialogs-_JY0yVob.js → ProjectNavigationDialogs-3Ce7-tVp.js} +1 -1
  15. package/dist/client/assets/ProjectSettings-9hYSUKDo.js +1 -0
  16. package/dist/client/assets/ProjectWorkspace-D9zmy0KR.js +32 -0
  17. package/dist/client/assets/{SelectionHistoryDialog-D8CeqjLD.js → SelectionHistoryDialog-COpXO4kL.js} +2 -2
  18. package/dist/client/assets/SystemMetricsDialog-D9dthbAB.js +1 -0
  19. package/dist/client/assets/{TagManagementDialog-4R1Gs43j.js → TagManagementDialog-S8zDQAaB.js} +1 -1
  20. package/dist/client/assets/{WordCountDialog-C-MCscAu.js → WordCountDialog-CMsFa_pM.js} +1 -1
  21. package/dist/client/assets/check-Hv1kodh8.js +1 -0
  22. package/dist/client/assets/chktex-BFELQm91.js +3 -0
  23. package/dist/client/assets/{editor-B9HNwtsd.js → editor-taw1iptB.js} +1 -1
  24. package/dist/client/assets/external-link-C2GCFJxP.js +1 -0
  25. package/dist/client/assets/github-B_Eh2y6s.js +1 -0
  26. package/dist/client/assets/{hard-drive-Bsk-RETn.js → hard-drive-bzz70DQ5.js} +1 -1
  27. package/dist/client/assets/{index-Csp5J5aI.js → index-CXtmuEUm.js} +1 -1
  28. package/dist/client/assets/index-CxrDisif.css +1 -0
  29. package/dist/client/assets/index-Dik-2GCl.js +10 -0
  30. package/dist/client/assets/{minimize-2-92UqFWI9.js → minimize-2-CmPZ9Iwp.js} +1 -1
  31. package/dist/client/assets/{minus-bx4ZLZnb.js → minus-Dzy8rgfA.js} +1 -1
  32. package/dist/client/assets/{plus-DQg_bV50.js → plus-DlX82JCl.js} +1 -1
  33. package/dist/client/assets/{refresh-cw-BoMEkk5J.js → refresh-cw-BWndInnG.js} +1 -1
  34. package/dist/client/assets/{search-UZspmceH.js → search-DMqOy-Ex.js} +1 -1
  35. package/dist/client/assets/{sigma-C0IvFdKV.js → sigma-B0zOJKVj.js} +1 -1
  36. package/dist/client/assets/{spellCheck-DyKIHf5C.js → spellCheck-BjkrQxRU.js} +1 -1
  37. package/dist/client/assets/{tags-BTUtvaAc.js → tags-XvdXg-yc.js} +1 -1
  38. package/dist/client/assets/{type-XvvfEJRs.js → type-DsGlKUKR.js} +1 -1
  39. package/dist/client/assets/{user-plus-Chr9T97G.js → user-plus-Bcq_EsqH.js} +1 -1
  40. package/dist/client/assets/{x-DlFXD9Vl.js → x-BJOMjm8Z.js} +1 -1
  41. package/dist/client/index.html +3 -3
  42. package/dist/server/app.js +8 -0
  43. package/dist/server/chktex.js +377 -0
  44. package/dist/server/collaboration.js +6 -0
  45. package/dist/server/db.js +35 -1
  46. package/dist/server/environment.js +1 -0
  47. package/dist/server/git.js +64 -29
  48. package/dist/server/harper.js +14 -0
  49. package/dist/server/history.js +18 -9
  50. package/dist/server/i18n.js +14 -4
  51. package/dist/server/projectImportUploads.js +133 -0
  52. package/dist/server/routes/projectFiles.js +3 -0
  53. package/dist/server/routes/projectGit.js +10 -11
  54. package/dist/server/routes/projectHistory.js +32 -5
  55. package/dist/server/routes/projectMembers.js +0 -5
  56. package/dist/server/routes/projectShared.js +1 -0
  57. package/dist/server/routes/projects.js +97 -15
  58. package/dist/server/routes/users.js +0 -2
  59. package/dist/shared/historyFiles.js +6 -0
  60. package/dist/shared/writingChecks.js +4 -0
  61. package/package.json +1 -1
  62. package/dist/client/assets/AdminUsers-C7oU_1Y_.js +0 -1
  63. package/dist/client/assets/Dashboard-BB4RO4x2.js +0 -2
  64. package/dist/client/assets/GitDialog-C2Np1i8v.js +0 -3
  65. package/dist/client/assets/HistoryDialog-CdoYeLyX.js +0 -7
  66. package/dist/client/assets/LatexEditor-Dmy6_gqt.js +0 -138
  67. package/dist/client/assets/PdfPreview-fDxKh6hP.js +0 -1
  68. package/dist/client/assets/ProjectIconPickerDialog-CsjMKq7h.js +0 -1
  69. package/dist/client/assets/ProjectSettings-DXGA-lmB.js +0 -1
  70. package/dist/client/assets/ProjectWorkspace-mCYjyc6e.js +0 -32
  71. package/dist/client/assets/SystemMetricsDialog-BZx7ZMQt.js +0 -1
  72. package/dist/client/assets/check-Jf8WBxDX.js +0 -1
  73. package/dist/client/assets/index-B6rP7QsM.js +0 -12
  74. package/dist/client/assets/index-DGZNAyxW.css +0 -1
  75. package/dist/client/assets/key-round-DqptHHKC.js +0 -1
@@ -22,6 +22,7 @@ export class ProjectGitService {
22
22
  await this.ensureGitAvailable();
23
23
  assertNoSourceSymlinks(this.config, project.id);
24
24
  const settings = this.settings(project.id);
25
+ const account = this.userSettings(project.owner_id);
25
26
  const gitDirectory = path.join(sourceRoot(this.config, project.id), ".git");
26
27
  let gitStat = null;
27
28
  try {
@@ -37,8 +38,8 @@ export class ProjectGitService {
37
38
  if (!initialized)
38
39
  return {
39
40
  initialized: false,
40
- tokenConfigured: Boolean(settings?.token_ciphertext),
41
- githubLogin: settings?.github_login ?? null,
41
+ tokenConfigured: Boolean(account?.token_ciphertext),
42
+ githubLogin: account?.github_login ?? null,
42
43
  remoteUrl: settings?.remote_url ?? null,
43
44
  repositoryName: settings?.repository_name ?? null,
44
45
  repositoryHtmlUrl: settings?.repository_html_url ?? null,
@@ -56,11 +57,17 @@ export class ProjectGitService {
56
57
  const branch = branchResult.code === 0 ? branchResult.stdout.trim() : null;
57
58
  const remoteResult = await this.git(root, ["remote", "get-url", "origin"], [2]);
58
59
  const latestResult = await this.git(root, ["log", "-1", "--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s"], [128]);
59
- const aheadResult = await this.git(root, ["rev-list", "--count", "@{upstream}..HEAD"], [128]);
60
+ const upstreamResult = branch
61
+ ? await this.git(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], [128])
62
+ : null;
63
+ // A newly initialized backup repository has no upstream until its first
64
+ // push. In that case, every local commit is pending for the remote.
65
+ const aheadRevision = upstreamResult?.code === 0 ? "@{upstream}..HEAD" : "HEAD";
66
+ const aheadResult = branch ? await this.git(root, ["rev-list", "--count", aheadRevision], [128]) : null;
60
67
  return {
61
68
  initialized: true,
62
- tokenConfigured: Boolean(settings?.token_ciphertext),
63
- githubLogin: settings?.github_login ?? null,
69
+ tokenConfigured: Boolean(account?.token_ciphertext),
70
+ githubLogin: account?.github_login ?? null,
64
71
  remoteUrl: settings?.remote_url ?? (remoteResult.code === 0 ? remoteResult.stdout.trim() : null),
65
72
  repositoryName: settings?.repository_name ?? null,
66
73
  repositoryHtmlUrl: settings?.repository_html_url ?? null,
@@ -69,41 +76,43 @@ export class ProjectGitService {
69
76
  dirty: Boolean(porcelain),
70
77
  restorable: porcelain.split("\n").some((line) => Boolean(line) && !line.startsWith("??")),
71
78
  changedFiles: porcelain ? porcelain.split("\n").length : 0,
72
- ahead: aheadResult.code === 0 ? Number.parseInt(aheadResult.stdout.trim(), 10) || 0 : 0,
79
+ ahead: aheadResult?.code === 0 ? Number.parseInt(aheadResult.stdout.trim(), 10) || 0 : 0,
73
80
  latestCommit: latestResult.code === 0 ? parseCommit(latestResult.stdout.trim()) : null
74
81
  };
75
82
  }
76
- async configureToken(project, tokenInput) {
83
+ githubAccountStatus(userId) {
84
+ const settings = this.userSettings(userId);
85
+ return { tokenConfigured: Boolean(settings?.token_ciphertext), githubLogin: settings?.github_login ?? null };
86
+ }
87
+ async configureUserToken(userId, tokenInput) {
77
88
  const token = tokenInput.trim();
78
89
  if (token.length < 20 || token.length > 500)
79
90
  throw httpError(400, "GIT_TOKEN_INVALID");
80
- await this.ensureGitAvailable();
81
91
  const account = await this.githubRequest(token, "/user", { method: "GET" });
82
92
  if (typeof account.login !== "string" || !account.login)
83
93
  throw httpError(502, "GIT_ACCOUNT_INVALID");
84
- await this.ensureRepository(project);
85
94
  const timestamp = new Date().toISOString();
86
- this.db.prepare(`INSERT INTO project_git_settings
87
- (project_id, token_ciphertext, github_login, default_branch, created_at, updated_at)
88
- VALUES (?, ?, ?, 'main', ?, ?)
89
- ON CONFLICT(project_id) DO UPDATE SET token_ciphertext = excluded.token_ciphertext,
95
+ this.db.prepare(`INSERT INTO user_github_settings
96
+ (user_id, token_ciphertext, github_login, created_at, updated_at)
97
+ VALUES (?, ?, ?, ?, ?)
98
+ ON CONFLICT(user_id) DO UPDATE SET token_ciphertext = excluded.token_ciphertext,
90
99
  github_login = excluded.github_login, updated_at = excluded.updated_at`)
91
- .run(project.id, encryptToken(this.config, token), account.login, timestamp, timestamp);
92
- return this.status(project);
100
+ .run(userId, encryptToken(this.config, token), account.login, timestamp, timestamp);
101
+ return this.githubAccountStatus(userId);
93
102
  }
94
- async removeToken(project) {
95
- this.db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
96
- .run(new Date().toISOString(), project.id);
97
- return this.status(project);
103
+ removeUserToken(userId) {
104
+ this.db.prepare("DELETE FROM user_github_settings WHERE user_id = ?").run(userId);
105
+ return this.githubAccountStatus(userId);
98
106
  }
99
107
  async createGitHubRepository(project, name, isPrivate) {
100
108
  if (!/^[A-Za-z0-9._-]{1,100}$/.test(name))
101
109
  throw httpError(400, "GIT_REPOSITORY_NAME_INVALID");
102
110
  await this.ensureGitAvailable();
103
- const settings = this.requireTokenSettings(project.id);
104
- if (settings.remote_url)
111
+ const account = this.requireUserTokenSettings(project.owner_id);
112
+ const settings = this.settings(project.id);
113
+ if (settings?.remote_url)
105
114
  throw httpError(409, "GIT_REMOTE_ALREADY_CONFIGURED");
106
- const token = decryptToken(this.config, settings.token_ciphertext);
115
+ const token = decryptToken(this.config, account.token_ciphertext);
107
116
  const repository = await this.githubRequest(token, "/user/repos", {
108
117
  method: "POST",
109
118
  body: JSON.stringify({ name, private: isPrivate, auto_init: false, description: `Backup of ${project.name}` })
@@ -112,6 +121,7 @@ export class ProjectGitService {
112
121
  throw httpError(502, "GIT_REPOSITORY_RESPONSE_INVALID");
113
122
  }
114
123
  await this.ensureRepository(project);
124
+ this.ensureProjectSettings(project.id);
115
125
  const root = sourceRoot(this.config, project.id);
116
126
  const currentRemote = await this.git(root, ["remote", "get-url", "origin"], [2]);
117
127
  if (currentRemote.code === 0)
@@ -149,8 +159,8 @@ export class ProjectGitService {
149
159
  return parseCommit(result.stdout.trim());
150
160
  }
151
161
  async push(project) {
152
- const settings = this.requireTokenSettings(project.id);
153
- if (!settings.remote_url)
162
+ const account = this.requireUserTokenSettings(project.owner_id);
163
+ if (!this.settings(project.id)?.remote_url)
154
164
  throw httpError(409, "GIT_REMOTE_NOT_CONFIGURED");
155
165
  const root = this.requireRepository(project.id);
156
166
  assertNoSourceSymlinks(this.config, project.id);
@@ -163,7 +173,7 @@ export class ProjectGitService {
163
173
  const branch = branchResult.stdout.trim();
164
174
  if (!/^[A-Za-z0-9._/-]+$/.test(branch))
165
175
  throw httpError(400, "GIT_BRANCH_INVALID");
166
- const token = decryptToken(this.config, settings.token_ciphertext);
176
+ const token = decryptToken(this.config, account.token_ciphertext);
167
177
  await this.git(root, ["push", "--set-upstream", "origin", `HEAD:refs/heads/${branch}`], [], this.authEnvironment(token));
168
178
  return this.status(project);
169
179
  }
@@ -269,12 +279,20 @@ export class ProjectGitService {
269
279
  settings(projectId) {
270
280
  return this.db.prepare("SELECT * FROM project_git_settings WHERE project_id = ?").get(projectId);
271
281
  }
272
- requireTokenSettings(projectId) {
273
- const settings = this.settings(projectId);
282
+ userSettings(userId) {
283
+ return this.db.prepare("SELECT * FROM user_github_settings WHERE user_id = ?").get(userId);
284
+ }
285
+ requireUserTokenSettings(userId) {
286
+ const settings = this.userSettings(userId);
274
287
  if (!settings?.token_ciphertext)
275
288
  throw httpError(409, "GIT_TOKEN_NOT_CONFIGURED");
276
289
  return settings;
277
290
  }
291
+ ensureProjectSettings(projectId) {
292
+ const timestamp = new Date().toISOString();
293
+ this.db.prepare(`INSERT INTO project_git_settings (project_id, default_branch, created_at, updated_at)
294
+ VALUES (?, 'main', ?, ?) ON CONFLICT(project_id) DO NOTHING`).run(projectId, timestamp, timestamp);
295
+ }
278
296
  async verifyCommit(root, revision) {
279
297
  const result = await this.git(root, ["rev-parse", "--verify", `${revision}^{commit}`], [128]);
280
298
  if (result.code !== 0)
@@ -303,7 +321,10 @@ export class ProjectGitService {
303
321
  }
304
322
  });
305
323
  if (!response.ok) {
306
- throw httpError(response.status === 401 || response.status === 403 ? 400 : 502, response.status === 401 || response.status === 403 ? "GIT_TOKEN_REJECTED" : "GIT_OPERATION_FAILED");
324
+ const authError = classifyGitHubAuthFailure(response.status);
325
+ if (authError)
326
+ throw httpError(400, authError);
327
+ throw httpError(502, "GIT_OPERATION_FAILED");
307
328
  }
308
329
  return await response.json();
309
330
  }
@@ -376,13 +397,27 @@ export class ProjectGitService {
376
397
  if (size > MAX_COMMAND_OUTPUT)
377
398
  return reject(httpError(413, "GIT_OUTPUT_TOO_LARGE"));
378
399
  if (result.code !== 0 && !allowedCodes.includes(result.code)) {
379
- return reject(httpError(400, "GIT_COMMAND_FAILED"));
400
+ const pushAuthError = args[0] === "push" ? classifyGitPushFailure(result.stderr) : null;
401
+ return reject(httpError(400, pushAuthError ?? "GIT_COMMAND_FAILED"));
380
402
  }
381
403
  resolve(result);
382
404
  });
383
405
  });
384
406
  }
385
407
  }
408
+ function classifyGitHubAuthFailure(status) {
409
+ // GitHub's 401/403 responses do not reliably distinguish invalid or expired
410
+ // credentials from insufficient repository access (including SSO/policy
411
+ // restrictions), so give users one actionable message for these cases.
412
+ return status === 401 || status === 403 ? "GIT_TOKEN_REJECTED" : null;
413
+ }
414
+ export function classifyGitPushFailure(stderr) {
415
+ if (/saml|single[- ]sign[- ]on|\bsso\b/i.test(stderr) ||
416
+ /invalid username or token|bad credentials|authentication failed|could not read username/i.test(stderr) ||
417
+ /write access.{0,80}(?:not granted|denied)|permission to .{1,200} denied|repository not found|returned error:\s*403/i.test(stderr))
418
+ return "GIT_TOKEN_REJECTED";
419
+ return null;
420
+ }
386
421
  function parseCommit(line) {
387
422
  const [sha = "", shortSha = "", authorName = "", authorEmail = "", authoredAt = "", ...message] = line.split("\x1f");
388
423
  return { sha, shortSha, authorName, authorEmail, authoredAt, message: message.join("\x1f") };
@@ -115,6 +115,20 @@ export class HarperService {
115
115
  async preload() {
116
116
  await this.ensureAvailable();
117
117
  }
118
+ /** Availability probe only; does not run a writing check or join its queue. */
119
+ async isAvailable() {
120
+ if (this.disposed)
121
+ return false;
122
+ try {
123
+ await this.ensureAvailable();
124
+ return !this.disposed;
125
+ }
126
+ catch (error) {
127
+ if (error instanceof HarperUnavailableError)
128
+ return false;
129
+ throw error;
130
+ }
131
+ }
118
132
  /**
119
133
  * Schedule one host-side check. A lane represents one browser page's
120
134
  * current file, so an edit can replace only its own stale waiting request
@@ -4,6 +4,8 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import { assertNoSourceSymlinks, listProjectFiles, outputRoot, projectRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
5
5
  import { httpError } from "./http.js";
6
6
  import { MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
7
+ import { isUtf8 } from "node:buffer";
8
+ import { isHistoryTextFile } from "../shared/historyFiles.js";
7
9
  const AUTOSAVE_COALESCE_MS = 2 * 60_000;
8
10
  // Logical stored payload; SQLite pages, indexes and WAL are shared database overhead.
9
11
  function historyMetadataBytes(row) {
@@ -39,7 +41,9 @@ export class ProjectHistoryService {
39
41
  }
40
42
  }
41
43
  const changedPaths = previousManifest ? changedManifestPaths(previousManifest, manifest) : Object.keys(manifest.files).sort();
42
- if (previousManifest && changedPaths.length === 0 && JSON.stringify(previousManifest.settings) === JSON.stringify(manifest.settings)) {
44
+ // Settings alone do not create a file-history entry. Keep the baseline
45
+ // current so the next real file change captures the effective settings.
46
+ if (changedPaths.length === 0) {
43
47
  this.saveBaseline(projectId, manifest);
44
48
  return null;
45
49
  }
@@ -72,7 +76,8 @@ export class ProjectHistoryService {
72
76
  list(projectId, limit = 100) {
73
77
  const rows = this.db.prepare(`SELECT history.*, user.username AS author_username, user.display_name AS author_name
74
78
  FROM project_history_versions history LEFT JOIN users user ON user.id = history.author_id
75
- WHERE history.project_id = ? ORDER BY history.created_at DESC, history.rowid DESC LIMIT ?`)
79
+ WHERE history.project_id = ? AND json_array_length(history.changed_paths_json) > 0
80
+ ORDER BY history.created_at DESC, history.rowid DESC LIMIT ?`)
76
81
  .all(projectId, Math.min(200, Math.max(1, limit)));
77
82
  return rows.map((row) => versionJson(row));
78
83
  }
@@ -97,7 +102,7 @@ export class ProjectHistoryService {
97
102
  const rows = this.db.prepare(`SELECT history.*, history.rowid AS history_rowid,
98
103
  user.username AS author_username, user.display_name AS author_name
99
104
  FROM project_history_versions history LEFT JOIN users user ON user.id = history.author_id
100
- WHERE history.project_id = ? ${cursorFilter}
105
+ WHERE history.project_id = ? AND json_array_length(history.changed_paths_json) > 0 ${cursorFilter}
101
106
  ORDER BY history.created_at DESC, history.rowid DESC LIMIT ?`)
102
107
  .all(...values);
103
108
  const pageRows = rows.slice(0, limitValue);
@@ -231,7 +236,8 @@ export class ProjectHistoryService {
231
236
  previousVersion(projectId, versionId) {
232
237
  const row = this.db.prepare(`SELECT older.id FROM project_history_versions older
233
238
  JOIN project_history_versions selected ON selected.id = ? AND selected.project_id = older.project_id
234
- WHERE older.project_id = ? AND (older.created_at < selected.created_at
239
+ WHERE older.project_id = ? AND json_array_length(older.changed_paths_json) > 0
240
+ AND (older.created_at < selected.created_at
235
241
  OR (older.created_at = selected.created_at AND older.rowid < selected.rowid))
236
242
  ORDER BY older.created_at DESC, older.rowid DESC LIMIT 1`).get(versionId, projectId);
237
243
  return row ? this.version(row.id, projectId) : null;
@@ -243,6 +249,8 @@ export class ProjectHistoryService {
243
249
  }
244
250
  readTextFile(projectId, versionId, filePathInput) {
245
251
  const filePath = safeRelativePath(filePathInput);
252
+ if (!isHistoryTextFile(filePath))
253
+ return null;
246
254
  const entry = this.manifest(projectId, versionId)?.files[filePath];
247
255
  if (!entry || entry.size > MAX_TEXT_PREVIEW_BYTES)
248
256
  return null;
@@ -272,8 +280,8 @@ export class ProjectHistoryService {
272
280
  return { restoredPaths: [filePath], manifest };
273
281
  }
274
282
  this.restoreProjectTree(projectId, manifest);
275
- this.db.prepare("UPDATE projects SET main_file = ?, engine = ?, latexmkrc = ? WHERE id = ?")
276
- .run(manifest.settings.mainFile, manifest.settings.engine, manifest.settings.latexmkrc, projectId);
283
+ this.db.prepare("UPDATE projects SET main_file = ?, engine = ?, latexmkrc = ?, chktex_enabled = ? WHERE id = ?")
284
+ .run(manifest.settings.mainFile, manifest.settings.engine, manifest.settings.latexmkrc, manifest.settings.chktexEnabled ? 1 : 0, projectId);
277
285
  return { restoredPaths: Object.keys(manifest.files).sort(), manifest };
278
286
  }
279
287
  assertRestoreTargetIsFile(projectId, filePath) {
@@ -364,7 +372,8 @@ export class ProjectHistoryService {
364
372
  */
365
373
  readStoredObject(projectId, digest, filePath) {
366
374
  try {
367
- return fs.readFileSync(this.objectPath(projectId, digest), "utf8");
375
+ const bytes = fs.readFileSync(this.objectPath(projectId, digest));
376
+ return !bytes.includes(0) && isUtf8(bytes) ? bytes.toString("utf8") : null;
368
377
  }
369
378
  catch (error) {
370
379
  this.rethrowHistoryObjectError(error, filePath);
@@ -570,7 +579,7 @@ export class ProjectHistoryService {
570
579
  }
571
580
  }
572
581
  function settings(project) {
573
- return { mainFile: project.main_file, engine: project.engine, latexmkrc: project.latexmkrc };
582
+ return { mainFile: project.main_file, engine: project.engine, latexmkrc: project.latexmkrc, chktexEnabled: Boolean(project.chktex_enabled) };
574
583
  }
575
584
  function cloneManifest(manifest) {
576
585
  return { version: 1, files: { ...manifest.files }, settings: { ...manifest.settings } };
@@ -579,7 +588,7 @@ function parseManifest(value) {
579
588
  const parsed = JSON.parse(value);
580
589
  if (parsed.version !== 1 || !parsed.files || !parsed.settings)
581
590
  throw new Error("History manifest is invalid");
582
- return parsed;
591
+ return { ...parsed, settings: { ...parsed.settings, chktexEnabled: parsed.settings.chktexEnabled === true } };
583
592
  }
584
593
  function parseStringArray(value) {
585
594
  try {
@@ -11,6 +11,10 @@ const enMessages = {
11
11
  CITATION_NOT_FOUND: "The citation does not exist.",
12
12
  CITATION_TAG_NOT_FOUND: "The citation tag does not exist.",
13
13
  CITATION_TOO_LARGE: "The BibTeX entry is too large.",
14
+ CHK_TEX_SOURCE_INVALID: "The source to check is invalid.",
15
+ CHK_TEX_SOURCE_TOO_LARGE: "The source to check is too large.",
16
+ CHK_TEX_SUPERSEDED: "A newer ChkTeX check replaced this request.",
17
+ CHK_TEX_UNAVAILABLE: "ChkTeX is not installed or cannot run on this server. Install the TeX Live chktex package and try again.",
14
18
  COMMENT_DELETE_FORBIDDEN: "You can only delete your own comments.",
15
19
  COMMENT_EDIT_FORBIDDEN: "You can only edit your own comments.",
16
20
  COMMENT_FILE_NOT_FOUND: "The file for this comment does not exist.",
@@ -42,7 +46,7 @@ const enMessages = {
42
46
  GIT_REVISION_INVALID: "Choose a Git revision to check out.",
43
47
  GIT_TOKEN_INVALID: "Enter a GitHub token.",
44
48
  GIT_TOKEN_NOT_CONFIGURED: "Configure a GitHub token first.",
45
- GIT_TOKEN_REJECTED: "GitHub rejected this token. Check its permissions and try again.",
49
+ GIT_TOKEN_REJECTED: "GitHub rejected this token. It may be invalid, expired, or lack the required permissions.",
46
50
  GIT_UNAVAILABLE: "Git is not available on this server.",
47
51
  HARPER_UNAVAILABLE: "Harper spelling and grammar checking is temporarily unavailable.",
48
52
  HISTORY_FILE_NOT_FOUND: "The historical file does not exist.",
@@ -77,6 +81,7 @@ const enMessages = {
77
81
  PROJECT_ICON_INVALID: "Enter a valid installed Lucide icon short name.",
78
82
  PROJECT_NOT_FOUND: "The project does not exist or is no longer accessible.",
79
83
  PROJECT_OWNER_ONLY: "Only the project owner can perform this action.",
84
+ PROJECT_SETTINGS_CONFLICT: "These project settings changed in another session. Refresh the project page before saving again.",
80
85
  PROJECT_TRANSFER_FORBIDDEN: "Only the current project owner can transfer ownership.",
81
86
  PROJECT_TRANSFER_SELF: "You already own this project.",
82
87
  PROJECT_TRANSFER_TARGET_INVALID: "Choose a new project owner.",
@@ -106,7 +111,7 @@ const enMessages = {
106
111
  WORD_COUNT_FAILED: "TeXcount could not count this source.",
107
112
  WORD_COUNT_SOURCE_INVALID: "Select LaTeX source text to count.",
108
113
  WORD_COUNT_SOURCE_TOO_LARGE: "The selected source is too large to count.",
109
- WORD_COUNT_UNAVAILABLE: "TeXcount is not available on this server. Install it from TeX Live and try again.",
114
+ WORD_COUNT_UNAVAILABLE: "The TeX Live texcount package is not installed on this server. Install it and try again.",
110
115
  ZIP_DUPLICATE_ENTRY: "The ZIP archive contains a duplicate filename: {{path}}.",
111
116
  ZIP_ENTRY_READ_FAILED: "A ZIP archive entry could not be read.",
112
117
  ZIP_ENTRY_TOO_LARGE: "The ZIP archive contains a file that exceeds the permitted size.",
@@ -143,6 +148,10 @@ const zhMessages = {
143
148
  CITATION_NOT_FOUND: "引用条目不存在。",
144
149
  CITATION_TAG_NOT_FOUND: "引用标签不存在。",
145
150
  CITATION_TOO_LARGE: "BibTeX 条目过大。",
151
+ CHK_TEX_SOURCE_INVALID: "待检查源码格式不正确。",
152
+ CHK_TEX_SOURCE_TOO_LARGE: "待检查源码过大。",
153
+ CHK_TEX_SUPERSEDED: "更新的 ChkTeX 检查已替代此请求。",
154
+ CHK_TEX_UNAVAILABLE: "服务器未安装 ChkTeX,或 ChkTeX 当前无法运行。请在服务器安装 TeX Live 的 chktex 包后重试。",
146
155
  COMMENT_DELETE_FORBIDDEN: "只能删除自己的批注。",
147
156
  COMMENT_EDIT_FORBIDDEN: "只能编辑自己的批注。",
148
157
  COMMENT_FILE_NOT_FOUND: "批注对应的文件不存在。",
@@ -174,7 +183,7 @@ const zhMessages = {
174
183
  GIT_REVISION_INVALID: "请选择要 checkout 的 Git 版本。",
175
184
  GIT_TOKEN_INVALID: "请输入 GitHub token。",
176
185
  GIT_TOKEN_NOT_CONFIGURED: "请先配置 GitHub token。",
177
- GIT_TOKEN_REJECTED: "GitHub 拒绝了该 token,请检查权限后重试。",
186
+ GIT_TOKEN_REJECTED: "GitHub 拒绝了此 token:它可能无效、已过期或权限不足。",
178
187
  GIT_UNAVAILABLE: "服务器未安装 Git。",
179
188
  HARPER_UNAVAILABLE: "Harper 拼写与语法检查暂不可用。",
180
189
  HISTORY_FILE_NOT_FOUND: "历史文件不存在。",
@@ -209,6 +218,7 @@ const zhMessages = {
209
218
  PROJECT_ICON_INVALID: "请输入已安装的有效 Lucide 图标短名称。",
210
219
  PROJECT_NOT_FOUND: "项目不存在或你已无法访问。",
211
220
  PROJECT_OWNER_ONLY: "只有项目所有者可以执行此操作。",
221
+ PROJECT_SETTINGS_CONFLICT: "项目设置已在其他会话中更改。请刷新项目页面后再保存。",
212
222
  PROJECT_TRANSFER_FORBIDDEN: "只有当前项目所有者可以转让项目。",
213
223
  PROJECT_TRANSFER_SELF: "你已经是该项目的所有者。",
214
224
  PROJECT_TRANSFER_TARGET_INVALID: "请选择新的项目所有者。",
@@ -238,7 +248,7 @@ const zhMessages = {
238
248
  WORD_COUNT_FAILED: "TeXcount 无法统计这段源码。",
239
249
  WORD_COUNT_SOURCE_INVALID: "请选择 LaTeX 源码后再统计。",
240
250
  WORD_COUNT_SOURCE_TOO_LARGE: "所选源码过大,无法统计。",
241
- WORD_COUNT_UNAVAILABLE: "服务器未安装 TeXcount,请安装 TeX Live 后重试。",
251
+ WORD_COUNT_UNAVAILABLE: "服务器未安装 TeX Live 的 texcount 包,请安装后重试。",
242
252
  ZIP_DUPLICATE_ENTRY: "ZIP 压缩包包含重复文件名:{{path}}。",
243
253
  ZIP_ENTRY_READ_FAILED: "无法读取 ZIP 压缩包中的文件。",
244
254
  ZIP_ENTRY_TOO_LARGE: "ZIP 压缩包中存在超过限制的文件。",
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ const CHUNK_SIZE = 4 * 1024 * 1024;
5
+ const SESSION_TTL_MS = 30 * 60 * 1000;
6
+ const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
7
+ const MAX_ACTIVE_UPLOADS = 8;
8
+ const MAX_ACTIVE_UPLOADS_PER_USER = 2;
9
+ const MAX_IN_FLIGHT_CHUNKS_PER_UPLOAD = 4;
10
+ export class ProjectImportUploadError extends Error {
11
+ code;
12
+ constructor(code) {
13
+ super(code);
14
+ this.code = code;
15
+ this.name = "ProjectImportUploadError";
16
+ }
17
+ }
18
+ /** Bounded, per-user temporary storage for parallel ZIP upload chunks. */
19
+ export class ProjectImportUploads {
20
+ rootDirectory;
21
+ sessions = new Map();
22
+ cleanupTimer;
23
+ constructor(config) {
24
+ this.rootDirectory = path.join(config.dataDir, "tmp", "project-imports");
25
+ this.cleanupTimer = setInterval(() => { void this.cleanupExpired().catch(() => undefined); }, CLEANUP_INTERVAL_MS);
26
+ this.cleanupTimer.unref();
27
+ }
28
+ async initialize() {
29
+ await fs.mkdir(this.rootDirectory, { recursive: true, mode: 0o700 });
30
+ // Any remaining entries are abandoned uploads from a previous server run.
31
+ const entries = await fs.readdir(this.rootDirectory, { withFileTypes: true });
32
+ await Promise.all(entries.map((entry) => fs.rm(path.join(this.rootDirectory, entry.name), { recursive: true, force: true })));
33
+ }
34
+ async start(userId, filename, size, maxBytes) {
35
+ await this.cleanupExpired();
36
+ if (!Number.isSafeInteger(size) || size <= 0 || size > maxBytes)
37
+ throw new ProjectImportUploadError("ZIP_UPLOAD_SIZE_INVALID");
38
+ const active = [...this.sessions.values()];
39
+ const reservedBytes = active.reduce((sum, session) => sum + session.size, 0);
40
+ if (active.length >= MAX_ACTIVE_UPLOADS || active.filter((session) => session.userId === userId).length >= MAX_ACTIVE_UPLOADS_PER_USER || reservedBytes + size > maxBytes * 2) {
41
+ throw new ProjectImportUploadError("ZIP_UPLOAD_LIMIT");
42
+ }
43
+ const id = randomUUID();
44
+ const directory = path.join(this.rootDirectory, id);
45
+ await fs.mkdir(directory, { recursive: false, mode: 0o700 });
46
+ const chunkCount = Math.ceil(size / CHUNK_SIZE);
47
+ const timestamp = Date.now();
48
+ this.sessions.set(id, {
49
+ id, userId, filename, size, chunkCount, directory, received: new Set(), inFlight: new Set(),
50
+ updatedAt: timestamp, completing: false
51
+ });
52
+ return { uploadId: id, chunkSize: CHUNK_SIZE, chunkCount };
53
+ }
54
+ async writeChunk(userId, uploadId, index, bytes) {
55
+ const session = this.getSession(userId, uploadId);
56
+ if (!Number.isSafeInteger(index) || index < 0 || index >= session.chunkCount || session.completing) {
57
+ throw new ProjectImportUploadError("ZIP_UPLOAD_CHUNK_INVALID");
58
+ }
59
+ if (session.inFlight.size >= MAX_IN_FLIGHT_CHUNKS_PER_UPLOAD)
60
+ throw new ProjectImportUploadError("ZIP_UPLOAD_LIMIT");
61
+ if (session.received.has(index) || session.inFlight.has(index))
62
+ throw new ProjectImportUploadError("ZIP_UPLOAD_CHUNK_DUPLICATE");
63
+ const expectedSize = Math.min(CHUNK_SIZE, session.size - index * CHUNK_SIZE);
64
+ if (bytes.length !== expectedSize)
65
+ throw new ProjectImportUploadError("ZIP_UPLOAD_CHUNK_INVALID");
66
+ session.inFlight.add(index);
67
+ try {
68
+ await fs.writeFile(path.join(session.directory, `${index}.part`), bytes, { flag: "wx", mode: 0o600 });
69
+ session.received.add(index);
70
+ session.updatedAt = Date.now();
71
+ }
72
+ finally {
73
+ session.inFlight.delete(index);
74
+ }
75
+ }
76
+ async assemble(userId, uploadId) {
77
+ const session = this.getSession(userId, uploadId);
78
+ if (session.completing || session.inFlight.size > 0 || session.received.size !== session.chunkCount) {
79
+ throw new ProjectImportUploadError("ZIP_UPLOAD_INCOMPLETE");
80
+ }
81
+ session.completing = true;
82
+ try {
83
+ const buffer = Buffer.allocUnsafe(session.size);
84
+ let offset = 0;
85
+ for (let index = 0; index < session.chunkCount; index += 1) {
86
+ const chunk = await fs.readFile(path.join(session.directory, `${index}.part`));
87
+ if (chunk.length !== Math.min(CHUNK_SIZE, session.size - offset))
88
+ throw new ProjectImportUploadError("ZIP_UPLOAD_CHUNK_INVALID");
89
+ chunk.copy(buffer, offset);
90
+ offset += chunk.length;
91
+ }
92
+ if (offset !== session.size)
93
+ throw new ProjectImportUploadError("ZIP_UPLOAD_CHUNK_INVALID");
94
+ return { filename: session.filename, buffer };
95
+ }
96
+ catch (error) {
97
+ if (error instanceof ProjectImportUploadError)
98
+ throw error;
99
+ throw new ProjectImportUploadError("ZIP_UPLOAD_INCOMPLETE");
100
+ }
101
+ finally {
102
+ await this.removeSession(session);
103
+ }
104
+ }
105
+ async cancel(userId, uploadId) {
106
+ const session = this.sessions.get(uploadId);
107
+ if (!session || session.userId !== userId || session.completing || session.inFlight.size > 0)
108
+ return;
109
+ await this.removeSession(session);
110
+ }
111
+ async close() {
112
+ clearInterval(this.cleanupTimer);
113
+ await Promise.all([...this.sessions.values()].map((session) => this.removeSession(session)));
114
+ }
115
+ getSession(userId, uploadId) {
116
+ const session = this.sessions.get(uploadId);
117
+ if (!session || session.userId !== userId || Date.now() - session.updatedAt > SESSION_TTL_MS) {
118
+ if (session && Date.now() - session.updatedAt > SESSION_TTL_MS)
119
+ void this.removeSession(session).catch(() => undefined);
120
+ throw new ProjectImportUploadError("ZIP_UPLOAD_NOT_FOUND");
121
+ }
122
+ return session;
123
+ }
124
+ async cleanupExpired() {
125
+ const expired = [...this.sessions.values()].filter((session) => !session.completing && session.inFlight.size === 0 && Date.now() - session.updatedAt > SESSION_TTL_MS);
126
+ await Promise.all(expired.map((session) => this.removeSession(session)));
127
+ }
128
+ async removeSession(session) {
129
+ if (this.sessions.get(session.id) === session)
130
+ this.sessions.delete(session.id);
131
+ await fs.rm(session.directory, { recursive: true, force: true });
132
+ }
133
+ }
@@ -477,6 +477,9 @@ export function registerProjectFileRoutes(app, context) {
477
477
  throw error;
478
478
  }
479
479
  fs.rmSync(absolute, { recursive: true, force: true });
480
+ if (currentProject.latexmkrc === relative || currentProject.latexmkrc?.startsWith(`${relative}/`)) {
481
+ db.prepare("UPDATE projects SET latexmkrc = NULL WHERE id = ?").run(id);
482
+ }
480
483
  const deleteResult = db.prepare("DELETE FROM comments WHERE project_id = ? AND (file_path = ? OR file_path GLOB ?)").run(id, relative, `${escapeGlobPattern(relative)}/*`);
481
484
  touchProject(db, id, user.id);
482
485
  collaboration.removePath(id, relative);
@@ -23,27 +23,26 @@ export function registerProjectGitRoutes(app, context) {
23
23
  return { status: await projectGit.status(requireGitOwner(id, user)) };
24
24
  }, { preflight: () => { requireGitOwner(id, user); } });
25
25
  });
26
- app.put("/api/projects/:id/git/token", async (request, reply) => {
26
+ app.get("/api/account/github", async (request, reply) => {
27
+ const user = requireUser(request, reply, db);
28
+ if (!user)
29
+ return;
30
+ return { status: projectGit.githubAccountStatus(user.id) };
31
+ });
32
+ app.put("/api/account/github", async (request, reply) => {
27
33
  const user = requireUser(request, reply, db);
28
34
  if (!user)
29
35
  return;
30
- const { id } = request.params;
31
36
  const body = request.body;
32
37
  if (typeof body.token !== "string")
33
38
  return apiError(reply, 400, "GIT_TOKEN_INVALID");
34
- const token = body.token;
35
- return await projectMutations.runSerialized(id, async () => {
36
- return { status: await projectGit.configureToken(requireGitOwner(id, user), token) };
37
- }, { preflight: () => { requireGitOwner(id, user); } });
39
+ return { status: await projectGit.configureUserToken(user.id, body.token) };
38
40
  });
39
- app.delete("/api/projects/:id/git/token", async (request, reply) => {
41
+ app.delete("/api/account/github", async (request, reply) => {
40
42
  const user = requireUser(request, reply, db);
41
43
  if (!user)
42
44
  return;
43
- const { id } = request.params;
44
- return await projectMutations.runSerialized(id, async () => {
45
- return { status: await projectGit.removeToken(requireGitOwner(id, user)) };
46
- }, { preflight: () => { requireGitOwner(id, user); } });
45
+ return { status: projectGit.removeUserToken(user.id) };
47
46
  });
48
47
  app.post("/api/projects/:id/git/repository", async (request, reply) => {
49
48
  const user = requireUser(request, reply, db);
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import { isUtf8 } from "node:buffer";
2
3
  import { requireUser } from "../auth.js";
3
4
  import { isCollaborativeTextFile, maxCollaborativeFileBytes } from "../collaboration.js";
4
5
  import { hashText } from "../editHistory.js";
@@ -6,6 +7,7 @@ import { HISTORY_RETENTION_DELAY_MS } from "../historyRetention.js";
6
7
  import { resolveSourcePath, safeRelativePath } from "../files.js";
7
8
  import { apiError, httpError } from "../http.js";
8
9
  import { MAX_TEXT_PREVIEW_BYTES } from "../limits.js";
10
+ import { isHistoryTextFile } from "../../shared/historyFiles.js";
9
11
  import { accessibleProject, canEdit } from "../projects.js";
10
12
  import { commentsSummaryForProject, projectJson, projectTextSnapshot, reanchorProjectSnapshot, requireEditableProject, requireProjectOwnerPermission, tagsForProject, text, touchProject } from "./projectShared.js";
11
13
  /** Register retained project version listing, inspection, restoration, and cleanup routes. */
@@ -112,7 +114,10 @@ export function registerProjectHistoryRoutes(app, context) {
112
114
  return {
113
115
  version,
114
116
  settings: manifest.settings,
115
- files: Object.entries(manifest.files).map(([filePath, file]) => ({ path: filePath, size: file.size })).sort((left, right) => left.path.localeCompare(right.path))
117
+ files: [
118
+ ...Object.entries(manifest.files).map(([filePath, file]) => ({ path: filePath, size: file.size })),
119
+ ...version.changedPaths.filter((filePath) => !manifest.files[filePath]).map((filePath) => ({ path: filePath, size: 0, deleted: true }))
120
+ ].sort((left, right) => left.path.localeCompare(right.path))
116
121
  };
117
122
  });
118
123
  app.get("/api/projects/:id/history/:versionId/file", async (request, reply) => {
@@ -125,19 +130,41 @@ export function registerProjectHistoryRoutes(app, context) {
125
130
  const query = request.query;
126
131
  const filePath = safeRelativePath(query.path ?? "");
127
132
  return await projectMutations.runConsistentRead(id, () => {
128
- const historical = history.readTextFile(id, versionId, filePath);
133
+ const manifest = history.manifest(id, versionId);
134
+ const version = history.version(versionId, id);
135
+ if (!manifest || !version)
136
+ return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
137
+ if (!manifest.files[filePath] && !version.changedPaths.includes(filePath))
138
+ return apiError(reply, 404, "HISTORY_FILE_NOT_FOUND");
139
+ // Never decode attachment bytes as UTF-8, including direct API calls.
140
+ if (!isHistoryTextFile(filePath))
141
+ return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
142
+ const historical = manifest.files[filePath] ? history.readTextFile(id, versionId, filePath) : "";
129
143
  if (historical === null)
130
144
  return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
131
145
  let comparison = "";
132
146
  const previousVersion = query.against === "__previous__" ? history.previousVersion(id, versionId) : null;
133
147
  const against = query.against === "__previous__" ? previousVersion?.id ?? "__none__" : query.against;
134
148
  if (query.against) {
135
- comparison = history.readTextFile(id, against, filePath) ?? "";
149
+ const comparisonManifest = history.manifest(id, against);
150
+ if (against !== "__none__" && !comparisonManifest)
151
+ return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
152
+ if (comparisonManifest?.files[filePath]) {
153
+ const text = history.readTextFile(id, against, filePath);
154
+ if (text === null)
155
+ return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
156
+ comparison = text;
157
+ }
136
158
  }
137
159
  else {
138
160
  const current = resolveSourcePath(config, id, filePath);
139
- if (fs.existsSync(current) && fs.statSync(current).isFile() && fs.statSync(current).size <= MAX_TEXT_PREVIEW_BYTES) {
140
- comparison = fs.readFileSync(current, "utf8");
161
+ if (fs.existsSync(current) && fs.statSync(current).isFile()) {
162
+ if (fs.statSync(current).size > MAX_TEXT_PREVIEW_BYTES)
163
+ return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
164
+ const bytes = fs.readFileSync(current);
165
+ if (bytes.includes(0) || !isUtf8(bytes))
166
+ return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
167
+ comparison = bytes.toString("utf8");
141
168
  }
142
169
  }
143
170
  return { path: filePath, historical, comparison, against: against ?? "current", previousVersion };