texlite 0.8.4 → 0.9.1

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 (69) hide show
  1. package/DESIGN.md +75 -8
  2. package/OPERATIONS.md +19 -2
  3. package/dist/client/assets/{AdminUsers-CqOmV9rq.js → AdminUsers-Dazar9cj.js} +1 -1
  4. package/dist/client/assets/{CitationLibraryDialog-BmWFXTW2.js → CitationLibraryDialog-VSUiSPgG.js} +1 -1
  5. package/dist/client/assets/Dashboard-CoE5ESfb.js +2 -0
  6. package/dist/client/assets/GitDialog-C9Loz4Al.js +3 -0
  7. package/dist/client/assets/HistoryDialog-3UNN_jhU.js +7 -0
  8. package/dist/client/assets/{LatexEditor-DSopAHbC.js → LatexEditor-Bq76v8sO.js} +2 -2
  9. package/dist/client/assets/{PdfPreview-BDkU20jP.js → PdfPreview-BKhcS-2c.js} +1 -1
  10. package/dist/client/assets/{ProjectIconPickerDialog-GAd8HGOx.js → ProjectIconPickerDialog-BoWMjQVm.js} +1 -1
  11. package/dist/client/assets/{ProjectNavigationDialogs-BTLcRY7i.js → ProjectNavigationDialogs-CZKTnxrl.js} +1 -1
  12. package/dist/client/assets/{ProjectSettings-BJWTkoZq.js → ProjectSettings-DiOQVJ40.js} +1 -1
  13. package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +27 -0
  14. package/dist/client/assets/SelectionHistoryDialog-D3dO83lg.js +3 -0
  15. package/dist/client/assets/{SystemMetricsDialog-C9P4TSQl.js → SystemMetricsDialog-0zCwLtH_.js} +1 -1
  16. package/dist/client/assets/{TagManagementDialog-LYbib36_.js → TagManagementDialog-C6KDwtvd.js} +1 -1
  17. package/dist/client/assets/{WordCountDialog-BFg0GeJy.js → WordCountDialog-TwpyAP3m.js} +1 -1
  18. package/dist/client/assets/check-sZxOOKtS.js +1 -0
  19. package/dist/client/assets/chevron-left-32SrOwa5.js +1 -0
  20. package/dist/client/assets/hard-drive-XHSiS_A7.js +1 -0
  21. package/dist/client/assets/index-BRr3Kuxh.css +1 -0
  22. package/dist/client/assets/index-BjzELSQ-.js +12 -0
  23. package/dist/client/assets/index-Cd6-F29b.js +5 -0
  24. package/dist/client/assets/{key-round-BaIj7Jch.js → key-round-C-CXCWnz.js} +1 -1
  25. package/dist/client/assets/minimize-2-BMDPVaF-.js +1 -0
  26. package/dist/client/assets/{minus-Beso_n-6.js → minus-BIa7-KuM.js} +1 -1
  27. package/dist/client/assets/{plus-BfEC7mzf.js → plus-BKDWy4Ui.js} +1 -1
  28. package/dist/client/assets/{refresh-cw-FZs9MtoA.js → refresh-cw-Bo7-a7-I.js} +1 -1
  29. package/dist/client/assets/{search-DL2UGvdj.js → search-Bz9Ob5mT.js} +1 -1
  30. package/dist/client/assets/{sigma-CSRwPPkk.js → sigma-DxlHf9gv.js} +1 -1
  31. package/dist/client/assets/spellCheck-BfZEnpwY.js +1 -0
  32. package/dist/client/assets/{tags-CjkWzXqX.js → tags-IMZehjzE.js} +1 -1
  33. package/dist/client/assets/{type-D1mmDdLM.js → type-DTWpFQNQ.js} +1 -1
  34. package/dist/client/assets/{user-plus-YJ3ftuL5.js → user-plus--aVpd8K6.js} +1 -1
  35. package/dist/client/assets/x-Tbo3aA0J.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/harper.js +205 -8
  46. package/dist/server/history.js +189 -23
  47. package/dist/server/historyRetention.js +37 -0
  48. package/dist/server/i18n.js +12 -0
  49. package/dist/server/index.js +6 -0
  50. package/dist/server/latexSpellMask.js +197 -43
  51. package/dist/server/logRotation.js +82 -0
  52. package/dist/server/pm2.js +7 -0
  53. package/dist/server/routes/comments.js +120 -17
  54. package/dist/server/routes/projectHistory.js +106 -8
  55. package/dist/server/routes/projectShared.js +2 -1
  56. package/dist/server/routes/projects.js +39 -5
  57. package/package.json +1 -1
  58. package/texlite.config.example.json +4 -1
  59. package/dist/client/assets/Dashboard-BfN2bCgZ.js +0 -2
  60. package/dist/client/assets/GitDialog-BCfSHuMB.js +0 -3
  61. package/dist/client/assets/HistoryDialog-BtQhyTr9.js +0 -5
  62. package/dist/client/assets/ProjectWorkspace-WLYA7BSv.js +0 -27
  63. package/dist/client/assets/check-bnimQPun.js +0 -1
  64. package/dist/client/assets/chevron-left-DNM7mhNi.js +0 -1
  65. package/dist/client/assets/hard-drive-CF3WDfkG.js +0 -1
  66. package/dist/client/assets/index-CzVDvC5h.js +0 -14
  67. package/dist/client/assets/index-RjUuWk8F.css +0 -1
  68. package/dist/client/assets/spellCheck-L9gD2d1M.js +0 -1
  69. package/dist/client/assets/x-BDSEosRE.js +0 -1
