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.
- package/DESIGN.md +75 -8
- package/OPERATIONS.md +19 -2
- package/dist/client/assets/{AdminUsers-CqOmV9rq.js → AdminUsers-Dazar9cj.js} +1 -1
- package/dist/client/assets/{CitationLibraryDialog-BmWFXTW2.js → CitationLibraryDialog-VSUiSPgG.js} +1 -1
- package/dist/client/assets/Dashboard-CoE5ESfb.js +2 -0
- package/dist/client/assets/GitDialog-C9Loz4Al.js +3 -0
- package/dist/client/assets/HistoryDialog-3UNN_jhU.js +7 -0
- package/dist/client/assets/{LatexEditor-DSopAHbC.js → LatexEditor-Bq76v8sO.js} +2 -2
- package/dist/client/assets/{PdfPreview-BDkU20jP.js → PdfPreview-BKhcS-2c.js} +1 -1
- package/dist/client/assets/{ProjectIconPickerDialog-GAd8HGOx.js → ProjectIconPickerDialog-BoWMjQVm.js} +1 -1
- package/dist/client/assets/{ProjectNavigationDialogs-BTLcRY7i.js → ProjectNavigationDialogs-CZKTnxrl.js} +1 -1
- package/dist/client/assets/{ProjectSettings-BJWTkoZq.js → ProjectSettings-DiOQVJ40.js} +1 -1
- package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +27 -0
- package/dist/client/assets/SelectionHistoryDialog-D3dO83lg.js +3 -0
- package/dist/client/assets/{SystemMetricsDialog-C9P4TSQl.js → SystemMetricsDialog-0zCwLtH_.js} +1 -1
- package/dist/client/assets/{TagManagementDialog-LYbib36_.js → TagManagementDialog-C6KDwtvd.js} +1 -1
- package/dist/client/assets/{WordCountDialog-BFg0GeJy.js → WordCountDialog-TwpyAP3m.js} +1 -1
- package/dist/client/assets/check-sZxOOKtS.js +1 -0
- package/dist/client/assets/chevron-left-32SrOwa5.js +1 -0
- package/dist/client/assets/hard-drive-XHSiS_A7.js +1 -0
- package/dist/client/assets/index-BRr3Kuxh.css +1 -0
- package/dist/client/assets/index-BjzELSQ-.js +12 -0
- package/dist/client/assets/index-Cd6-F29b.js +5 -0
- package/dist/client/assets/{key-round-BaIj7Jch.js → key-round-C-CXCWnz.js} +1 -1
- package/dist/client/assets/minimize-2-BMDPVaF-.js +1 -0
- package/dist/client/assets/{minus-Beso_n-6.js → minus-BIa7-KuM.js} +1 -1
- package/dist/client/assets/{plus-BfEC7mzf.js → plus-BKDWy4Ui.js} +1 -1
- package/dist/client/assets/{refresh-cw-FZs9MtoA.js → refresh-cw-Bo7-a7-I.js} +1 -1
- package/dist/client/assets/{search-DL2UGvdj.js → search-Bz9Ob5mT.js} +1 -1
- package/dist/client/assets/{sigma-CSRwPPkk.js → sigma-DxlHf9gv.js} +1 -1
- package/dist/client/assets/spellCheck-BfZEnpwY.js +1 -0
- package/dist/client/assets/{tags-CjkWzXqX.js → tags-IMZehjzE.js} +1 -1
- package/dist/client/assets/{type-D1mmDdLM.js → type-DTWpFQNQ.js} +1 -1
- package/dist/client/assets/{user-plus-YJ3ftuL5.js → user-plus--aVpd8K6.js} +1 -1
- package/dist/client/assets/x-Tbo3aA0J.js +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +45 -4
- package/dist/server/cli.js +4 -0
- package/dist/server/collaboration.js +219 -7
- package/dist/server/commentMentions.js +139 -0
- package/dist/server/config.js +9 -2
- package/dist/server/db.js +59 -0
- package/dist/server/editHistory.js +411 -0
- package/dist/server/editHistoryRetry.js +90 -0
- package/dist/server/harper.js +205 -8
- package/dist/server/history.js +189 -23
- package/dist/server/historyRetention.js +37 -0
- package/dist/server/i18n.js +12 -0
- package/dist/server/index.js +6 -0
- package/dist/server/latexSpellMask.js +197 -43
- package/dist/server/logRotation.js +82 -0
- package/dist/server/pm2.js +7 -0
- package/dist/server/routes/comments.js +120 -17
- package/dist/server/routes/projectHistory.js +106 -8
- package/dist/server/routes/projectShared.js +2 -1
- package/dist/server/routes/projects.js +39 -5
- package/package.json +1 -1
- package/texlite.config.example.json +4 -1
- package/dist/client/assets/Dashboard-BfN2bCgZ.js +0 -2
- package/dist/client/assets/GitDialog-BCfSHuMB.js +0 -3
- package/dist/client/assets/HistoryDialog-BtQhyTr9.js +0 -5
- package/dist/client/assets/ProjectWorkspace-WLYA7BSv.js +0 -27
- package/dist/client/assets/check-bnimQPun.js +0 -1
- package/dist/client/assets/chevron-left-DNM7mhNi.js +0 -1
- package/dist/client/assets/hard-drive-CF3WDfkG.js +0 -1
- package/dist/client/assets/index-CzVDvC5h.js +0 -14
- package/dist/client/assets/index-RjUuWk8F.css +0 -1
- package/dist/client/assets/spellCheck-L9gD2d1M.js +0 -1
- 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.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
.
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
20
|
-
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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