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
@@ -1,14 +1,75 @@
1
1
  import fs from "node:fs";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { requireUser } from "../auth.js";
4
4
  import { createSourceAnchor, offsetToLine } from "../anchors.js";
5
5
  import { resolveSourcePath, safeRelativePath } from "../files.js";
6
6
  import { apiError, httpError } from "../http.js";
7
7
  import { accessibleProject } from "../projects.js";
8
+ import { commentMentionForUser, createCommentMentions, listCommentMentions, markAllCommentMentionsRead, markCommentMentionRead, markCommentMentionsResolved, mentionableUsersForProject } from "../commentMentions.js";
8
9
  import { commentsForFile, now, repliesForComment, text } from "./projectShared.js";
9
10
  /** Register source-anchored comments and reply routes. */
10
11
  export function registerCommentRoutes(app, context) {
11
12
  const { config, db, collaboration, projectMutations } = context;
13
+ app.get("/api/projects/:id/mention-candidates", async (request, reply) => {
14
+ const user = requireUser(request, reply, db);
15
+ if (!user)
16
+ return;
17
+ const { id } = request.params;
18
+ if (!accessibleProject(db, id, user))
19
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
20
+ // Mentions are intended to notify another participant. Excluding the
21
+ // author avoids offering a no-op self notification in every composer.
22
+ return { users: mentionableUsersForProject(db, id, user.id) };
23
+ });
24
+ app.get("/api/projects/:id/mentions", async (request, reply) => {
25
+ const user = requireUser(request, reply, db);
26
+ if (!user)
27
+ return;
28
+ const { id } = request.params;
29
+ if (!accessibleProject(db, id, user))
30
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
31
+ const query = request.query;
32
+ const requestedLimit = Number.parseInt(query.limit ?? "50", 10);
33
+ return {
34
+ mentions: listCommentMentions(db, id, user.id, {
35
+ unreadOnly: query.unread === "1" || query.unread === "true",
36
+ limit: Number.isFinite(requestedLimit) ? requestedLimit : 50,
37
+ filePath: typeof query.path === "string" && query.path ? safeRelativePath(query.path) : undefined
38
+ })
39
+ };
40
+ });
41
+ app.get("/api/projects/:id/mentions/:mentionId", async (request, reply) => {
42
+ const user = requireUser(request, reply, db);
43
+ if (!user)
44
+ return;
45
+ const { id, mentionId } = request.params;
46
+ if (!accessibleProject(db, id, user))
47
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
48
+ const mention = commentMentionForUser(db, mentionId, id, user.id);
49
+ if (!mention)
50
+ return apiError(reply, 404, "MENTION_NOT_FOUND");
51
+ return { mention };
52
+ });
53
+ app.post("/api/projects/:id/mentions/:mentionId/read", async (request, reply) => {
54
+ const user = requireUser(request, reply, db);
55
+ if (!user)
56
+ return;
57
+ const { id, mentionId } = request.params;
58
+ if (!accessibleProject(db, id, user))
59
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
60
+ if (!commentMentionForUser(db, mentionId, id, user.id))
61
+ return apiError(reply, 404, "MENTION_NOT_FOUND");
62
+ return { ok: true, changed: markCommentMentionRead(db, mentionId, user.id, now(), "manual") };
63
+ });
64
+ app.post("/api/projects/:id/mentions/read-all", async (request, reply) => {
65
+ const user = requireUser(request, reply, db);
66
+ if (!user)
67
+ return;
68
+ const { id } = request.params;
69
+ if (!accessibleProject(db, id, user))
70
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
71
+ return { ok: true, changed: markAllCommentMentionsRead(db, id, user.id, now()) };
72
+ });
12
73
  app.get("/api/projects/:id/comments", async (request, reply) => {
13
74
  const user = requireUser(request, reply, db);
14
75
  if (!user)
@@ -45,16 +106,34 @@ export function registerCommentRoutes(app, context) {
45
106
  if (!Number.isInteger(body.startOffset) || !Number.isInteger(body.endOffset)) {
46
107
  return apiError(reply, 400, "COMMENT_RANGE_INVALID");
47
108
  }
109
+ // The browser supplies the exact source revision the user selected.
110
+ // Keep this optional for an already-open client during an upgrade;
111
+ // current clients never bind an annotation to offsets from a different
112
+ // revision.
113
+ if (body.sourceHash !== undefined) {
114
+ if (typeof body.sourceHash !== "string" || !/^[a-f0-9]{64}$/.test(body.sourceHash)) {
115
+ return apiError(reply, 400, "REQUEST_INVALID");
116
+ }
117
+ const currentHash = createHash("sha256").update(source, "utf8").digest("hex");
118
+ if (currentHash !== body.sourceHash)
119
+ return apiError(reply, 409, "COMMENT_SOURCE_CHANGED");
120
+ }
48
121
  const anchor = createSourceAnchor(source, Number(body.startOffset), Number(body.endOffset));
49
122
  const comment = {
50
123
  id: randomUUID(), filePath,
51
124
  content: text(body.content, 5000)
52
125
  };
53
- db.prepare(`INSERT INTO comments
54
- (id, project_id, file_path, author_id, selected_text, start_offset, end_offset,
55
- context_before, context_after, orphaned, start_line, end_line, content, created_at, updated_at)
56
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`)
57
- .run(comment.id, id, comment.filePath, user.id, anchor.selectedText, anchor.startOffset, anchor.endOffset, anchor.contextBefore, anchor.contextAfter, offsetToLine(source, anchor.startOffset), offsetToLine(source, anchor.endOffset), comment.content, createdAt, createdAt);
126
+ db.transaction(() => {
127
+ db.prepare(`INSERT INTO comments
128
+ (id, project_id, file_path, author_id, selected_text, start_offset, end_offset,
129
+ context_before, context_after, orphaned, start_line, end_line, content, created_at, updated_at)
130
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`)
131
+ .run(comment.id, id, comment.filePath, user.id, anchor.selectedText, anchor.startOffset, anchor.endOffset, anchor.contextBefore, anchor.contextAfter, offsetToLine(source, anchor.startOffset), offsetToLine(source, anchor.endOffset), comment.content, createdAt, createdAt);
132
+ createCommentMentions(db, {
133
+ projectId: id, commentId: comment.id, authorId: user.id,
134
+ content: comment.content, createdAt
135
+ });
136
+ })();
58
137
  const created = commentsForFile(db, config, id, filePath).find((item) => item.id === comment.id);
59
138
  collaboration.signalComments(id);
60
139
  return reply.code(201).send({ comment: created });
@@ -76,9 +155,15 @@ export function registerCommentRoutes(app, context) {
76
155
  const body = request.body;
77
156
  const createdAt = now();
78
157
  const replyId = randomUUID();
79
- db.prepare(`INSERT INTO comment_replies (id, comment_id, author_id, content, created_at, updated_at)
80
- VALUES (?, ?, ?, ?, ?, ?)`)
81
- .run(replyId, commentId, user.id, text(body.content, 5000), createdAt, createdAt);
158
+ const content = text(body.content, 5000);
159
+ db.transaction(() => {
160
+ db.prepare(`INSERT INTO comment_replies (id, comment_id, author_id, content, created_at, updated_at)
161
+ VALUES (?, ?, ?, ?, ?, ?)`)
162
+ .run(replyId, commentId, user.id, content, createdAt, createdAt);
163
+ createCommentMentions(db, {
164
+ projectId: id, commentId, replyId, authorId: user.id, content, createdAt
165
+ });
166
+ })();
82
167
  const created = repliesForComment(db, commentId).find((item) => item.id === replyId);
83
168
  collaboration.signalComments(id);
84
169
  return reply.code(201).send({ reply: created });
@@ -90,27 +175,39 @@ export function registerCommentRoutes(app, context) {
90
175
  const { id, commentId } = request.params;
91
176
  if (!accessibleProject(db, id, user))
92
177
  return apiError(reply, 404, "PROJECT_NOT_FOUND");
93
- const comment = db.prepare("SELECT author_id FROM comments WHERE id = ? AND project_id = ?")
178
+ const comment = db.prepare("SELECT author_id, resolved, content FROM comments WHERE id = ? AND project_id = ?")
94
179
  .get(commentId, id);
95
180
  if (!comment)
96
181
  return apiError(reply, 404, "COMMENT_NOT_FOUND");
97
182
  const body = request.body;
98
183
  const changedAt = now();
99
184
  let changed = false;
185
+ const content = typeof body.content === "string" ? text(body.content, 5000) : null;
100
186
  if (typeof body.content === "string") {
101
187
  if (comment.author_id !== user.id)
102
188
  return apiError(reply, 403, "COMMENT_EDIT_FORBIDDEN");
103
- db.prepare("UPDATE comments SET content = ?, updated_at = ?, edited_at = ? WHERE id = ?")
104
- .run(text(body.content, 5000), changedAt, changedAt, commentId);
105
189
  changed = true;
106
190
  }
107
191
  if (typeof body.resolved === "boolean") {
108
- db.prepare("UPDATE comments SET resolved = ?, updated_at = ? WHERE id = ?")
109
- .run(body.resolved ? 1 : 0, changedAt, commentId);
110
192
  changed = true;
111
193
  }
112
194
  if (!changed)
113
195
  return apiError(reply, 400, "COMMENT_UPDATE_EMPTY");
196
+ db.transaction(() => {
197
+ if (content !== null) {
198
+ db.prepare("UPDATE comments SET content = ?, updated_at = ?, edited_at = ? WHERE id = ?")
199
+ .run(content, changedAt, changedAt, commentId);
200
+ createCommentMentions(db, {
201
+ projectId: id, commentId, authorId: user.id, content, previousContent: comment.content, createdAt: changedAt
202
+ });
203
+ }
204
+ if (typeof body.resolved === "boolean") {
205
+ db.prepare("UPDATE comments SET resolved = ?, updated_at = ? WHERE id = ?")
206
+ .run(body.resolved ? 1 : 0, changedAt, commentId);
207
+ if (body.resolved && !comment.resolved)
208
+ markCommentMentionsResolved(db, commentId, changedAt);
209
+ }
210
+ })();
114
211
  collaboration.signalComments(id);
115
212
  return { ok: true };
116
213
  });
@@ -138,7 +235,7 @@ export function registerCommentRoutes(app, context) {
138
235
  const { id, commentId, replyId } = request.params;
139
236
  if (!accessibleProject(db, id, user))
140
237
  return apiError(reply, 404, "PROJECT_NOT_FOUND");
141
- const commentReply = db.prepare(`SELECT reply.author_id FROM comment_replies reply
238
+ const commentReply = db.prepare(`SELECT reply.author_id, reply.content FROM comment_replies reply
142
239
  JOIN comments comment ON comment.id = reply.comment_id
143
240
  WHERE reply.id = ? AND reply.comment_id = ? AND comment.project_id = ?`)
144
241
  .get(replyId, commentId, id);
@@ -148,8 +245,14 @@ export function registerCommentRoutes(app, context) {
148
245
  return apiError(reply, 403, "REPLY_EDIT_FORBIDDEN");
149
246
  const body = request.body;
150
247
  const changedAt = now();
151
- db.prepare("UPDATE comment_replies SET content = ?, updated_at = ?, edited_at = ? WHERE id = ?")
152
- .run(text(body.content, 5000), changedAt, changedAt, replyId);
248
+ const content = text(body.content, 5000);
249
+ db.transaction(() => {
250
+ db.prepare("UPDATE comment_replies SET content = ?, updated_at = ?, edited_at = ? WHERE id = ?")
251
+ .run(content, changedAt, changedAt, replyId);
252
+ createCommentMentions(db, {
253
+ projectId: id, commentId, replyId, authorId: user.id, content, previousContent: commentReply.content, createdAt: changedAt
254
+ });
255
+ })();
153
256
  const updated = repliesForComment(db, commentId).find((item) => item.id === replyId);
154
257
  collaboration.signalComments(id);
155
258
  return { reply: updated };
@@ -1,5 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import { requireUser } from "../auth.js";
3
+ import { isCollaborativeTextFile, maxCollaborativeFileBytes } from "../collaboration.js";
4
+ import { hashText } from "../editHistory.js";
5
+ import { HISTORY_RETENTION_DELAY_MS } from "../historyRetention.js";
3
6
  import { resolveSourcePath, safeRelativePath } from "../files.js";
4
7
  import { apiError, httpError } from "../http.js";
5
8
  import { MAX_TEXT_PREVIEW_BYTES } from "../limits.js";
@@ -7,7 +10,75 @@ import { accessibleProject, canEdit } from "../projects.js";
7
10
  import { commentsSummaryForProject, projectJson, projectTextSnapshot, reanchorProjectSnapshot, requireEditableProject, requireProjectOwnerPermission, tagsForProject, text, touchProject } from "./projectShared.js";
8
11
  /** Register retained project version listing, inspection, restoration, and cleanup routes. */
9
12
  export function registerProjectHistoryRoutes(app, context) {
10
- const { config, db, history, projectMutations, recordHistory } = context;
13
+ const { config, db, history, editHistory, projectMutations, recordHistory } = context;
14
+ app.get("/api/projects/:id/edit-history/stats", async (request, reply) => {
15
+ const user = requireUser(request, reply, db);
16
+ if (!user)
17
+ return;
18
+ const { id } = request.params;
19
+ if (accessibleProject(db, id, user)?.permission !== "owner")
20
+ return apiError(reply, 403, "PROJECT_OWNER_ONLY");
21
+ return editHistory.stats(id);
22
+ });
23
+ app.delete("/api/projects/:id/edit-history", async (request, reply) => {
24
+ const user = requireUser(request, reply, db);
25
+ if (!user)
26
+ return;
27
+ const { id } = request.params;
28
+ if (accessibleProject(db, id, user)?.permission !== "owner")
29
+ return apiError(reply, 403, "PROJECT_OWNER_ONLY");
30
+ return await projectMutations.runWrite(id, () => {
31
+ editHistory.clear(id);
32
+ context.clearPendingEdits(id);
33
+ return editHistory.stats(id);
34
+ }, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
35
+ });
36
+ /**
37
+ * Return only author-attributed edits that can be mapped to the caller's
38
+ * current selection. Recovery snapshots live under /history and deliberately
39
+ * remain a separate concern.
40
+ */
41
+ app.get("/api/projects/:id/edit-history", async (request, reply) => {
42
+ const user = requireUser(request, reply, db);
43
+ if (!user)
44
+ return;
45
+ const { id } = request.params;
46
+ if (!accessibleProject(db, id, user))
47
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
48
+ const query = request.query;
49
+ if (typeof query.sourceHash !== "string" || !/^[a-f0-9]{64}$/.test(query.sourceHash))
50
+ return apiError(reply, 400, "REQUEST_INVALID");
51
+ let filePath;
52
+ try {
53
+ filePath = safeRelativePath(query.path ?? "");
54
+ }
55
+ catch {
56
+ return apiError(reply, 400, "REQUEST_INVALID");
57
+ }
58
+ if (!isCollaborativeTextFile(filePath))
59
+ return apiError(reply, 400, "REQUEST_INVALID");
60
+ const start = parseOffset(query.start);
61
+ const end = parseOffset(query.end);
62
+ if (start === null || end === null || start === end)
63
+ return apiError(reply, 400, "REQUEST_INVALID");
64
+ const requestedLimit = parseOffset(query.limit);
65
+ return await projectMutations.runConsistentRead(id, () => {
66
+ const source = resolveSourcePath(config, id, filePath);
67
+ if (!fs.existsSync(source) || !fs.statSync(source).isFile())
68
+ return apiError(reply, 404, "FILE_NOT_FOUND", { path: filePath });
69
+ if (fs.statSync(source).size > maxCollaborativeFileBytes(config))
70
+ return apiError(reply, 413, "FILE_TOO_LARGE", { path: filePath });
71
+ const content = fs.readFileSync(source, "utf8");
72
+ if (hashText(content) !== query.sourceHash)
73
+ return apiError(reply, 409, "SELECTION_HISTORY_SOURCE_CHANGED");
74
+ if (start > end || end > content.length)
75
+ return apiError(reply, 400, "REQUEST_INVALID");
76
+ return editHistory.selectionHistory(id, filePath, content, start, end, requestedLimit ?? undefined);
77
+ }, { preflight: () => {
78
+ if (!accessibleProject(db, id, user))
79
+ throw httpError(404, "PROJECT_NOT_FOUND");
80
+ } });
81
+ });
11
82
  app.get("/api/projects/:id/history", async (request, reply) => {
12
83
  const user = requireUser(request, reply, db);
13
84
  if (!user)
@@ -16,10 +87,14 @@ export function registerProjectHistoryRoutes(app, context) {
16
87
  const project = accessibleProject(db, id, user);
17
88
  if (!project)
18
89
  return apiError(reply, 404, "PROJECT_NOT_FOUND");
19
- const { limit: limitInput } = request.query;
20
- const limit = Number.parseInt(limitInput ?? "100", 10);
90
+ const query = request.query;
91
+ if (query.before !== undefined && typeof query.before !== "string")
92
+ return apiError(reply, 400, "REQUEST_INVALID");
93
+ const limit = typeof query.limit === "string" ? Number.parseInt(query.limit, 10) : 100;
94
+ const page = history.listPage(id, Number.isFinite(limit) ? limit : 100, query.before);
21
95
  return {
22
- versions: history.list(id, Number.isFinite(limit) ? limit : 100),
96
+ versions: page.versions,
97
+ nextCursor: page.nextCursor,
23
98
  stats: project.permission === "owner" ? history.stats(id) : null
24
99
  };
25
100
  });
@@ -54,8 +129,10 @@ export function registerProjectHistoryRoutes(app, context) {
54
129
  if (historical === null)
55
130
  return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
56
131
  let comparison = "";
132
+ const previousVersion = query.against === "__previous__" ? history.previousVersion(id, versionId) : null;
133
+ const against = query.against === "__previous__" ? previousVersion?.id ?? "__none__" : query.against;
57
134
  if (query.against) {
58
- comparison = history.readTextFile(id, query.against, filePath) ?? "";
135
+ comparison = history.readTextFile(id, against, filePath) ?? "";
59
136
  }
60
137
  else {
61
138
  const current = resolveSourcePath(config, id, filePath);
@@ -63,7 +140,7 @@ export function registerProjectHistoryRoutes(app, context) {
63
140
  comparison = fs.readFileSync(current, "utf8");
64
141
  }
65
142
  }
66
- return { path: filePath, historical, comparison, against: query.against ?? "current" };
143
+ return { path: filePath, historical, comparison, against: against ?? "current", previousVersion };
67
144
  }, { preflight: () => {
68
145
  if (!accessibleProject(db, id, user))
69
146
  throw httpError(404, "PROJECT_NOT_FOUND");
@@ -78,11 +155,19 @@ export function registerProjectHistoryRoutes(app, context) {
78
155
  if (!project || !canEdit(project))
79
156
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
80
157
  const body = request.body;
81
- const label = body.label === null || body.label === "" ? null : text(body.label, 80);
158
+ if (body?.snapshotHash !== undefined && (typeof body.snapshotHash !== "string" || !/^[a-f0-9]{64}$/.test(body.snapshotHash))) {
159
+ return apiError(reply, 400, "REQUEST_INVALID");
160
+ }
161
+ const snapshotHash = typeof body?.snapshotHash === "string" ? body.snapshotHash : undefined;
162
+ history.assertSnapshotHash(id, versionId, snapshotHash);
163
+ const label = body?.label === null || body?.label === "" ? null : text(body?.label, 80);
82
164
  const version = history.setLabel(id, versionId, label);
83
165
  if (!version)
84
166
  return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
85
- return { version, stats: project.permission === "owner" ? history.stats(id) : null };
167
+ if (!label)
168
+ context.scheduleHistoryRetention(id);
169
+ return { version, retentionScheduled: !label, retentionRefreshAfterMs: !label ? HISTORY_RETENTION_DELAY_MS + 1_000 : 0,
170
+ stats: project.permission === "owner" ? history.stats(id) : null };
86
171
  });
87
172
  app.delete("/api/projects/:id/history/:versionId", async (request, reply) => {
88
173
  const user = requireUser(request, reply, db);
@@ -126,8 +211,15 @@ export function registerProjectHistoryRoutes(app, context) {
126
211
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
127
212
  const body = request.body;
128
213
  const filePath = typeof body?.path === "string" ? safeRelativePath(body.path) : undefined;
214
+ // Current clients bind a restore to the snapshot they displayed. Accept
215
+ // an omitted fingerprint only for already-open clients during an upgrade.
216
+ if (body?.snapshotHash !== undefined && (typeof body.snapshotHash !== "string" || !/^[a-f0-9]{64}$/.test(body.snapshotHash))) {
217
+ return apiError(reply, 400, "REQUEST_INVALID");
218
+ }
219
+ const snapshotHash = typeof body?.snapshotHash === "string" ? body.snapshotHash : undefined;
129
220
  return await projectMutations.runExclusive(id, "history restore", () => {
130
221
  const currentProject = requireEditableProject(db, id, user);
222
+ history.assertSnapshotHash(id, versionId, snapshotHash);
131
223
  recordHistory(id, user.id, "checkpoint");
132
224
  const before = projectTextSnapshot(config, id);
133
225
  const restored = history.restore(id, versionId, filePath);
@@ -148,3 +240,9 @@ export function registerProjectHistoryRoutes(app, context) {
148
240
  } });
149
241
  });
150
242
  }
243
+ function parseOffset(value) {
244
+ if (value === undefined || !/^\d+$/.test(value))
245
+ return null;
246
+ const parsed = Number.parseInt(value, 10);
247
+ return Number.isSafeInteger(parsed) ? parsed : null;
248
+ }
@@ -83,7 +83,7 @@ export function commentsSummaryForProject(db, projectId) {
83
83
  unresolvedCount: Number(row?.unresolved_count) || 0
84
84
  };
85
85
  }
86
- export function projectJson(project, tags = [], commentsSummary) {
86
+ export function projectJson(project, tags = [], commentsSummary, unreadMentionCount = 0) {
87
87
  return {
88
88
  id: project.id,
89
89
  ownerId: project.owner_id,
@@ -101,6 +101,7 @@ export function projectJson(project, tags = [], commentsSummary) {
101
101
  tags,
102
102
  unresolvedCommentCount: commentsSummary?.unresolvedCount ?? 0,
103
103
  commentCount: commentsSummary?.totalCount ?? 0,
104
+ unreadMentionCount,
104
105
  archived: Boolean(project.archived),
105
106
  createdAt: project.created_at,
106
107
  updatedAt: project.updated_at
@@ -10,8 +10,11 @@ import { lucideIconSvg, resolveLucideIconName } from "../lucideIcons.js";
10
10
  import { accessibleProject, canEdit } from "../projects.js";
11
11
  import { writeProjectArchive } from "../archive.js";
12
12
  import { extractProjectZip, ZipValidationError } from "../zip.js";
13
- import { HarperUnavailableError } from "../harper.js";
13
+ import { HarperLintSupersededError, HarperUnavailableError } from "../harper.js";
14
+ import { digestToken } from "../security.js";
15
+ import { unreadMentionCountsForProjects } from "../commentMentions.js";
14
16
  import { commentsSummaryForProject, commentsSummaryForProjects, dictionaryWord, escapeLikePattern, now, projectJson, requireProjectOwnerPermission, tagColors, tagsForProject, tagsForProjects, text } from "./projectShared.js";
17
+ const clientIdPattern = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
15
18
  /** Register project catalog, metadata, archive, dictionary, tag, export, and deletion routes. */
16
19
  export function registerProjectCatalogRoutes(app, context) {
17
20
  const { config, db, collaboration, projectMutations, latexCompletions, projectOutlines, harper, recordHistory } = context;
@@ -172,8 +175,9 @@ export function registerProjectCatalogRoutes(app, context) {
172
175
  const projectIds = projects.map((project) => project.id);
173
176
  const projectTags = tagsForProjects(db, projectIds, user.id);
174
177
  const commentsSummaries = commentsSummaryForProjects(db, projectIds);
178
+ const unreadMentionCounts = unreadMentionCountsForProjects(db, projectIds, user.id);
175
179
  return {
176
- projects: projects.map((project) => projectJson({ ...project, archived: archivedOnly }, projectTags.get(project.id) ?? [], commentsSummaries.get(project.id))),
180
+ projects: projects.map((project) => projectJson({ ...project, archived: archivedOnly }, projectTags.get(project.id) ?? [], commentsSummaries.get(project.id), unreadMentionCounts.get(project.id) ?? 0)),
177
181
  pagination: { page: currentPage, pageSize, total, totalPages }
178
182
  };
179
183
  });
@@ -360,15 +364,45 @@ export function registerProjectCatalogRoutes(app, context) {
360
364
  if (!accessibleProject(db, id, user))
361
365
  return apiError(reply, 404, "PROJECT_NOT_FOUND");
362
366
  const body = request.body;
363
- if (typeof body?.source !== "string" || typeof body.path !== "string")
367
+ const source = body?.source;
368
+ const sourcePath = body?.path;
369
+ const clientId = body?.clientId;
370
+ const sequence = body?.sequence;
371
+ if (typeof source !== "string" || typeof sourcePath !== "string") {
364
372
  return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
365
- if (Buffer.byteLength(body.source, "utf8") > maxCollaborativeFileBytes(config)) {
373
+ }
374
+ // Accept already-open clients during a rolling update. Current clients
375
+ // pair a page-local UUID with a monotonically increasing sequence so a
376
+ // delayed HTTP request cannot replace a newer queued check.
377
+ if (clientId !== undefined && (typeof clientId !== "string" || !clientIdPattern.test(clientId))) {
378
+ return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
379
+ }
380
+ if (sequence !== undefined && (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence <= 0 || clientId === undefined)) {
381
+ return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
382
+ }
383
+ if (Buffer.byteLength(source, "utf8") > maxCollaborativeFileBytes(config)) {
366
384
  return apiError(reply, 413, "SPELLCHECK_SOURCE_TOO_LARGE");
367
385
  }
386
+ let filePath;
368
387
  try {
369
- return { lints: await harper.lint(body.source, body.path) };
388
+ filePath = safeRelativePath(sourcePath);
389
+ }
390
+ catch {
391
+ return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
392
+ }
393
+ // A login session is shared by browser tabs. Pair the authenticated user
394
+ // with the page-local client ID so each new editor can replace only its
395
+ // own obsolete waiting work. Legacy clients retain their former session
396
+ // grouping until their page is refreshed.
397
+ const laneClientId = clientId ?? `legacy:${digestToken(request.cookies.texlite_session ?? "")}`;
398
+ const lane = `${id}\0${user.id}\0${laneClientId}\0${filePath}`;
399
+ try {
400
+ return { lints: await harper.lint(source, filePath, lane, typeof sequence === "number" ? sequence : undefined) };
370
401
  }
371
402
  catch (error) {
403
+ if (error instanceof HarperLintSupersededError) {
404
+ return apiError(reply, 409, "SPELLCHECK_SUPERSEDED");
405
+ }
372
406
  // A missing optional command is an expected fallback condition. Keep it
373
407
  // out of normal logs while preserving diagnostics for an actual failure.
374
408
  if (error instanceof HarperUnavailableError)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "texlite",
3
- "version": "0.8.4",
3
+ "version": "0.9.1",
4
4
  "description": "A lightweight local-first collaborative LaTeX workspace",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -17,9 +17,12 @@
17
17
  "rangeThresholdMB": 5
18
18
  },
19
19
  "history": {
20
- "maxVersions": 200,
20
+ "maxVersions": 0,
21
21
  "maxStorageMB": 128
22
22
  },
23
+ "editHistory": {
24
+ "maxStorageMB": 32
25
+ },
23
26
  "git": {
24
27
  "binary": "git",
25
28
  "operationTimeoutSeconds": 120,