@@ -0,0 +1,139 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * List active users who can access a project. The owner is included even
4
+ * though they have no project_members row. Callers can exclude the author
5
+ * when presenting a mention picker.
6
+ */
7
+ export function mentionableUsersForProject(db, projectId, excludeUserId) {
8
+ const exclusion = excludeUserId ? "AND user.id <> ?" : "";
9
+ const parameters = excludeUserId ? [projectId, excludeUserId] : [projectId];
10
+ const rows = db.prepare(`SELECT DISTINCT user.id, user.username, user.display_name
11
+ FROM users user
12
+ JOIN projects project ON project.id = ?
13
+ LEFT JOIN project_members member ON member.project_id = project.id AND member.user_id = user.id
14
+ WHERE user.disabled = 0
15
+ AND (user.id = project.owner_id OR member.user_id IS NOT NULL)
16
+ ${exclusion}
17
+ ORDER BY user.display_name COLLATE NOCASE, user.username COLLATE NOCASE`)
18
+ .all(...parameters);
19
+ return rows.map((user) => ({ id: user.id, username: user.username, displayName: user.display_name }));
20
+ }
21
+ /**
22
+ * Extract mention tokens without taking ownership of ordinary `@` text.
23
+ * The surrounding-character rule intentionally excludes e-mail addresses;
24
+ * the candidate lookup below then makes a token a mention only when it is an
25
+ * exact, currently accessible username.
26
+ */
27
+ export function mentionedUsernames(content, candidates) {
28
+ const available = new Map(candidates.map((candidate) => [candidate.username.toLocaleLowerCase(), candidate.username]));
29
+ const usernames = new Set();
30
+ const pattern = /(^|[^\p{L}\p{N}_.-])@([\p{L}\p{N}_.-]+)/gu;
31
+ for (const match of content.matchAll(pattern)) {
32
+ let token = match[2];
33
+ let username = available.get(token.toLocaleLowerCase());
34
+ // A sentence-ending period or comma is not part of the mention, even
35
+ // though a username itself is allowed to contain dots and hyphens.
36
+ while (!username && /[.,;:!?)}\]\u201d\u2019]$/u.test(token)) {
37
+ token = token.slice(0, -1);
38
+ username = available.get(token.toLocaleLowerCase());
39
+ }
40
+ if (username)
41
+ usernames.add(username);
42
+ }
43
+ return [...usernames];
44
+ }
45
+ /** Persist first-class notifications for exact mentions in one comment item. */
46
+ export function createCommentMentions(db, input) {
47
+ const candidates = mentionableUsersForProject(db, input.projectId);
48
+ const previousUsernames = new Set(input.previousContent ? mentionedUsernames(input.previousContent, candidates) : []);
49
+ const recipients = mentionedUsernames(input.content, candidates)
50
+ .map((username) => candidates.find((candidate) => candidate.username === username))
51
+ .filter((candidate) => Boolean(candidate && candidate.id !== input.authorId && !previousUsernames.has(candidate.username)));
52
+ if (!recipients.length)
53
+ return;
54
+ const insert = db.prepare(`INSERT INTO comment_mentions
55
+ (id, project_id, comment_id, reply_id, mentioned_user_id, mentioned_by_user_id, created_at)
56
+ VALUES (?, ?, ?, ?, ?, ?, ?)
57
+ ON CONFLICT DO UPDATE SET
58
+ mentioned_by_user_id = excluded.mentioned_by_user_id,
59
+ created_at = excluded.created_at,
60
+ read_at = NULL,
61
+ read_reason = NULL`);
62
+ for (const recipient of recipients) {
63
+ insert.run(randomUUID(), input.projectId, input.commentId, input.replyId ?? null, recipient.id, input.authorId, input.createdAt);
64
+ }
65
+ }
66
+ /** Return unread mention counts scoped to one receiving user. */
67
+ export function unreadMentionCountsForProjects(db, projectIds, userId) {
68
+ const counts = new Map();
69
+ for (let offset = 0; offset < projectIds.length; offset += 200) {
70
+ const chunk = projectIds.slice(offset, offset + 200);
71
+ const placeholders = chunk.map(() => "?").join(", ");
72
+ const rows = db.prepare(`SELECT project_id, COUNT(*) AS count
73
+ FROM comment_mentions
74
+ WHERE mentioned_user_id = ? AND read_at IS NULL AND project_id IN (${placeholders})
75
+ GROUP BY project_id`).all(userId, ...chunk);
76
+ for (const row of rows)
77
+ counts.set(row.project_id, Number(row.count) || 0);
78
+ }
79
+ return counts;
80
+ }
81
+ function mentionFromRow(row) {
82
+ return {
83
+ id: row.id,
84
+ projectId: row.project_id,
85
+ commentId: row.comment_id,
86
+ replyId: row.reply_id,
87
+ filePath: row.file_path,
88
+ content: row.reply_id ? row.reply_content ?? "" : row.comment_content,
89
+ resolved: Boolean(row.resolved),
90
+ createdAt: row.created_at,
91
+ readAt: row.read_at,
92
+ readReason: row.read_reason
93
+ };
94
+ }
95
+ const mentionSelect = `SELECT mention.id, mention.project_id, mention.comment_id, mention.reply_id,
96
+ comment.file_path, comment.content AS comment_content, reply.content AS reply_content,
97
+ comment.resolved, mention.created_at, mention.read_at, mention.read_reason
98
+ FROM comment_mentions mention
99
+ JOIN comments comment ON comment.id = mention.comment_id
100
+ LEFT JOIN comment_replies reply ON reply.id = mention.reply_id`;
101
+ export function listCommentMentions(db, projectId, userId, options = {}) {
102
+ const limit = Math.min(100, Math.max(1, options.limit ?? 50));
103
+ const unreadClause = options.unreadOnly ? "AND mention.read_at IS NULL" : "";
104
+ const fileClause = options.filePath ? "AND comment.file_path = ?" : "";
105
+ const parameters = options.filePath
106
+ ? [projectId, userId, options.filePath, limit]
107
+ : [projectId, userId, limit];
108
+ const rows = db.prepare(`${mentionSelect}
109
+ WHERE mention.project_id = ? AND mention.mentioned_user_id = ? ${unreadClause} ${fileClause}
110
+ ORDER BY mention.created_at DESC LIMIT ?`).all(...parameters);
111
+ return rows.map(mentionFromRow);
112
+ }
113
+ export function commentMentionForUser(db, mentionId, projectId, userId) {
114
+ const row = db.prepare(`${mentionSelect}
115
+ WHERE mention.id = ? AND mention.project_id = ? AND mention.mentioned_user_id = ?`)
116
+ .get(mentionId, projectId, userId);
117
+ return row ? mentionFromRow(row) : null;
118
+ }
119
+ /** Mark an item read after the UI has opened and located its target thread. */
120
+ export function markCommentMentionRead(db, mentionId, userId, readAt, reason = "opened") {
121
+ return db.prepare(`UPDATE comment_mentions
122
+ SET read_at = ?, read_reason = ?
123
+ WHERE id = ? AND mentioned_user_id = ? AND read_at IS NULL`)
124
+ .run(readAt, reason, mentionId, userId).changes > 0;
125
+ }
126
+ /** Explicitly clear every unread notification in a project for one recipient. */
127
+ export function markAllCommentMentionsRead(db, projectId, userId, readAt) {
128
+ return db.prepare(`UPDATE comment_mentions
129
+ SET read_at = ?, read_reason = 'manual'
130
+ WHERE project_id = ? AND mentioned_user_id = ? AND read_at IS NULL`)
131
+ .run(readAt, projectId, userId).changes;
132
+ }
133
+ /** Resolving a thread makes all existing unread mentions in it non-actionable. */
134
+ export function markCommentMentionsResolved(db, commentId, resolvedAt) {
135
+ return db.prepare(`UPDATE comment_mentions
136
+ SET read_at = ?, read_reason = 'resolved'
137
+ WHERE comment_id = ? AND read_at IS NULL`)
138
+ .run(resolvedAt, commentId).changes;
139
+ }
@@ -22,8 +22,9 @@ export const CONFIG_DEFAULTS = {
22
22
  maxFileSizeMB: 50,
23
23
  pdfLoadingStrategy: "auto",
24
24
  pdfRangeThresholdMB: 5,
25
- historyMaxVersions: 200,
25
+ historyMaxVersions: 0,
26
26
  historyMaxStorageMB: 128,
27
+ editHistoryMaxStorageMB: 32,
27
28
  git: "git",
28
29
  gitOperationTimeoutSeconds: 120,
29
30
  githubApiBaseUrl: "https://api.github.com"
@@ -35,8 +36,9 @@ const CONFIG_LIMITS = {
35
36
  maxCompileJobs: [1, 32],
36
37
  maxFileSizeMB: [1, 2_048],
37
38
  pdfRangeThresholdMB: [1, 2_048],
38
- historyMaxVersions: [10, 5_000],
39
+ historyMaxVersions: [0, 50_000],
39
40
  historyMaxStorageMB: [16, 102_400],
41
+ editHistoryMaxStorageMB: [4, 102_400],
40
42
  gitOperationTimeoutSeconds: [1, 3_600]
41
43
  };
42
44
  export function loadConfig(configPathOverride) {
@@ -70,6 +72,7 @@ export function loadConfig(configPathOverride) {
70
72
  const pdfRangeThresholdMB = integerSetting("pdf.rangeThresholdMB", process.env.TEXLITE_PDF_RANGE_THRESHOLD_MB, fileConfig.pdf?.rangeThresholdMB, CONFIG_DEFAULTS.pdfRangeThresholdMB, CONFIG_LIMITS.pdfRangeThresholdMB);
71
73
  const historyMaxVersions = integerSetting("history.maxVersions", process.env.TEXLITE_HISTORY_MAX_VERSIONS, fileConfig.history?.maxVersions, CONFIG_DEFAULTS.historyMaxVersions, CONFIG_LIMITS.historyMaxVersions);
72
74
  const historyMaxStorageMB = integerSetting("history.maxStorageMB", process.env.TEXLITE_HISTORY_MAX_STORAGE_MB, fileConfig.history?.maxStorageMB, CONFIG_DEFAULTS.historyMaxStorageMB, CONFIG_LIMITS.historyMaxStorageMB);
75
+ const editHistoryMaxStorageMB = integerSetting("editHistory.maxStorageMB", process.env.TEXLITE_EDIT_HISTORY_MAX_STORAGE_MB, fileConfig.editHistory?.maxStorageMB, CONFIG_DEFAULTS.editHistoryMaxStorageMB, CONFIG_LIMITS.editHistoryMaxStorageMB);
73
76
  const gitOperationTimeoutSeconds = integerSetting("git.operationTimeoutSeconds", process.env.TEXLITE_GIT_TIMEOUT, fileConfig.git?.operationTimeoutSeconds, CONFIG_DEFAULTS.gitOperationTimeoutSeconds, CONFIG_LIMITS.gitOperationTimeoutSeconds);
74
77
  const config = {
75
78
  configPath,
@@ -94,6 +97,7 @@ export function loadConfig(configPathOverride) {
94
97
  pdfRangeThresholdBytes: pdfRangeThresholdMB * 1024 * 1024,
95
98
  historyMaxVersions,
96
99
  historyMaxStorageBytes: historyMaxStorageMB * 1024 * 1024,
100
+ editHistoryMaxStorageBytes: editHistoryMaxStorageMB * 1024 * 1024,
97
101
  git: stringSetting("git.binary", process.env.TEXLITE_GIT, fileConfig.git?.binary, CONFIG_DEFAULTS.git, { min: 1, max: 256 }),
98
102
  gitOperationTimeoutMs: gitOperationTimeoutSeconds * 1000,
99
103
  githubApiBaseUrl: stringSetting("git.githubApiBaseUrl", process.env.TEXLITE_GITHUB_API_URL, fileConfig.git?.githubApiBaseUrl, CONFIG_DEFAULTS.githubApiBaseUrl, { min: 1, max: 2_048 }).replace(/\/+$/, "")
@@ -132,6 +136,7 @@ export function validateConfig(config) {
132
136
  validateInteger("pdf.rangeThresholdMB", config.pdfRangeThresholdBytes / (1024 * 1024), CONFIG_LIMITS.pdfRangeThresholdMB);
133
137
  validateInteger("history.maxVersions", config.historyMaxVersions, CONFIG_LIMITS.historyMaxVersions);
134
138
  validateInteger("history.maxStorageMB", config.historyMaxStorageBytes / (1024 * 1024), CONFIG_LIMITS.historyMaxStorageMB);
139
+ validateInteger("editHistory.maxStorageMB", config.editHistoryMaxStorageBytes / (1024 * 1024), CONFIG_LIMITS.editHistoryMaxStorageMB);
135
140
  validateInteger("git.operationTimeoutSeconds", config.gitOperationTimeoutMs / 1000, CONFIG_LIMITS.gitOperationTimeoutSeconds);
136
141
  }
137
142
  function isEngine(value) {
@@ -176,6 +181,8 @@ function validateFileConfig(config) {
176
181
  const history = optionalSection(config.history, "history");
177
182
  optionalInteger(history?.maxVersions, "history.maxVersions", CONFIG_LIMITS.historyMaxVersions);
178
183
  optionalInteger(history?.maxStorageMB, "history.maxStorageMB", CONFIG_LIMITS.historyMaxStorageMB);
184
+ const editHistory = optionalSection(config.editHistory, "editHistory");
185
+ optionalInteger(editHistory?.maxStorageMB, "editHistory.maxStorageMB", CONFIG_LIMITS.editHistoryMaxStorageMB);
179
186
  const latex = optionalSection(config.latex, "latex");
180
187
  optionalString(latex?.latexmk, "latex.latexmk", { min: 1, max: 256 });
181
188
  if (latex && Object.prototype.hasOwnProperty.call(latex, "defaultEngine") && !isEngine(latex.defaultEngine)) {
package/dist/server/db.js CHANGED
@@ -143,6 +143,27 @@ function migrate(db) {
143
143
  );
144
144
  CREATE INDEX IF NOT EXISTS comment_replies_comment_id ON comment_replies(comment_id, created_at);
145
145
 
146
+ -- Mentions are personal notification records, rather than a mutable
147
+ -- counter on comments or projects. This makes unread state naturally
148
+ -- per user and lets resolving one thread clear its existing notifications.
149
+ CREATE TABLE IF NOT EXISTS comment_mentions (
150
+ id TEXT PRIMARY KEY,
151
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
152
+ comment_id TEXT NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
153
+ reply_id TEXT REFERENCES comment_replies(id) ON DELETE CASCADE,
154
+ mentioned_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
155
+ mentioned_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
156
+ read_at TEXT,
157
+ read_reason TEXT CHECK (read_reason IN ('opened', 'resolved', 'manual')),
158
+ created_at TEXT NOT NULL
159
+ );
160
+ CREATE INDEX IF NOT EXISTS comment_mentions_receiver_unread
161
+ ON comment_mentions(mentioned_user_id, project_id, created_at DESC)
162
+ WHERE read_at IS NULL;
163
+ CREATE INDEX IF NOT EXISTS comment_mentions_comment_id ON comment_mentions(comment_id);
164
+ CREATE UNIQUE INDEX IF NOT EXISTS comment_mentions_source_recipient
165
+ ON comment_mentions(comment_id, IFNULL(reply_id, ''), mentioned_user_id);
166
+
146
167
  CREATE TABLE IF NOT EXISTS compile_runs (
147
168
  id TEXT PRIMARY KEY,
148
169
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
@@ -174,6 +195,37 @@ function migrate(db) {
174
195
  updated_at TEXT NOT NULL
175
196
  );
176
197
 
198
+ -- Recovery snapshots and collaborative edit records serve different
199
+ -- purposes. Snapshots are content-addressed restore points; these rows
200
+ -- retain the ordered, author-attributed text changes that led to the
201
+ -- current source, so a selected range can show who changed it.
202
+ CREATE TABLE IF NOT EXISTS project_edit_segments (
203
+ id TEXT PRIMARY KEY,
204
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
205
+ file_path TEXT NOT NULL,
206
+ author_id TEXT REFERENCES users(id) ON DELETE SET NULL,
207
+ kind TEXT NOT NULL CHECK (kind IN ('edit', 'format')),
208
+ before_hash TEXT NOT NULL,
209
+ after_hash TEXT NOT NULL,
210
+ steps_json TEXT NOT NULL,
211
+ steps_bytes INTEGER NOT NULL DEFAULT 0,
212
+ created_at TEXT NOT NULL,
213
+ updated_at TEXT NOT NULL
214
+ );
215
+ CREATE INDEX IF NOT EXISTS project_edit_segments_project_file_updated
216
+ ON project_edit_segments(project_id, file_path, updated_at DESC);
217
+
218
+ -- Records the newest missing delta per file after retention drops an edit
219
+ -- segment. Selection history uses it to stop at a known boundary instead
220
+ -- of presenting an incomplete attribution as a full edit trail.
221
+ CREATE TABLE IF NOT EXISTS project_edit_history_boundaries (
222
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
223
+ file_path TEXT NOT NULL,
224
+ after_hash TEXT NOT NULL,
225
+ updated_at TEXT NOT NULL,
226
+ PRIMARY KEY (project_id, file_path)
227
+ );
228
+
177
229
  CREATE TABLE IF NOT EXISTS project_dictionary_words (
178
230
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
179
231
  word TEXT NOT NULL COLLATE NOCASE,
@@ -228,6 +280,13 @@ function migrate(db) {
228
280
  updated_at TEXT NOT NULL
229
281
  );
230
282
  `);
283
+ const editColumns = db.prepare("PRAGMA table_info(project_edit_segments)").all();
284
+ if (!editColumns.some((column) => column.name === "steps_bytes")) {
285
+ db.transaction(() => {
286
+ db.exec("ALTER TABLE project_edit_segments ADD COLUMN steps_bytes INTEGER NOT NULL DEFAULT 0");
287
+ db.exec("UPDATE project_edit_segments SET steps_bytes = LENGTH(CAST(steps_json AS BLOB))");
288
+ })();
289
+ }
231
290
  const projectColumns = db.prepare("PRAGMA table_info(projects)").all();
232
291
  if (!projectColumns.some((column) => column.name === "latexmkrc")) {
233
292
  db.exec("ALTER TABLE projects ADD COLUMN latexmkrc TEXT");