texlite 0.7.3 → 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.
Files changed (71) hide show
  1. package/README.md +20 -9
  2. package/README.zh-CN.md +9 -9
  3. package/dist/client/assets/{GitDialog-BmRzMv-C.js → GitDialog-W3hLOWrF.js} +2 -2
  4. package/dist/client/assets/HistoryDialog-7ADYJcGi.js +5 -0
  5. package/dist/client/assets/LatexEditor-ChHQwJXZ.js +59 -0
  6. package/dist/client/assets/PdfPreview-CuvWphzY.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-BVQb4qdD.js → ProjectNavigationDialogs-CzFhIw6B.js} +1 -1
  8. package/dist/client/assets/SystemMetricsDialog-C0m9JUDy.js +1 -0
  9. package/dist/client/assets/index-DRShM2ob.js +64 -0
  10. package/dist/client/assets/index-DzcH7hBu.css +1 -0
  11. package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
  12. package/dist/client/assets/minus-BfqMIeUg.js +1 -0
  13. package/dist/client/assets/pdf-BB1XvBIF.js +55 -0
  14. package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
  15. package/dist/client/assets/spellCheck-BBnCnxmQ.js +4 -0
  16. package/dist/client/assets/tex_fmt_bg-VEAUIuFz.wasm +0 -0
  17. package/dist/client/index.html +3 -3
  18. package/dist/server/anchors.js +22 -1
  19. package/dist/server/app.js +103 -2375
  20. package/dist/server/auth.js +3 -2
  21. package/dist/server/cli.js +8 -2
  22. package/dist/server/collaboration.js +250 -2
  23. package/dist/server/compileArtifacts.js +10 -7
  24. package/dist/server/compiler.js +9 -4
  25. package/dist/server/config.js +25 -1
  26. package/dist/server/environment.js +5 -1
  27. package/dist/server/files.js +7 -6
  28. package/dist/server/git.js +28 -39
  29. package/dist/server/harper.js +144 -0
  30. package/dist/server/harperRuntime.js +38 -0
  31. package/dist/server/harperWorker.js +15 -0
  32. package/dist/server/history.js +11 -9
  33. package/dist/server/http.js +33 -13
  34. package/dist/server/i18n.js +276 -0
  35. package/dist/server/latexRoot.js +78 -0
  36. package/dist/server/limits.js +9 -0
  37. package/dist/server/projectSearch.js +3 -4
  38. package/dist/server/routes/auth.js +77 -0
  39. package/dist/server/routes/citations.js +331 -0
  40. package/dist/server/routes/collaboration.js +17 -0
  41. package/dist/server/routes/comments.js +176 -0
  42. package/dist/server/routes/compile.js +71 -50
  43. package/dist/server/routes/projectFiles.js +572 -0
  44. package/dist/server/routes/projectGit.js +145 -0
  45. package/dist/server/routes/projectHistory.js +150 -0
  46. package/dist/server/routes/projectMembers.js +112 -0
  47. package/dist/server/routes/projectShared.js +238 -0
  48. package/dist/server/routes/projects.js +473 -0
  49. package/dist/server/routes/system.js +37 -0
  50. package/dist/server/routes/users.js +163 -0
  51. package/dist/server/security.js +2 -4
  52. package/dist/server/synctex.js +7 -4
  53. package/dist/server/zip.js +29 -13
  54. package/package.json +4 -2
  55. package/texlite.config.example.json +5 -1
  56. package/dist/client/assets/BinaryModule-Aj1vLnwf-_qIOaefD.js +0 -4
  57. package/dist/client/assets/HistoryDialog-V0IkSDtJ.js +0 -1
  58. package/dist/client/assets/LatexEditor-CokkLLxi.js +0 -59
  59. package/dist/client/assets/PdfPreview-Cs1WYPsC.js +0 -1
  60. package/dist/client/assets/SystemMetricsDialog-DBrendnp.js +0 -1
  61. package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  62. package/dist/client/assets/binary-BQKEW09b.js +0 -1
  63. package/dist/client/assets/harper_wasm_bg-LWXDb7Ll.wasm +0 -0
  64. package/dist/client/assets/index-BS1IMOaB.js +0 -62
  65. package/dist/client/assets/index-Lw53KTaT.css +0 -1
  66. package/dist/client/assets/index-QyVe7I1s.js +0 -3456
  67. package/dist/client/assets/index-rBWMBTI-.js +0 -5
  68. package/dist/client/assets/minus-yEV4SLvG.js +0 -1
  69. package/dist/client/assets/pdf-DPnwNQHb.js +0 -55
  70. package/dist/client/assets/spellCheck-OZGa6ljx.js +0 -5
  71. package/dist/server/latexFormatter.js +0 -118
