texlite 0.7.5 → 0.7.6
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/README.md +19 -3
- package/README.zh-CN.md +8 -3
- package/dist/client/assets/{GitDialog-pn8n5Afm.js → GitDialog-W3hLOWrF.js} +2 -2
- package/dist/client/assets/HistoryDialog-7ADYJcGi.js +5 -0
- package/dist/client/assets/LatexEditor-ChHQwJXZ.js +59 -0
- package/dist/client/assets/PdfPreview-CuvWphzY.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-CaovMu4V.js → ProjectNavigationDialogs-CzFhIw6B.js} +1 -1
- package/dist/client/assets/SystemMetricsDialog-C0m9JUDy.js +1 -0
- package/dist/client/assets/index-DRShM2ob.js +64 -0
- package/dist/client/assets/index-DzcH7hBu.css +1 -0
- package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
- package/dist/client/assets/minus-BfqMIeUg.js +1 -0
- package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
- package/dist/client/assets/spellCheck-BBnCnxmQ.js +4 -0
- package/dist/client/index.html +3 -3
- package/dist/server/anchors.js +22 -1
- package/dist/server/app.js +103 -2335
- package/dist/server/auth.js +3 -2
- package/dist/server/cli.js +8 -2
- package/dist/server/collaboration.js +250 -2
- package/dist/server/compileArtifacts.js +10 -7
- package/dist/server/compiler.js +9 -4
- package/dist/server/config.js +25 -1
- package/dist/server/environment.js +5 -1
- package/dist/server/files.js +7 -6
- package/dist/server/git.js +28 -39
- package/dist/server/harper.js +144 -0
- package/dist/server/harperRuntime.js +38 -0
- package/dist/server/harperWorker.js +15 -0
- package/dist/server/history.js +11 -9
- package/dist/server/http.js +33 -13
- package/dist/server/i18n.js +276 -0
- package/dist/server/latexRoot.js +78 -0
- package/dist/server/limits.js +9 -0
- package/dist/server/projectSearch.js +3 -4
- package/dist/server/routes/auth.js +77 -0
- package/dist/server/routes/citations.js +331 -0
- package/dist/server/routes/collaboration.js +17 -0
- package/dist/server/routes/comments.js +176 -0
- package/dist/server/routes/compile.js +71 -50
- package/dist/server/routes/projectFiles.js +572 -0
- package/dist/server/routes/projectGit.js +145 -0
- package/dist/server/routes/projectHistory.js +150 -0
- package/dist/server/routes/projectMembers.js +112 -0
- package/dist/server/routes/projectShared.js +238 -0
- package/dist/server/routes/projects.js +473 -0
- package/dist/server/routes/system.js +37 -0
- package/dist/server/routes/users.js +163 -0
- package/dist/server/security.js +2 -4
- package/dist/server/synctex.js +7 -4
- package/dist/server/zip.js +29 -13
- package/package.json +3 -3
- package/texlite.config.example.json +5 -1
- package/dist/client/assets/BinaryModule-Aj1vLnwf-CKY6Y_Pt.js +0 -4
- package/dist/client/assets/HistoryDialog-BpWEZrYf.js +0 -1
- package/dist/client/assets/LatexEditor-DNAmNvHu.js +0 -59
- package/dist/client/assets/PdfPreview-BVaG7TtG.js +0 -1
- package/dist/client/assets/SystemMetricsDialog-D8jj7epG.js +0 -1
- package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/dist/client/assets/binary-BoldOm0z.js +0 -1
- package/dist/client/assets/harper_wasm_bg-LWXDb7Ll.wasm +0 -0
- package/dist/client/assets/index-BQCB3z5H.js +0 -64
- package/dist/client/assets/index-BnvpaWsa.css +0 -1
- package/dist/client/assets/index-Byn2Miy9.js +0 -3456
- package/dist/client/assets/index-rBWMBTI-.js +0 -5
- package/dist/client/assets/minus-K-5IK4nL.js +0 -1
- package/dist/client/assets/spellCheck-C32VQVUE.js +0 -5
- package/dist/client/assets/texFmtRuntime-EYyQNUnJ.js +0 -2
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { requireUser } from "../auth.js";
|
|
3
|
+
import { apiError, httpError, ValidationError } from "../http.js";
|
|
4
|
+
import { MAX_CITATION_BIBTEX_BYTES } from "../limits.js";
|
|
5
|
+
const tagColors = ["red", "orange", "yellow", "green", "blue", "purple", "gray"];
|
|
6
|
+
const now = () => new Date().toISOString();
|
|
7
|
+
function escapeLikePattern(value) {
|
|
8
|
+
return value.replace(/[\\%_]/g, "\\$&");
|
|
9
|
+
}
|
|
10
|
+
function text(value, max = 200) {
|
|
11
|
+
if (typeof value !== "string" || !value.trim() || value.length > max) {
|
|
12
|
+
throw new ValidationError();
|
|
13
|
+
}
|
|
14
|
+
return value.trim();
|
|
15
|
+
}
|
|
16
|
+
function citationJson(row, tags = []) {
|
|
17
|
+
return {
|
|
18
|
+
id: row.id,
|
|
19
|
+
citationKey: row.citation_key,
|
|
20
|
+
entryType: row.entry_type,
|
|
21
|
+
bibtex: row.bibtex,
|
|
22
|
+
title: row.title,
|
|
23
|
+
authors: row.authors,
|
|
24
|
+
year: row.year,
|
|
25
|
+
revision: row.revision,
|
|
26
|
+
tags: tags.map((tag) => ({ id: tag.id, name: tag.name, color: tag.color, ownerId: tag.user_id })),
|
|
27
|
+
ownerId: row.user_id,
|
|
28
|
+
ownerUsername: row.owner_username ?? null,
|
|
29
|
+
ownerDisplayName: row.owner_display_name ?? null,
|
|
30
|
+
createdAt: row.created_at,
|
|
31
|
+
updatedAt: row.updated_at
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function citationTagsForEntries(db, entryIds) {
|
|
35
|
+
const result = new Map(entryIds.map((entryId) => [entryId, []]));
|
|
36
|
+
if (!entryIds.length)
|
|
37
|
+
return result;
|
|
38
|
+
const placeholders = entryIds.map(() => "?").join(", ");
|
|
39
|
+
const rows = db.prepare(`SELECT link.entry_id, tag.id, tag.name, tag.color, tag.user_id
|
|
40
|
+
FROM citation_library_entry_tags link JOIN citation_library_tags tag ON tag.id = link.tag_id
|
|
41
|
+
JOIN citation_library_entries entry ON entry.id = link.entry_id AND entry.user_id = tag.user_id
|
|
42
|
+
WHERE link.entry_id IN (${placeholders})
|
|
43
|
+
ORDER BY tag.name COLLATE NOCASE`).all(...entryIds);
|
|
44
|
+
for (const row of rows)
|
|
45
|
+
result.get(row.entry_id)?.push({ id: row.id, name: row.name, color: row.color, user_id: row.user_id });
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
function citationTagIds(db, userId, value) {
|
|
49
|
+
if (value === undefined)
|
|
50
|
+
return null;
|
|
51
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
|
|
52
|
+
throw new ValidationError();
|
|
53
|
+
const ids = [...new Set(value.map((item) => item.trim()).filter(Boolean))];
|
|
54
|
+
if (ids.length > 100)
|
|
55
|
+
throw new ValidationError();
|
|
56
|
+
if (!ids.length)
|
|
57
|
+
return [];
|
|
58
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
59
|
+
const rows = db.prepare(`SELECT id FROM citation_library_tags WHERE user_id = ? AND id IN (${placeholders})`)
|
|
60
|
+
.all(userId, ...ids);
|
|
61
|
+
if (rows.length !== ids.length)
|
|
62
|
+
throw httpError(404, "CITATION_TAG_NOT_FOUND");
|
|
63
|
+
return ids;
|
|
64
|
+
}
|
|
65
|
+
function citationTagName(value) {
|
|
66
|
+
return text(value, 32);
|
|
67
|
+
}
|
|
68
|
+
function citationExpectedRevision(value) {
|
|
69
|
+
if (!Number.isInteger(value) || Number(value) < 1) {
|
|
70
|
+
throw new ValidationError();
|
|
71
|
+
}
|
|
72
|
+
return Number(value);
|
|
73
|
+
}
|
|
74
|
+
function citationNullableText(value, max) {
|
|
75
|
+
if (value === undefined || value === null || value === "")
|
|
76
|
+
return null;
|
|
77
|
+
if (typeof value !== "string" || value.length > max)
|
|
78
|
+
throw new ValidationError();
|
|
79
|
+
const trimmed = value.trim();
|
|
80
|
+
return trimmed || null;
|
|
81
|
+
}
|
|
82
|
+
function citationInput(value) {
|
|
83
|
+
if (typeof value !== "object" || value === null)
|
|
84
|
+
throw new ValidationError();
|
|
85
|
+
const body = value;
|
|
86
|
+
if (typeof body.bibtex !== "string" || !body.bibtex.trim()) {
|
|
87
|
+
throw new ValidationError();
|
|
88
|
+
}
|
|
89
|
+
if (Buffer.byteLength(body.bibtex, "utf8") > MAX_CITATION_BIBTEX_BYTES) {
|
|
90
|
+
throw httpError(413, "CITATION_TOO_LARGE");
|
|
91
|
+
}
|
|
92
|
+
if (typeof body.citationKey !== "string" || !body.citationKey.trim() || body.citationKey.length > 512) {
|
|
93
|
+
throw new ValidationError();
|
|
94
|
+
}
|
|
95
|
+
if (typeof body.entryType !== "string" || !body.entryType.trim() || body.entryType.length > 128) {
|
|
96
|
+
throw new ValidationError();
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
bibtex: body.bibtex.trim(),
|
|
100
|
+
citationKey: body.citationKey.trim(),
|
|
101
|
+
entryType: body.entryType.trim(),
|
|
102
|
+
title: citationNullableText(body.title, 2048),
|
|
103
|
+
authors: citationNullableText(body.authors, 2048),
|
|
104
|
+
year: citationNullableText(body.year, 128)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** Register the current user's private citation-library routes. */
|
|
108
|
+
export function registerCitationRoutes(app, context) {
|
|
109
|
+
const { db } = context;
|
|
110
|
+
app.get("/api/citations", async (request, reply) => {
|
|
111
|
+
const user = requireUser(request, reply, db);
|
|
112
|
+
if (!user)
|
|
113
|
+
return;
|
|
114
|
+
const query = request.query;
|
|
115
|
+
const search = typeof query.q === "string" ? query.q.trim() : "";
|
|
116
|
+
const tagId = typeof query.tag === "string" ? query.tag.trim() : "";
|
|
117
|
+
const requestedPage = Number.parseInt(query.page ?? "1", 10);
|
|
118
|
+
const requestedPageSize = Number.parseInt(query.pageSize ?? query.limit ?? "60", 10);
|
|
119
|
+
const pageSize = Math.min(200, Math.max(1, Number.isFinite(requestedPageSize) ? requestedPageSize : 60));
|
|
120
|
+
const where = ["entry.user_id = ?"];
|
|
121
|
+
const params = [user.id];
|
|
122
|
+
if (search) {
|
|
123
|
+
where.push(`(
|
|
124
|
+
citation_key LIKE ? ESCAPE '\\' OR entry_type LIKE ? ESCAPE '\\'
|
|
125
|
+
OR COALESCE(title, '') LIKE ? ESCAPE '\\'
|
|
126
|
+
OR COALESCE(authors, '') LIKE ? ESCAPE '\\'
|
|
127
|
+
OR COALESCE(year, '') LIKE ? ESCAPE '\\'
|
|
128
|
+
)`);
|
|
129
|
+
params.push(...Array.from({ length: 5 }, () => `%${escapeLikePattern(search)}%`));
|
|
130
|
+
}
|
|
131
|
+
if (tagId) {
|
|
132
|
+
where.push(`EXISTS (SELECT 1 FROM citation_library_entry_tags filter_link
|
|
133
|
+
JOIN citation_library_tags filter_tag ON filter_tag.id = filter_link.tag_id
|
|
134
|
+
WHERE filter_link.entry_id = entry.id AND filter_link.tag_id = ? AND filter_tag.user_id = ?)`);
|
|
135
|
+
params.push(tagId, user.id);
|
|
136
|
+
}
|
|
137
|
+
const countRow = db.prepare(`SELECT COUNT(*) AS count
|
|
138
|
+
FROM citation_library_entries entry
|
|
139
|
+
WHERE ${where.join(" AND ")}`).get(...params);
|
|
140
|
+
const total = Number(countRow.count) || 0;
|
|
141
|
+
const totalPages = total > 0 ? Math.ceil(total / pageSize) : 0;
|
|
142
|
+
const page = totalPages > 0 ? Math.min(Math.max(1, Number.isFinite(requestedPage) ? requestedPage : 1), totalPages) : 1;
|
|
143
|
+
const offset = (page - 1) * pageSize;
|
|
144
|
+
params.push(pageSize, offset);
|
|
145
|
+
const rows = db.prepare(`SELECT entry.*, owner.username AS owner_username, owner.display_name AS owner_display_name
|
|
146
|
+
FROM citation_library_entries entry
|
|
147
|
+
JOIN users owner ON owner.id = entry.user_id
|
|
148
|
+
WHERE ${where.join(" AND ")} ORDER BY entry.updated_at DESC, entry.citation_key COLLATE NOCASE LIMIT ? OFFSET ?`).all(...params);
|
|
149
|
+
const tags = citationTagsForEntries(db, rows.map((row) => row.id));
|
|
150
|
+
return {
|
|
151
|
+
entries: rows.map((row) => citationJson(row, tags.get(row.id) ?? [])),
|
|
152
|
+
pagination: { page, pageSize, total, totalPages }
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
app.get("/api/citations/tags", async (request, reply) => {
|
|
156
|
+
const user = requireUser(request, reply, db);
|
|
157
|
+
if (!user)
|
|
158
|
+
return;
|
|
159
|
+
const tags = db.prepare(`SELECT tag.id, tag.name, tag.color, tag.user_id AS owner_id
|
|
160
|
+
FROM citation_library_tags tag
|
|
161
|
+
WHERE tag.user_id = ?
|
|
162
|
+
ORDER BY tag.name COLLATE NOCASE`).all(user.id);
|
|
163
|
+
return { tags: tags.map((tag) => ({ id: tag.id, name: tag.name, color: tag.color, ownerId: tag.owner_id })) };
|
|
164
|
+
});
|
|
165
|
+
app.post("/api/citations/tags", async (request, reply) => {
|
|
166
|
+
const user = requireUser(request, reply, db);
|
|
167
|
+
if (!user)
|
|
168
|
+
return;
|
|
169
|
+
const body = request.body;
|
|
170
|
+
const name = citationTagName(body?.name);
|
|
171
|
+
const color = tagColors.includes(body?.color) ? body?.color : "gray";
|
|
172
|
+
const existing = db.prepare("SELECT id, name, color, user_id FROM citation_library_tags WHERE user_id = ? AND name = ? COLLATE NOCASE").get(user.id, name);
|
|
173
|
+
if (existing)
|
|
174
|
+
return { tag: { id: existing.id, name: existing.name, color: existing.color, ownerId: existing.user_id }, created: false };
|
|
175
|
+
const tag = { id: randomUUID(), name, color, ownerId: user.id };
|
|
176
|
+
db.prepare("INSERT INTO citation_library_tags (id, user_id, name, color, created_at) VALUES (?, ?, ?, ?, ?)")
|
|
177
|
+
.run(tag.id, user.id, tag.name, tag.color, now());
|
|
178
|
+
return reply.code(201).send({ tag, created: true });
|
|
179
|
+
});
|
|
180
|
+
app.delete("/api/citations/tags/:tagId", async (request, reply) => {
|
|
181
|
+
const user = requireUser(request, reply, db);
|
|
182
|
+
if (!user)
|
|
183
|
+
return;
|
|
184
|
+
const { tagId } = request.params;
|
|
185
|
+
const result = db.prepare("DELETE FROM citation_library_tags WHERE id = ? AND user_id = ?").run(tagId, user.id);
|
|
186
|
+
if (!result.changes)
|
|
187
|
+
return apiError(reply, 404, "CITATION_TAG_NOT_FOUND");
|
|
188
|
+
return { ok: true };
|
|
189
|
+
});
|
|
190
|
+
// Keep the old settings endpoint explicit so stale clients cannot accidentally
|
|
191
|
+
// reinterpret "settings" as a citation id. Citation libraries are always private.
|
|
192
|
+
app.patch("/api/citations/settings", async (request, reply) => {
|
|
193
|
+
if (!requireUser(request, reply, db))
|
|
194
|
+
return;
|
|
195
|
+
return apiError(reply, 403, "CITATION_LIBRARY_PRIVATE");
|
|
196
|
+
});
|
|
197
|
+
app.post("/api/citations/lookup", async (request, reply) => {
|
|
198
|
+
const user = requireUser(request, reply, db);
|
|
199
|
+
if (!user)
|
|
200
|
+
return;
|
|
201
|
+
const body = request.body;
|
|
202
|
+
if (!Array.isArray(body?.keys) || body.keys.length > 5000
|
|
203
|
+
|| body.keys.some((key) => typeof key !== "string" || !key.trim() || key.length > 512)) {
|
|
204
|
+
throw new ValidationError();
|
|
205
|
+
}
|
|
206
|
+
const keys = [...new Map(body.keys.map((key) => [key.trim().toLowerCase(), key.trim()])).values()];
|
|
207
|
+
const matches = [];
|
|
208
|
+
for (let offset = 0; offset < keys.length; offset += 500) {
|
|
209
|
+
const chunk = keys.slice(offset, offset + 500);
|
|
210
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
211
|
+
matches.push(...db.prepare(`SELECT id, citation_key, revision FROM citation_library_entries
|
|
212
|
+
WHERE user_id = ? AND citation_key COLLATE NOCASE IN (${placeholders})`)
|
|
213
|
+
.all(user.id, ...chunk));
|
|
214
|
+
}
|
|
215
|
+
return { matches: matches.map((match) => ({ id: match.id, citationKey: match.citation_key, revision: match.revision })) };
|
|
216
|
+
});
|
|
217
|
+
app.post("/api/citations", async (request, reply) => {
|
|
218
|
+
const user = requireUser(request, reply, db);
|
|
219
|
+
if (!user)
|
|
220
|
+
return;
|
|
221
|
+
const body = request.body;
|
|
222
|
+
const citation = citationInput(body);
|
|
223
|
+
const tagIds = citationTagIds(db, user.id, body?.tagIds);
|
|
224
|
+
const overwrite = body?.overwrite === true;
|
|
225
|
+
const existing = db.prepare("SELECT id, revision FROM citation_library_entries WHERE user_id = ? AND citation_key = ? COLLATE NOCASE")
|
|
226
|
+
.get(user.id, citation.citationKey);
|
|
227
|
+
if (existing && !overwrite)
|
|
228
|
+
return apiError(reply, 409, "CITATION_KEY_EXISTS");
|
|
229
|
+
const expectedRevision = existing ? citationExpectedRevision(body?.expectedRevision) : null;
|
|
230
|
+
if (existing && existing.revision !== expectedRevision) {
|
|
231
|
+
return apiError(reply, 409, "CITATION_CONFLICT");
|
|
232
|
+
}
|
|
233
|
+
const id = existing?.id ?? randomUUID();
|
|
234
|
+
const timestamp = now();
|
|
235
|
+
db.transaction(() => {
|
|
236
|
+
if (existing) {
|
|
237
|
+
const result = db.prepare(`UPDATE citation_library_entries SET citation_key = ?, entry_type = ?, bibtex = ?,
|
|
238
|
+
title = ?, authors = ?, year = ?, revision = revision + 1, updated_at = ?
|
|
239
|
+
WHERE id = ? AND user_id = ? AND revision = ?`)
|
|
240
|
+
.run(citation.citationKey, citation.entryType, citation.bibtex, citation.title, citation.authors, citation.year, timestamp, id, user.id, expectedRevision);
|
|
241
|
+
if (!result.changes)
|
|
242
|
+
throw httpError(409, "CITATION_CONFLICT");
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
db.prepare(`INSERT INTO citation_library_entries
|
|
246
|
+
(id, user_id, citation_key, entry_type, bibtex, title, authors, year, revision, created_at, updated_at)
|
|
247
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`)
|
|
248
|
+
.run(id, user.id, citation.citationKey, citation.entryType, citation.bibtex, citation.title, citation.authors, citation.year, timestamp, timestamp);
|
|
249
|
+
}
|
|
250
|
+
if (tagIds !== null) {
|
|
251
|
+
db.prepare("DELETE FROM citation_library_entry_tags WHERE entry_id = ?").run(id);
|
|
252
|
+
for (const tagId of tagIds)
|
|
253
|
+
db.prepare("INSERT INTO citation_library_entry_tags (entry_id, tag_id, created_at) VALUES (?, ?, ?)").run(id, tagId, timestamp);
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
const row = db.prepare("SELECT * FROM citation_library_entries WHERE id = ? AND user_id = ?")
|
|
257
|
+
.get(id, user.id);
|
|
258
|
+
const tags = citationTagsForEntries(db, [id]);
|
|
259
|
+
return reply.code(existing ? 200 : 201).send({ entry: citationJson(row, tags.get(id) ?? []), updated: Boolean(existing) });
|
|
260
|
+
});
|
|
261
|
+
app.patch("/api/citations/:citationId/tags", async (request, reply) => {
|
|
262
|
+
const user = requireUser(request, reply, db);
|
|
263
|
+
if (!user)
|
|
264
|
+
return;
|
|
265
|
+
const { citationId } = request.params;
|
|
266
|
+
const existing = db.prepare("SELECT id FROM citation_library_entries WHERE id = ? AND user_id = ?")
|
|
267
|
+
.get(citationId, user.id);
|
|
268
|
+
if (!existing)
|
|
269
|
+
return apiError(reply, 404, "CITATION_NOT_FOUND");
|
|
270
|
+
const body = request.body;
|
|
271
|
+
const tagIds = citationTagIds(db, user.id, body?.tagIds);
|
|
272
|
+
if (tagIds === null)
|
|
273
|
+
throw new ValidationError();
|
|
274
|
+
const expectedRevision = citationExpectedRevision(body?.expectedRevision);
|
|
275
|
+
const timestamp = now();
|
|
276
|
+
db.transaction(() => {
|
|
277
|
+
const result = db.prepare(`UPDATE citation_library_entries SET revision = revision + 1, updated_at = ?
|
|
278
|
+
WHERE id = ? AND user_id = ? AND revision = ?`).run(timestamp, citationId, user.id, expectedRevision);
|
|
279
|
+
if (!result.changes) {
|
|
280
|
+
const exists = db.prepare("SELECT id FROM citation_library_entries WHERE id = ? AND user_id = ?").get(citationId, user.id);
|
|
281
|
+
if (!exists)
|
|
282
|
+
throw httpError(404, "CITATION_NOT_FOUND");
|
|
283
|
+
throw httpError(409, "CITATION_CONFLICT");
|
|
284
|
+
}
|
|
285
|
+
db.prepare("DELETE FROM citation_library_entry_tags WHERE entry_id = ?").run(citationId);
|
|
286
|
+
for (const tagId of tagIds)
|
|
287
|
+
db.prepare("INSERT INTO citation_library_entry_tags (entry_id, tag_id, created_at) VALUES (?, ?, ?)").run(citationId, tagId, timestamp);
|
|
288
|
+
})();
|
|
289
|
+
const row = db.prepare("SELECT * FROM citation_library_entries WHERE id = ? AND user_id = ?")
|
|
290
|
+
.get(citationId, user.id);
|
|
291
|
+
const tags = citationTagsForEntries(db, [citationId]);
|
|
292
|
+
return { entry: citationJson(row, tags.get(citationId) ?? []) };
|
|
293
|
+
});
|
|
294
|
+
app.patch("/api/citations/:citationId", async (request, reply) => {
|
|
295
|
+
const user = requireUser(request, reply, db);
|
|
296
|
+
if (!user)
|
|
297
|
+
return;
|
|
298
|
+
const { citationId } = request.params;
|
|
299
|
+
const existing = db.prepare("SELECT id FROM citation_library_entries WHERE id = ? AND user_id = ?")
|
|
300
|
+
.get(citationId, user.id);
|
|
301
|
+
if (!existing)
|
|
302
|
+
return apiError(reply, 404, "CITATION_NOT_FOUND");
|
|
303
|
+
const body = request.body;
|
|
304
|
+
const citation = citationInput(body);
|
|
305
|
+
const expectedRevision = citationExpectedRevision(body?.expectedRevision);
|
|
306
|
+
const duplicate = db.prepare("SELECT id FROM citation_library_entries WHERE user_id = ? AND citation_key = ? COLLATE NOCASE AND id != ?")
|
|
307
|
+
.get(user.id, citation.citationKey, citationId);
|
|
308
|
+
if (duplicate)
|
|
309
|
+
return apiError(reply, 409, "CITATION_KEY_EXISTS");
|
|
310
|
+
const result = db.prepare(`UPDATE citation_library_entries SET citation_key = ?, entry_type = ?, bibtex = ?,
|
|
311
|
+
title = ?, authors = ?, year = ?, revision = revision + 1, updated_at = ?
|
|
312
|
+
WHERE id = ? AND user_id = ? AND revision = ?`)
|
|
313
|
+
.run(citation.citationKey, citation.entryType, citation.bibtex, citation.title, citation.authors, citation.year, now(), citationId, user.id, expectedRevision);
|
|
314
|
+
if (!result.changes)
|
|
315
|
+
return apiError(reply, 409, "CITATION_CONFLICT");
|
|
316
|
+
const updated = db.prepare("SELECT * FROM citation_library_entries WHERE id = ? AND user_id = ?")
|
|
317
|
+
.get(citationId, user.id);
|
|
318
|
+
const tags = citationTagsForEntries(db, [citationId]);
|
|
319
|
+
return { entry: citationJson(updated, tags.get(citationId) ?? []) };
|
|
320
|
+
});
|
|
321
|
+
app.delete("/api/citations/:citationId", async (request, reply) => {
|
|
322
|
+
const user = requireUser(request, reply, db);
|
|
323
|
+
if (!user)
|
|
324
|
+
return;
|
|
325
|
+
const { citationId } = request.params;
|
|
326
|
+
const result = db.prepare("DELETE FROM citation_library_entries WHERE id = ? AND user_id = ?").run(citationId, user.id);
|
|
327
|
+
if (!result.changes)
|
|
328
|
+
return apiError(reply, 404, "CITATION_NOT_FOUND");
|
|
329
|
+
return { ok: true };
|
|
330
|
+
});
|
|
331
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { currentUser } from "../auth.js";
|
|
3
|
+
/** Register the project-scoped WebSocket endpoint used for live editing. */
|
|
4
|
+
export function registerCollaborationRoutes(app, context) {
|
|
5
|
+
const { db, collaboration, metrics } = context;
|
|
6
|
+
app.get("/api/collaboration/:id", { websocket: true }, (socket, request) => {
|
|
7
|
+
const user = currentUser(request, db);
|
|
8
|
+
if (!user) {
|
|
9
|
+
socket.close(1008, "Authentication required");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const { id } = request.params;
|
|
13
|
+
const startedAt = performance.now();
|
|
14
|
+
void collaboration.connect(socket, id, user)
|
|
15
|
+
.finally(() => metrics.record("collaboration.connect", performance.now() - startedAt));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { requireUser } from "../auth.js";
|
|
4
|
+
import { createSourceAnchor, offsetToLine } from "../anchors.js";
|
|
5
|
+
import { resolveSourcePath, safeRelativePath } from "../files.js";
|
|
6
|
+
import { apiError, httpError } from "../http.js";
|
|
7
|
+
import { accessibleProject } from "../projects.js";
|
|
8
|
+
import { commentsForFile, now, repliesForComment, text } from "./projectShared.js";
|
|
9
|
+
/** Register source-anchored comments and reply routes. */
|
|
10
|
+
export function registerCommentRoutes(app, context) {
|
|
11
|
+
const { config, db, collaboration, projectMutations } = context;
|
|
12
|
+
app.get("/api/projects/:id/comments", async (request, reply) => {
|
|
13
|
+
const user = requireUser(request, reply, db);
|
|
14
|
+
if (!user)
|
|
15
|
+
return;
|
|
16
|
+
const { id } = request.params;
|
|
17
|
+
const { path: filePath } = request.query;
|
|
18
|
+
if (!accessibleProject(db, id, user))
|
|
19
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
20
|
+
const relative = safeRelativePath(filePath ?? "");
|
|
21
|
+
return {
|
|
22
|
+
comments: await projectMutations.runConsistentRead(id, () => commentsForFile(db, config, id, relative), {
|
|
23
|
+
preflight: () => {
|
|
24
|
+
if (!accessibleProject(db, id, user))
|
|
25
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
app.post("/api/projects/:id/comments", async (request, reply) => {
|
|
31
|
+
const user = requireUser(request, reply, db);
|
|
32
|
+
if (!user)
|
|
33
|
+
return;
|
|
34
|
+
const { id } = request.params;
|
|
35
|
+
if (!accessibleProject(db, id, user))
|
|
36
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
37
|
+
const body = request.body;
|
|
38
|
+
const createdAt = now();
|
|
39
|
+
const filePath = safeRelativePath(typeof body.path === "string" ? body.path : "");
|
|
40
|
+
return await projectMutations.runWrite(id, () => {
|
|
41
|
+
const absolute = resolveSourcePath(config, id, filePath);
|
|
42
|
+
if (!fs.existsSync(absolute))
|
|
43
|
+
return apiError(reply, 404, "COMMENT_FILE_NOT_FOUND");
|
|
44
|
+
const source = fs.readFileSync(absolute, "utf8");
|
|
45
|
+
if (!Number.isInteger(body.startOffset) || !Number.isInteger(body.endOffset)) {
|
|
46
|
+
return apiError(reply, 400, "COMMENT_RANGE_INVALID");
|
|
47
|
+
}
|
|
48
|
+
const anchor = createSourceAnchor(source, Number(body.startOffset), Number(body.endOffset));
|
|
49
|
+
const comment = {
|
|
50
|
+
id: randomUUID(), filePath,
|
|
51
|
+
content: text(body.content, 5000)
|
|
52
|
+
};
|
|
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);
|
|
58
|
+
const created = commentsForFile(db, config, id, filePath).find((item) => item.id === comment.id);
|
|
59
|
+
collaboration.signalComments(id);
|
|
60
|
+
return reply.code(201).send({ comment: created });
|
|
61
|
+
}, { preflight: () => {
|
|
62
|
+
if (!accessibleProject(db, id, user))
|
|
63
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
64
|
+
} });
|
|
65
|
+
});
|
|
66
|
+
app.post("/api/projects/:id/comments/:commentId/replies", async (request, reply) => {
|
|
67
|
+
const user = requireUser(request, reply, db);
|
|
68
|
+
if (!user)
|
|
69
|
+
return;
|
|
70
|
+
const { id, commentId } = request.params;
|
|
71
|
+
if (!accessibleProject(db, id, user))
|
|
72
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
73
|
+
if (!db.prepare("SELECT 1 FROM comments WHERE id = ? AND project_id = ?").get(commentId, id)) {
|
|
74
|
+
return apiError(reply, 404, "COMMENT_NOT_FOUND");
|
|
75
|
+
}
|
|
76
|
+
const body = request.body;
|
|
77
|
+
const createdAt = now();
|
|
78
|
+
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);
|
|
82
|
+
const created = repliesForComment(db, commentId).find((item) => item.id === replyId);
|
|
83
|
+
collaboration.signalComments(id);
|
|
84
|
+
return reply.code(201).send({ reply: created });
|
|
85
|
+
});
|
|
86
|
+
app.patch("/api/projects/:id/comments/:commentId", async (request, reply) => {
|
|
87
|
+
const user = requireUser(request, reply, db);
|
|
88
|
+
if (!user)
|
|
89
|
+
return;
|
|
90
|
+
const { id, commentId } = request.params;
|
|
91
|
+
if (!accessibleProject(db, id, user))
|
|
92
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
93
|
+
const comment = db.prepare("SELECT author_id FROM comments WHERE id = ? AND project_id = ?")
|
|
94
|
+
.get(commentId, id);
|
|
95
|
+
if (!comment)
|
|
96
|
+
return apiError(reply, 404, "COMMENT_NOT_FOUND");
|
|
97
|
+
const body = request.body;
|
|
98
|
+
const changedAt = now();
|
|
99
|
+
let changed = false;
|
|
100
|
+
if (typeof body.content === "string") {
|
|
101
|
+
if (comment.author_id !== user.id)
|
|
102
|
+
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
|
+
changed = true;
|
|
106
|
+
}
|
|
107
|
+
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
|
+
changed = true;
|
|
111
|
+
}
|
|
112
|
+
if (!changed)
|
|
113
|
+
return apiError(reply, 400, "COMMENT_UPDATE_EMPTY");
|
|
114
|
+
collaboration.signalComments(id);
|
|
115
|
+
return { ok: true };
|
|
116
|
+
});
|
|
117
|
+
app.delete("/api/projects/:id/comments/:commentId", async (request, reply) => {
|
|
118
|
+
const user = requireUser(request, reply, db);
|
|
119
|
+
if (!user)
|
|
120
|
+
return;
|
|
121
|
+
const { id, commentId } = request.params;
|
|
122
|
+
if (!accessibleProject(db, id, user))
|
|
123
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
124
|
+
const comment = db.prepare("SELECT author_id FROM comments WHERE id = ? AND project_id = ?")
|
|
125
|
+
.get(commentId, id);
|
|
126
|
+
if (!comment)
|
|
127
|
+
return apiError(reply, 404, "COMMENT_NOT_FOUND");
|
|
128
|
+
if (comment.author_id !== user.id)
|
|
129
|
+
return apiError(reply, 403, "COMMENT_DELETE_FORBIDDEN");
|
|
130
|
+
db.prepare("DELETE FROM comments WHERE id = ?").run(commentId);
|
|
131
|
+
collaboration.signalComments(id);
|
|
132
|
+
return { ok: true };
|
|
133
|
+
});
|
|
134
|
+
app.patch("/api/projects/:id/comments/:commentId/replies/:replyId", async (request, reply) => {
|
|
135
|
+
const user = requireUser(request, reply, db);
|
|
136
|
+
if (!user)
|
|
137
|
+
return;
|
|
138
|
+
const { id, commentId, replyId } = request.params;
|
|
139
|
+
if (!accessibleProject(db, id, user))
|
|
140
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
141
|
+
const commentReply = db.prepare(`SELECT reply.author_id FROM comment_replies reply
|
|
142
|
+
JOIN comments comment ON comment.id = reply.comment_id
|
|
143
|
+
WHERE reply.id = ? AND reply.comment_id = ? AND comment.project_id = ?`)
|
|
144
|
+
.get(replyId, commentId, id);
|
|
145
|
+
if (!commentReply)
|
|
146
|
+
return apiError(reply, 404, "REPLY_NOT_FOUND");
|
|
147
|
+
if (commentReply.author_id !== user.id)
|
|
148
|
+
return apiError(reply, 403, "REPLY_EDIT_FORBIDDEN");
|
|
149
|
+
const body = request.body;
|
|
150
|
+
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);
|
|
153
|
+
const updated = repliesForComment(db, commentId).find((item) => item.id === replyId);
|
|
154
|
+
collaboration.signalComments(id);
|
|
155
|
+
return { reply: updated };
|
|
156
|
+
});
|
|
157
|
+
app.delete("/api/projects/:id/comments/:commentId/replies/:replyId", async (request, reply) => {
|
|
158
|
+
const user = requireUser(request, reply, db);
|
|
159
|
+
if (!user)
|
|
160
|
+
return;
|
|
161
|
+
const { id, commentId, replyId } = request.params;
|
|
162
|
+
if (!accessibleProject(db, id, user))
|
|
163
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
164
|
+
const commentReply = db.prepare(`SELECT reply.author_id FROM comment_replies reply
|
|
165
|
+
JOIN comments comment ON comment.id = reply.comment_id
|
|
166
|
+
WHERE reply.id = ? AND reply.comment_id = ? AND comment.project_id = ?`)
|
|
167
|
+
.get(replyId, commentId, id);
|
|
168
|
+
if (!commentReply)
|
|
169
|
+
return apiError(reply, 404, "REPLY_NOT_FOUND");
|
|
170
|
+
if (commentReply.author_id !== user.id)
|
|
171
|
+
return apiError(reply, 403, "REPLY_DELETE_FORBIDDEN");
|
|
172
|
+
db.prepare("DELETE FROM comment_replies WHERE id = ?").run(replyId);
|
|
173
|
+
collaboration.signalComments(id);
|
|
174
|
+
return { ok: true };
|
|
175
|
+
});
|
|
176
|
+
}
|