@@ -0,0 +1,145 @@
1
+ import { requireUser } from "../auth.js";
2
+ import { apiError, httpError } from "../http.js";
3
+ import { accessibleProject } from "../projects.js";
4
+ import { projectTextSnapshot, reanchorProjectSnapshot, requireActiveUser, touchProject } from "./projectShared.js";
5
+ /** Register owner-only local Git and GitHub backup routes. */
6
+ export function registerProjectGitRoutes(app, context) {
7
+ const { config, db, collaboration, projectMutations, projectGit, recordHistory } = context;
8
+ const requireGitOwner = (projectId, user) => {
9
+ requireActiveUser(db, user);
10
+ const project = accessibleProject(db, projectId, user);
11
+ if (!project)
12
+ throw httpError(404, "PROJECT_NOT_FOUND");
13
+ if (project.owner_id !== user.id)
14
+ throw httpError(403, "PROJECT_OWNER_ONLY");
15
+ return project;
16
+ };
17
+ app.get("/api/projects/:id/git", async (request, reply) => {
18
+ const user = requireUser(request, reply, db);
19
+ if (!user)
20
+ return;
21
+ const { id } = request.params;
22
+ return await projectMutations.runConsistentRead(id, async () => {
23
+ return { status: await projectGit.status(requireGitOwner(id, user)) };
24
+ }, { preflight: () => { requireGitOwner(id, user); } });
25
+ });
26
+ app.put("/api/projects/:id/git/token", async (request, reply) => {
27
+ const user = requireUser(request, reply, db);
28
+ if (!user)
29
+ return;
30
+ const { id } = request.params;
31
+ const body = request.body;
32
+ if (typeof body.token !== "string")
33
+ return apiError(reply, 400, "GIT_TOKEN_INVALID");
34
+ const token = body.token;
35
+ return await projectMutations.runSerialized(id, async () => {
36
+ return { status: await projectGit.configureToken(requireGitOwner(id, user), token) };
37
+ }, { preflight: () => { requireGitOwner(id, user); } });
38
+ });
39
+ app.delete("/api/projects/:id/git/token", async (request, reply) => {
40
+ const user = requireUser(request, reply, db);
41
+ if (!user)
42
+ return;
43
+ const { id } = request.params;
44
+ return await projectMutations.runSerialized(id, async () => {
45
+ return { status: await projectGit.removeToken(requireGitOwner(id, user)) };
46
+ }, { preflight: () => { requireGitOwner(id, user); } });
47
+ });
48
+ app.post("/api/projects/:id/git/repository", async (request, reply) => {
49
+ const user = requireUser(request, reply, db);
50
+ if (!user)
51
+ return;
52
+ const { id } = request.params;
53
+ const body = request.body;
54
+ if (typeof body.name !== "string")
55
+ return apiError(reply, 400, "GIT_REPOSITORY_NAME_INVALID");
56
+ const repositoryName = body.name.trim();
57
+ return await projectMutations.runSerialized(id, async () => {
58
+ return { status: await projectGit.createGitHubRepository(requireGitOwner(id, user), repositoryName, body.private !== false) };
59
+ }, { preflight: () => { requireGitOwner(id, user); } });
60
+ });
61
+ app.post("/api/projects/:id/git/commit", async (request, reply) => {
62
+ const user = requireUser(request, reply, db);
63
+ if (!user)
64
+ return;
65
+ const { id } = request.params;
66
+ if (collaboration.isMaintaining(id))
67
+ return apiError(reply, 409, "PROJECT_BUSY");
68
+ const body = request.body;
69
+ return await projectMutations.runExclusive(id, "Git commit", async () => {
70
+ const project = requireGitOwner(id, user);
71
+ const commit = await projectGit.commit(project, user, typeof body.message === "string" ? body.message : "");
72
+ return reply.code(201).send({ commit, status: await projectGit.status(project) });
73
+ }, { preflight: () => { requireGitOwner(id, user); } });
74
+ });
75
+ app.post("/api/projects/:id/git/push", async (request, reply) => {
76
+ const user = requireUser(request, reply, db);
77
+ if (!user)
78
+ return;
79
+ const { id } = request.params;
80
+ if (collaboration.isMaintaining(id))
81
+ return apiError(reply, 409, "PROJECT_BUSY");
82
+ return await projectMutations.runSerialized(id, async () => {
83
+ const project = requireGitOwner(id, user);
84
+ return { status: await projectGit.push(project) };
85
+ }, { flush: false, preflight: () => { requireGitOwner(id, user); } });
86
+ });
87
+ app.get("/api/projects/:id/git/history", async (request, reply) => {
88
+ const user = requireUser(request, reply, db);
89
+ if (!user)
90
+ return;
91
+ const { id } = request.params;
92
+ return await projectMutations.runConsistentRead(id, async () => {
93
+ return { commits: await projectGit.history(requireGitOwner(id, user)) };
94
+ }, { preflight: () => { requireGitOwner(id, user); } });
95
+ });
96
+ app.get("/api/projects/:id/git/diff", async (request, reply) => {
97
+ const user = requireUser(request, reply, db);
98
+ if (!user)
99
+ return;
100
+ const { id } = request.params;
101
+ const { revision } = request.query;
102
+ return await projectMutations.runConsistentRead(id, async () => {
103
+ const project = requireGitOwner(id, user);
104
+ return projectGit.diff(project, revision);
105
+ }, { preflight: () => { requireGitOwner(id, user); } });
106
+ });
107
+ app.post("/api/projects/:id/git/checkout", async (request, reply) => {
108
+ const user = requireUser(request, reply, db);
109
+ if (!user)
110
+ return;
111
+ const { id } = request.params;
112
+ requireGitOwner(id, user);
113
+ const body = request.body;
114
+ if (body.revision !== null && typeof body.revision !== "string")
115
+ return apiError(reply, 400, "GIT_REVISION_INVALID");
116
+ const revisionInput = body.revision === null ? null : body.revision;
117
+ return await projectMutations.runExclusive(id, "Git checkout", async () => {
118
+ const currentProject = requireGitOwner(id, user);
119
+ recordHistory(id, user.id, "checkpoint");
120
+ const before = projectTextSnapshot(config, id);
121
+ const revision = await projectGit.checkout(currentProject, revisionInput, body.force === true);
122
+ reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
123
+ touchProject(db, id, user.id);
124
+ recordHistory(id, user.id, "git");
125
+ return { revision, status: await projectGit.status(currentProject) };
126
+ }, { preflight: () => { requireGitOwner(id, user); } });
127
+ });
128
+ app.post("/api/projects/:id/git/discard", async (request, reply) => {
129
+ const user = requireUser(request, reply, db);
130
+ if (!user)
131
+ return;
132
+ const { id } = request.params;
133
+ requireGitOwner(id, user);
134
+ return await projectMutations.runExclusive(id, "Git restore", async () => {
135
+ const currentProject = requireGitOwner(id, user);
136
+ recordHistory(id, user.id, "checkpoint");
137
+ const before = projectTextSnapshot(config, id);
138
+ await projectGit.discardChanges(currentProject);
139
+ reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
140
+ touchProject(db, id, user.id);
141
+ recordHistory(id, user.id, "git");
142
+ return { status: await projectGit.status(currentProject) };
143
+ }, { preflight: () => { requireGitOwner(id, user); } });
144
+ });
145
+ }
@@ -0,0 +1,150 @@
1
+ import fs from "node:fs";
2
+ import { requireUser } from "../auth.js";
3
+ import { resolveSourcePath, safeRelativePath } from "../files.js";
4
+ import { apiError, httpError } from "../http.js";
5
+ import { MAX_TEXT_PREVIEW_BYTES } from "../limits.js";
6
+ import { accessibleProject, canEdit } from "../projects.js";
7
+ import { commentsSummaryForProject, projectJson, projectTextSnapshot, reanchorProjectSnapshot, requireEditableProject, requireProjectOwnerPermission, tagsForProject, text, touchProject } from "./projectShared.js";
8
+ /** Register retained project version listing, inspection, restoration, and cleanup routes. */
9
+ export function registerProjectHistoryRoutes(app, context) {
10
+ const { config, db, history, projectMutations, recordHistory } = context;
11
+ app.get("/api/projects/:id/history", async (request, reply) => {
12
+ const user = requireUser(request, reply, db);
13
+ if (!user)
14
+ return;
15
+ const { id } = request.params;
16
+ const project = accessibleProject(db, id, user);
17
+ if (!project)
18
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
19
+ const { limit: limitInput } = request.query;
20
+ const limit = Number.parseInt(limitInput ?? "100", 10);
21
+ return {
22
+ versions: history.list(id, Number.isFinite(limit) ? limit : 100),
23
+ stats: project.permission === "owner" ? history.stats(id) : null
24
+ };
25
+ });
26
+ app.get("/api/projects/:id/history/:versionId", async (request, reply) => {
27
+ const user = requireUser(request, reply, db);
28
+ if (!user)
29
+ return;
30
+ const { id, versionId } = request.params;
31
+ if (!accessibleProject(db, id, user))
32
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
33
+ const version = history.version(versionId, id);
34
+ const manifest = history.manifest(id, versionId);
35
+ if (!version || !manifest)
36
+ return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
37
+ return {
38
+ version,
39
+ settings: manifest.settings,
40
+ files: Object.entries(manifest.files).map(([filePath, file]) => ({ path: filePath, size: file.size })).sort((left, right) => left.path.localeCompare(right.path))
41
+ };
42
+ });
43
+ app.get("/api/projects/:id/history/:versionId/file", async (request, reply) => {
44
+ const user = requireUser(request, reply, db);
45
+ if (!user)
46
+ return;
47
+ const { id, versionId } = request.params;
48
+ if (!accessibleProject(db, id, user))
49
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
50
+ const query = request.query;
51
+ const filePath = safeRelativePath(query.path ?? "");
52
+ return await projectMutations.runConsistentRead(id, () => {
53
+ const historical = history.readTextFile(id, versionId, filePath);
54
+ if (historical === null)
55
+ return apiError(reply, 415, "HISTORY_FILE_PREVIEW_UNSUPPORTED", { path: filePath });
56
+ let comparison = "";
57
+ if (query.against) {
58
+ comparison = history.readTextFile(id, query.against, filePath) ?? "";
59
+ }
60
+ else {
61
+ const current = resolveSourcePath(config, id, filePath);
62
+ if (fs.existsSync(current) && fs.statSync(current).isFile() && fs.statSync(current).size <= MAX_TEXT_PREVIEW_BYTES) {
63
+ comparison = fs.readFileSync(current, "utf8");
64
+ }
65
+ }
66
+ return { path: filePath, historical, comparison, against: query.against ?? "current" };
67
+ }, { preflight: () => {
68
+ if (!accessibleProject(db, id, user))
69
+ throw httpError(404, "PROJECT_NOT_FOUND");
70
+ } });
71
+ });
72
+ app.patch("/api/projects/:id/history/:versionId", async (request, reply) => {
73
+ const user = requireUser(request, reply, db);
74
+ if (!user)
75
+ return;
76
+ const { id, versionId } = request.params;
77
+ const project = accessibleProject(db, id, user);
78
+ if (!project || !canEdit(project))
79
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
80
+ const body = request.body;
81
+ const label = body.label === null || body.label === "" ? null : text(body.label, 80);
82
+ const version = history.setLabel(id, versionId, label);
83
+ if (!version)
84
+ return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
85
+ return { version, stats: project.permission === "owner" ? history.stats(id) : null };
86
+ });
87
+ app.delete("/api/projects/:id/history/:versionId", async (request, reply) => {
88
+ const user = requireUser(request, reply, db);
89
+ if (!user)
90
+ return;
91
+ const { id, versionId } = request.params;
92
+ const project = accessibleProject(db, id, user);
93
+ if (!project || project.permission !== "owner")
94
+ return apiError(reply, 403, "PROJECT_OWNER_ONLY");
95
+ return await projectMutations.runWrite(id, () => {
96
+ if (!history.deleteVersion(id, versionId))
97
+ return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
98
+ return { ok: true, stats: history.stats(id) };
99
+ }, { preflight: () => {
100
+ requireProjectOwnerPermission(db, id, user);
101
+ if (!history.version(versionId, id)) {
102
+ throw httpError(404, "HISTORY_VERSION_NOT_FOUND");
103
+ }
104
+ } });
105
+ });
106
+ app.delete("/api/projects/:id/history", async (request, reply) => {
107
+ const user = requireUser(request, reply, db);
108
+ if (!user)
109
+ return;
110
+ const { id } = request.params;
111
+ const project = accessibleProject(db, id, user);
112
+ if (!project || project.permission !== "owner")
113
+ return apiError(reply, 403, "PROJECT_OWNER_ONLY");
114
+ return await projectMutations.runWrite(id, () => {
115
+ history.clear(id);
116
+ return { ok: true, stats: history.stats(id) };
117
+ }, { preflight: () => { requireProjectOwnerPermission(db, id, user); } });
118
+ });
119
+ app.post("/api/projects/:id/history/:versionId/restore", async (request, reply) => {
120
+ const user = requireUser(request, reply, db);
121
+ if (!user)
122
+ return;
123
+ const { id, versionId } = request.params;
124
+ const project = accessibleProject(db, id, user);
125
+ if (!project || !canEdit(project))
126
+ return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
127
+ const body = request.body;
128
+ const filePath = typeof body?.path === "string" ? safeRelativePath(body.path) : undefined;
129
+ return await projectMutations.runExclusive(id, "history restore", () => {
130
+ const currentProject = requireEditableProject(db, id, user);
131
+ recordHistory(id, user.id, "checkpoint");
132
+ const before = projectTextSnapshot(config, id);
133
+ const restored = history.restore(id, versionId, filePath);
134
+ reanchorProjectSnapshot(db, id, before, projectTextSnapshot(config, id));
135
+ touchProject(db, id, user.id);
136
+ recordHistory(id, user.id, "restore", filePath ? [filePath] : undefined);
137
+ return {
138
+ ok: true,
139
+ restoredPaths: restored.restoredPaths,
140
+ project: projectJson(accessibleProject(db, id, user) ?? currentProject, tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
141
+ };
142
+ }, { preflight: () => {
143
+ requireEditableProject(db, id, user);
144
+ if (!history.version(versionId, id)) {
145
+ throw httpError(404, "HISTORY_VERSION_NOT_FOUND");
146
+ }
147
+ history.validateRestoreTarget(id, versionId, filePath);
148
+ } });
149
+ });
150
+ }
@@ -0,0 +1,112 @@
1
+ import { requireUser } from "../auth.js";
2
+ import { apiError, httpError } from "../http.js";
3
+ import { accessibleProject } from "../projects.js";
4
+ import { commentsSummaryForProject, now, projectJson, requireActualProjectOwner, tagsForProject, touchProject } from "./projectShared.js";
5
+ /** Register project sharing, member permission, and ownership-transfer routes. */
6
+ export function registerProjectMemberRoutes(app, context) {
7
+ const { db, collaboration, projectMutations } = context;
8
+ app.get("/api/projects/:id/members", async (request, reply) => {
9
+ const user = requireUser(request, reply, db);
10
+ if (!user)
11
+ return;
12
+ const { id } = request.params;
13
+ if (!accessibleProject(db, id, user))
14
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
15
+ const members = db.prepare(`SELECT pm.user_id AS id, u.username, u.display_name AS displayName, pm.permission
16
+ FROM project_members pm JOIN users u ON u.id = pm.user_id WHERE pm.project_id = ? ORDER BY u.username`).all(id);
17
+ return { members };
18
+ });
19
+ app.put("/api/projects/:id/owner", async (request, reply) => {
20
+ const user = requireUser(request, reply, db);
21
+ if (!user)
22
+ return;
23
+ const { id } = request.params;
24
+ const project = accessibleProject(db, id, user);
25
+ if (!project || project.owner_id !== user.id) {
26
+ return apiError(reply, 403, "PROJECT_TRANSFER_FORBIDDEN");
27
+ }
28
+ const body = (request.body ?? {});
29
+ if (typeof body.userId !== "string" || !body.userId) {
30
+ return apiError(reply, 400, "PROJECT_TRANSFER_TARGET_INVALID");
31
+ }
32
+ if (body.userId === user.id) {
33
+ return apiError(reply, 400, "PROJECT_TRANSFER_SELF");
34
+ }
35
+ const target = db.prepare("SELECT id FROM users WHERE id = ? AND disabled = 0").get(body.userId);
36
+ if (!target)
37
+ return apiError(reply, 404, "USER_NOT_FOUND");
38
+ return await projectMutations.runExclusive(id, "project transfer", () => {
39
+ const currentTarget = db.prepare("SELECT id FROM users WHERE id = ? AND disabled = 0").get(body.userId);
40
+ // The synchronous preflight immediately precedes maintenance, so this
41
+ // lookup cannot change before the operation starts.
42
+ if (!currentTarget)
43
+ throw httpError(404, "USER_NOT_FOUND");
44
+ const changedAt = now();
45
+ db.exec("BEGIN IMMEDIATE");
46
+ try {
47
+ // The new owner may already be a shared member. The previous owner keeps
48
+ // edit access so a transfer does not unexpectedly lock them out.
49
+ db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, currentTarget.id);
50
+ db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at)
51
+ VALUES (?, ?, 'edit', ?)
52
+ ON CONFLICT(project_id, user_id) DO UPDATE SET permission = 'edit'`)
53
+ .run(id, user.id, changedAt);
54
+ db.prepare("UPDATE projects SET owner_id = ?, last_modified_by = ?, updated_at = ? WHERE id = ?")
55
+ .run(currentTarget.id, user.id, changedAt, id);
56
+ // A project token belongs to the account that supplied it. Preserve the
57
+ // local repository/remote metadata, but require the new owner to add
58
+ // their own credential before the next GitHub operation.
59
+ db.prepare("UPDATE project_git_settings SET token_ciphertext = NULL, github_login = NULL, updated_at = ? WHERE project_id = ?")
60
+ .run(changedAt, id);
61
+ db.exec("COMMIT");
62
+ }
63
+ catch (error) {
64
+ db.exec("ROLLBACK");
65
+ throw error;
66
+ }
67
+ return {
68
+ project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
69
+ };
70
+ }, { preflight: () => {
71
+ requireActualProjectOwner(db, id, user);
72
+ if (!db.prepare("SELECT 1 FROM users WHERE id = ? AND disabled = 0").get(body.userId)) {
73
+ throw httpError(404, "USER_NOT_FOUND");
74
+ }
75
+ } });
76
+ });
77
+ app.put("/api/projects/:id/members/:userId", async (request, reply) => {
78
+ const user = requireUser(request, reply, db);
79
+ if (!user)
80
+ return;
81
+ const { id, userId } = request.params;
82
+ const project = accessibleProject(db, id, user);
83
+ if (!project || project.permission !== "owner")
84
+ return apiError(reply, 403, "MEMBERS_MANAGE_FORBIDDEN");
85
+ if (userId === project.owner_id)
86
+ return apiError(reply, 400, "OWNER_MEMBER_FORBIDDEN");
87
+ if (!db.prepare("SELECT 1 FROM users WHERE id = ? AND disabled = 0").get(userId)) {
88
+ return apiError(reply, 404, "USER_NOT_FOUND");
89
+ }
90
+ const body = request.body;
91
+ const permission = body.permission === "edit" ? "edit" : "read";
92
+ db.prepare(`INSERT INTO project_members (project_id, user_id, permission, created_at) VALUES (?, ?, ?, ?)
93
+ ON CONFLICT(project_id, user_id) DO UPDATE SET permission = excluded.permission`)
94
+ .run(id, userId, permission, now());
95
+ touchProject(db, id, user.id);
96
+ collaboration.notifyPermissionChanged(id, userId, permission);
97
+ return { ok: true };
98
+ });
99
+ app.delete("/api/projects/:id/members/:userId", async (request, reply) => {
100
+ const user = requireUser(request, reply, db);
101
+ if (!user)
102
+ return;
103
+ const { id, userId } = request.params;
104
+ const project = accessibleProject(db, id, user);
105
+ if (!project || project.permission !== "owner")
106
+ return apiError(reply, 403, "MEMBERS_MANAGE_FORBIDDEN");
107
+ db.prepare("DELETE FROM project_members WHERE project_id = ? AND user_id = ?").run(id, userId);
108
+ touchProject(db, id, user.id);
109
+ collaboration.notifyPermissionChanged(id, userId, "revoked");
110
+ return { ok: true };
111
+ });
112
+ }
@@ -0,0 +1,238 @@
1
+ import fs from "node:fs";
2
+ import { reanchorFileComments, offsetToLine } from "../anchors.js";
3
+ import { maxCollaborativeFileBytes } from "../collaboration.js";
4
+ import { listProjectFiles, resolveSourcePath } from "../files.js";
5
+ import { httpError, ValidationError } from "../http.js";
6
+ import { accessibleProject, canEdit } from "../projects.js";
7
+ export const now = () => new Date().toISOString();
8
+ export function escapeLikePattern(value) {
9
+ return value.replace(/[\\%_]/g, "\\$&");
10
+ }
11
+ export function escapeGlobPattern(value) {
12
+ return value.replace(/[*?[]/g, "[$&]");
13
+ }
14
+ export function text(value, max = 200) {
15
+ if (typeof value !== "string" || !value.trim() || value.length > max) {
16
+ throw new ValidationError();
17
+ }
18
+ return value.trim();
19
+ }
20
+ export function dictionaryWord(value) {
21
+ if (typeof value !== "string")
22
+ throw new ValidationError();
23
+ const word = value.trim();
24
+ if (!word || word.length > 64 || /[\s\\{}$%]/u.test(word))
25
+ throw new ValidationError();
26
+ return word;
27
+ }
28
+ export const tagColors = ["red", "orange", "yellow", "green", "blue", "purple", "gray"];
29
+ export function tagsForProject(db, projectId, userId) {
30
+ return db.prepare(`SELECT tag.id, tag.name, tag.color
31
+ FROM user_tags tag JOIN user_project_tag_links link ON link.tag_id = tag.id
32
+ WHERE link.project_id = ? AND tag.user_id = ? ORDER BY tag.name COLLATE NOCASE`)
33
+ .all(projectId, userId);
34
+ }
35
+ export function tagsForProjects(db, projectIds, userId) {
36
+ const result = new Map(projectIds.map((projectId) => [projectId, []]));
37
+ for (let offset = 0; offset < projectIds.length; offset += 500) {
38
+ const chunk = projectIds.slice(offset, offset + 500);
39
+ const placeholders = chunk.map(() => "?").join(", ");
40
+ const rows = db.prepare(`SELECT link.project_id, tag.id, tag.name, tag.color
41
+ FROM user_project_tag_links link JOIN user_tags tag ON tag.id = link.tag_id
42
+ WHERE tag.user_id = ? AND link.project_id IN (${placeholders})
43
+ ORDER BY tag.name COLLATE NOCASE`).all(userId, ...chunk);
44
+ for (const row of rows)
45
+ result.get(row.project_id)?.push({ id: row.id, name: row.name, color: row.color });
46
+ }
47
+ return result;
48
+ }
49
+ export function commentsSummaryForProjects(db, projectIds) {
50
+ const result = new Map();
51
+ if (!projectIds.length)
52
+ return result;
53
+ for (let index = 0; index < projectIds.length; index += 100) {
54
+ const chunk = projectIds.slice(index, index + 100);
55
+ const placeholders = chunk.map(() => "?").join(", ");
56
+ const rows = db.prepare(`
57
+ SELECT project_id,
58
+ COUNT(*) AS total_count,
59
+ SUM(CASE WHEN resolved = 0 THEN 1 ELSE 0 END) AS unresolved_count
60
+ FROM comments
61
+ WHERE project_id IN (${placeholders})
62
+ GROUP BY project_id
63
+ `).all(...chunk);
64
+ for (const row of rows) {
65
+ result.set(row.project_id, {
66
+ totalCount: Number(row.total_count) || 0,
67
+ unresolvedCount: Number(row.unresolved_count) || 0
68
+ });
69
+ }
70
+ }
71
+ return result;
72
+ }
73
+ export function commentsSummaryForProject(db, projectId) {
74
+ const row = db.prepare(`
75
+ SELECT
76
+ COUNT(*) AS total_count,
77
+ SUM(CASE WHEN resolved = 0 THEN 1 ELSE 0 END) AS unresolved_count
78
+ FROM comments
79
+ WHERE project_id = ?
80
+ `).get(projectId);
81
+ return {
82
+ totalCount: Number(row?.total_count) || 0,
83
+ unresolvedCount: Number(row?.unresolved_count) || 0
84
+ };
85
+ }
86
+ export function projectJson(project, tags = [], commentsSummary) {
87
+ return {
88
+ id: project.id,
89
+ ownerId: project.owner_id,
90
+ ownerUsername: project.owner_username,
91
+ ownerDisplayName: project.owner_display_name,
92
+ lastModifiedBy: project.last_modified_by,
93
+ lastModifiedUsername: project.last_modified_username,
94
+ lastModifiedDisplayName: project.last_modified_display_name,
95
+ name: project.name,
96
+ mainFile: project.main_file,
97
+ latexmkrc: project.latexmkrc,
98
+ engine: project.engine,
99
+ permission: project.permission,
100
+ tags,
101
+ unresolvedCommentCount: commentsSummary?.unresolvedCount ?? 0,
102
+ commentCount: commentsSummary?.totalCount ?? 0,
103
+ archived: Boolean(project.archived),
104
+ createdAt: project.created_at,
105
+ updatedAt: project.updated_at
106
+ };
107
+ }
108
+ export function touchProject(db, projectId, userId) {
109
+ db.prepare("UPDATE projects SET updated_at = ?, last_modified_by = ? WHERE id = ?")
110
+ .run(now(), userId, projectId);
111
+ }
112
+ export function requireActiveUser(db, user) {
113
+ const current = db.prepare("SELECT disabled FROM users WHERE id = ?").get(user.id);
114
+ if (!current || current.disabled) {
115
+ throw httpError(401, "AUTH_REQUIRED");
116
+ }
117
+ }
118
+ /**
119
+ * Authorization must be checked again after a queued mutation acquires its
120
+ * project lock. A member can be revoked, or ownership can be transferred,
121
+ * while the request is waiting behind another filesystem operation.
122
+ */
123
+ export function requireEditableProject(db, projectId, user) {
124
+ requireActiveUser(db, user);
125
+ const project = accessibleProject(db, projectId, user);
126
+ if (!project)
127
+ throw httpError(404, "PROJECT_NOT_FOUND");
128
+ if (!canEdit(project))
129
+ throw httpError(403, "PROJECT_EDIT_FORBIDDEN");
130
+ return project;
131
+ }
132
+ /** Owner permission includes an administrator's effective owner access. */
133
+ export function requireProjectOwnerPermission(db, projectId, user) {
134
+ requireActiveUser(db, user);
135
+ const project = accessibleProject(db, projectId, user);
136
+ if (!project)
137
+ throw httpError(404, "PROJECT_NOT_FOUND");
138
+ if (project.permission !== "owner") {
139
+ throw httpError(403, "PROJECT_OWNER_ONLY");
140
+ }
141
+ return project;
142
+ }
143
+ /** Operations such as ownership transfer require the actual stored owner. */
144
+ export function requireActualProjectOwner(db, projectId, user) {
145
+ requireActiveUser(db, user);
146
+ const project = accessibleProject(db, projectId, user);
147
+ if (!project)
148
+ throw httpError(404, "PROJECT_NOT_FOUND");
149
+ if (project.owner_id !== user.id) {
150
+ throw httpError(403, "PROJECT_OWNER_ONLY");
151
+ }
152
+ return project;
153
+ }
154
+ export function projectTextSnapshot(config, projectId) {
155
+ const versionedText = (filePath) => /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
156
+ return new Map(listProjectFiles(config, projectId).filter((entry) => entry.type === "file" && versionedText(entry.path)).map((entry) => {
157
+ const absolute = resolveSourcePath(config, projectId, entry.path);
158
+ return [entry.path, fs.statSync(absolute).size <= maxCollaborativeFileBytes(config) ? fs.readFileSync(absolute, "utf8") : ""];
159
+ }));
160
+ }
161
+ export function reanchorProjectSnapshot(db, projectId, before, after) {
162
+ for (const filePath of new Set([...before.keys(), ...after.keys()])) {
163
+ reanchorFileComments(db, projectId, filePath, before.get(filePath) ?? "", after.get(filePath) ?? "");
164
+ }
165
+ }
166
+ export function movedProjectPath(value, source, destination) {
167
+ if (value === null)
168
+ return null;
169
+ if (value === source)
170
+ return destination;
171
+ return value.startsWith(`${source}/`) ? `${destination}${value.slice(source.length)}` : value;
172
+ }
173
+ export function repliesForComment(db, commentId) {
174
+ const rows = db.prepare(`SELECT reply.*, user.username AS author_username,
175
+ user.display_name AS author_display_name
176
+ FROM comment_replies reply LEFT JOIN users user ON user.id = reply.author_id
177
+ WHERE reply.comment_id = ? ORDER BY reply.created_at`)
178
+ .all(commentId);
179
+ return rows.map((reply) => ({
180
+ id: reply.id,
181
+ authorId: reply.author_id,
182
+ authorUsername: reply.author_username,
183
+ authorDisplayName: reply.author_display_name,
184
+ content: reply.content,
185
+ createdAt: reply.created_at,
186
+ updatedAt: reply.updated_at,
187
+ editedAt: reply.edited_at
188
+ }));
189
+ }
190
+ export function commentsForFile(db, config, projectId, filePath) {
191
+ const absolute = resolveSourcePath(config, projectId, filePath);
192
+ const source = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
193
+ const rows = db.prepare(`SELECT c.*, u.username AS author_username, u.display_name AS author_display_name FROM comments c
194
+ LEFT JOIN users u ON u.id = c.author_id WHERE c.project_id = ? AND c.file_path = ? ORDER BY c.created_at`)
195
+ .all(projectId, filePath);
196
+ if (!rows.length)
197
+ return [];
198
+ const replies = db.prepare(`SELECT reply.*, user.username AS author_username, user.display_name AS author_display_name
199
+ FROM comment_replies reply
200
+ JOIN comments c ON c.id = reply.comment_id
201
+ LEFT JOIN users user ON user.id = reply.author_id
202
+ WHERE c.project_id = ? AND c.file_path = ?
203
+ ORDER BY reply.created_at`)
204
+ .all(projectId, filePath);
205
+ const replyMap = new Map();
206
+ for (const reply of replies) {
207
+ const list = replyMap.get(reply.comment_id) ?? [];
208
+ list.push({
209
+ id: reply.id,
210
+ authorId: reply.author_id,
211
+ authorUsername: reply.author_username,
212
+ authorDisplayName: reply.author_display_name,
213
+ content: reply.content,
214
+ createdAt: reply.created_at,
215
+ updatedAt: reply.updated_at,
216
+ editedAt: reply.edited_at
217
+ });
218
+ replyMap.set(reply.comment_id, list);
219
+ }
220
+ return rows.map((comment) => ({
221
+ id: comment.id,
222
+ authorId: comment.author_id,
223
+ authorUsername: comment.author_username,
224
+ authorDisplayName: comment.author_display_name,
225
+ selectedText: comment.selected_text,
226
+ startOffset: comment.start_offset,
227
+ endOffset: comment.end_offset,
228
+ startLine: offsetToLine(source, comment.start_offset),
229
+ endLine: offsetToLine(source, comment.end_offset),
230
+ content: comment.content,
231
+ resolved: Boolean(comment.resolved),
232
+ orphaned: Boolean(comment.orphaned),
233
+ createdAt: comment.created_at,
234
+ updatedAt: comment.updated_at,
235
+ editedAt: comment.edited_at,
236
+ replies: replyMap.get(comment.id) ?? []
237
+ }));
238
+ }