texlite 0.7.8 → 0.8.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 +463 -0
- package/NPM_TESTING.md +136 -0
- package/OPERATIONS.md +261 -0
- package/README.md +82 -305
- package/README.zh-CN.md +64 -329
- package/dist/client/assets/{GitDialog-CqGyUxfP.js → GitDialog-CZRQPg3O.js} +1 -1
- package/dist/client/assets/{HistoryDialog-BMl2zxBa.js → HistoryDialog-BQtr58Co.js} +1 -1
- package/dist/client/assets/{LatexEditor-C4bFVwJZ.js → LatexEditor-XLwZxO6_.js} +1 -1
- package/dist/client/assets/{PdfPreview-BpP2LnX7.js → PdfPreview-D5CC-vqV.js} +1 -1
- package/dist/client/assets/{ProjectNavigationDialogs-B7mxG8Fz.js → ProjectNavigationDialogs-BdtTldSa.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-BK_K3ah8.js → SystemMetricsDialog-CEbYJdj7.js} +1 -1
- package/dist/client/assets/index-BAFR6u6_.js +64 -0
- package/dist/client/assets/index-g5CaToD6.css +1 -0
- package/dist/client/assets/{minus-BwzSZenU.js → minus-KzdrD7TK.js} +1 -1
- package/dist/client/assets/spellCheck-CDZ9ZXo6.js +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +8 -3
- package/dist/server/cli.js +170 -32
- package/dist/server/environment.js +168 -38
- package/dist/server/harper.js +210 -119
- package/dist/server/i18n.js +8 -0
- package/dist/server/latexSpellMask.js +257 -0
- package/dist/server/projects.js +0 -12
- package/dist/server/routes/projectShared.js +1 -1
- package/dist/server/routes/projects.js +20 -19
- package/dist/server/routes/wordCount.js +83 -0
- package/dist/server/texcount.js +244 -0
- package/package.json +6 -3
- package/preview-1.png +0 -0
- package/preview-2.png +0 -0
- package/dist/client/assets/index-B9n8hfV0.js +0 -64
- package/dist/client/assets/index-BhyTKMoO.css +0 -1
- package/dist/client/assets/spellCheck-CvOAlLWp.js +0 -4
- package/preview.png +0 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// This intentionally recognizes only the parts of LaTeX that are clearly not
|
|
2
|
+
// prose. It is a single-pass lexer, not a parser: malformed delimiters remain
|
|
3
|
+
// narrow, so they never hide the rest of a document or cause backtracking.
|
|
4
|
+
const nonProseEnvironments = new Set([
|
|
5
|
+
"math", "displaymath", "equation", "equation*", "align", "align*", "alignat", "alignat*", "gather", "gather*", "multline", "multline*", "flalign", "flalign*",
|
|
6
|
+
"tikzpicture", "axis", "scope", "pgfonlayer", "pgfpicture",
|
|
7
|
+
"tabular", "tabularx", "tabulary", "longtable", "array", "matrix", "pmatrix", "bmatrix", "vmatrix",
|
|
8
|
+
"verbatim", "lstlisting", "minted", "comment", "algorithmic"
|
|
9
|
+
]);
|
|
10
|
+
const opaqueArgumentCommands = new Set([
|
|
11
|
+
"documentclass", "usepackage", "requirepackage", "includegraphics", "tikzset", "pgfplotsset", "hypersetup", "lstset",
|
|
12
|
+
"label", "hypertarget", "ref", "pageref", "autoref", "nameref", "hyperref", "index", "gls", "glspl",
|
|
13
|
+
"bibliography", "addbibresource", "input", "include", "url", "path", "setlength", "setcounter", "color", "textcolor", "colorbox", "pagecolor"
|
|
14
|
+
]);
|
|
15
|
+
function addRange(ranges, from, to) {
|
|
16
|
+
if (to > from)
|
|
17
|
+
ranges.push({ from, to });
|
|
18
|
+
}
|
|
19
|
+
function isWhitespace(character) {
|
|
20
|
+
return character === " " || character === "\t" || character === "\r" || character === "\n";
|
|
21
|
+
}
|
|
22
|
+
function isCommandNameCharacter(character) {
|
|
23
|
+
if (!character)
|
|
24
|
+
return false;
|
|
25
|
+
const code = character.charCodeAt(0);
|
|
26
|
+
return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57)
|
|
27
|
+
|| character === "@" || character === ":" || character === "_";
|
|
28
|
+
}
|
|
29
|
+
function balancedArgument(source, start, open, close) {
|
|
30
|
+
if (source[start] !== open)
|
|
31
|
+
return null;
|
|
32
|
+
let depth = 1;
|
|
33
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
34
|
+
if (source[index] === "\\") {
|
|
35
|
+
index += 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (source[index] === open)
|
|
39
|
+
depth += 1;
|
|
40
|
+
else if (source[index] === close && --depth === 0)
|
|
41
|
+
return { from: start, to: index + 1 };
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function skipWhitespace(source, start) {
|
|
46
|
+
let index = start;
|
|
47
|
+
while (index < source.length && isWhitespace(source[index]))
|
|
48
|
+
index += 1;
|
|
49
|
+
return index;
|
|
50
|
+
}
|
|
51
|
+
function readCommand(source, start) {
|
|
52
|
+
let end = start + 1;
|
|
53
|
+
if (isCommandNameCharacter(source[end]))
|
|
54
|
+
while (isCommandNameCharacter(source[end]))
|
|
55
|
+
end += 1;
|
|
56
|
+
else if (end < source.length)
|
|
57
|
+
end += 1;
|
|
58
|
+
const name = source.slice(start + 1, end);
|
|
59
|
+
if (source[end] === "*")
|
|
60
|
+
end += 1;
|
|
61
|
+
return { name, end };
|
|
62
|
+
}
|
|
63
|
+
function isCitationCommand(name) {
|
|
64
|
+
const normalized = name.toLocaleLowerCase("en-US");
|
|
65
|
+
return normalized === "cite" || normalized.startsWith("cite") || normalized.endsWith("cite");
|
|
66
|
+
}
|
|
67
|
+
function masksOpaqueArguments(name) {
|
|
68
|
+
const normalized = name.toLocaleLowerCase("en-US");
|
|
69
|
+
return opaqueArgumentCommands.has(normalized) || isCitationCommand(normalized) || normalized === "definecolor" || normalized === "colorlet" || normalized === "href";
|
|
70
|
+
}
|
|
71
|
+
function maskOpaqueArguments(source, start, name, ranges) {
|
|
72
|
+
let cursor = start;
|
|
73
|
+
const normalized = name.toLocaleLowerCase("en-US");
|
|
74
|
+
const count = normalized === "definecolor" || normalized === "colorlet" ? 3 : 1;
|
|
75
|
+
for (let index = 0; index < count; index += 1) {
|
|
76
|
+
const argument = balancedArgument(source, cursor, "{", "}");
|
|
77
|
+
if (!argument)
|
|
78
|
+
break;
|
|
79
|
+
addRange(ranges, argument.from, argument.to);
|
|
80
|
+
cursor = skipWhitespace(source, argument.to);
|
|
81
|
+
}
|
|
82
|
+
return cursor;
|
|
83
|
+
}
|
|
84
|
+
function rawUrlEnd(source, start) {
|
|
85
|
+
const first = source[start];
|
|
86
|
+
if (first !== "h" && first !== "H" && first !== "f" && first !== "F")
|
|
87
|
+
return null;
|
|
88
|
+
for (const prefix of ["http://", "https://", "ftp://"]) {
|
|
89
|
+
if (source.slice(start, start + prefix.length).toLowerCase() !== prefix)
|
|
90
|
+
continue;
|
|
91
|
+
let end = start + prefix.length;
|
|
92
|
+
while (end < source.length && !isWhitespace(source[end]))
|
|
93
|
+
end += 1;
|
|
94
|
+
return end;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function mergeOrderedRanges(ranges) {
|
|
99
|
+
const merged = [];
|
|
100
|
+
for (const range of ranges) {
|
|
101
|
+
const previous = merged.at(-1);
|
|
102
|
+
if (previous && range.from <= previous.to)
|
|
103
|
+
previous.to = Math.max(previous.to, range.to);
|
|
104
|
+
else
|
|
105
|
+
merged.push({ ...range });
|
|
106
|
+
}
|
|
107
|
+
return merged;
|
|
108
|
+
}
|
|
109
|
+
/** Return ordered source spans that should not be sent to a prose checker. */
|
|
110
|
+
export function ignoredLatexRanges(source) {
|
|
111
|
+
const ranges = [];
|
|
112
|
+
// Each non-prose environment installs its span at \begin and extends it at
|
|
113
|
+
// \end. This preserves source order, so final merging stays linear.
|
|
114
|
+
const nonProseStack = [];
|
|
115
|
+
// The same technique keeps unmatched $ / \[ delimiters cheap and local.
|
|
116
|
+
let pendingMath = null;
|
|
117
|
+
for (let index = 0; index < source.length;) {
|
|
118
|
+
if (pendingMath?.close.startsWith("\\") && source.startsWith(pendingMath.close, index)) {
|
|
119
|
+
pendingMath.range.to = index + pendingMath.close.length;
|
|
120
|
+
index += pendingMath.close.length;
|
|
121
|
+
pendingMath = null;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (source[index] === "%") {
|
|
125
|
+
const end = source.indexOf("\n", index);
|
|
126
|
+
addRange(ranges, index, end < 0 ? source.length : end);
|
|
127
|
+
index = end < 0 ? source.length : end + 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (source[index] === "$") {
|
|
131
|
+
if (pendingMath) {
|
|
132
|
+
if (pendingMath.close === "$" || (pendingMath.close === "$$" && source.startsWith("$$", index))) {
|
|
133
|
+
pendingMath.range.to = index + pendingMath.close.length;
|
|
134
|
+
index += pendingMath.close.length;
|
|
135
|
+
pendingMath = null;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
index += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const delimiter = source[index + 1] === "$" ? "$$" : "$";
|
|
143
|
+
const range = { from: index, to: index + delimiter.length };
|
|
144
|
+
ranges.push(range);
|
|
145
|
+
pendingMath = { close: delimiter, range };
|
|
146
|
+
index += delimiter.length;
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const urlEnd = rawUrlEnd(source, index);
|
|
151
|
+
if (urlEnd !== null) {
|
|
152
|
+
addRange(ranges, index, urlEnd);
|
|
153
|
+
index = urlEnd;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (source[index] !== "\\") {
|
|
157
|
+
index += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const commandStart = index;
|
|
161
|
+
if (source[index + 1] === "(" || source[index + 1] === "[") {
|
|
162
|
+
if (pendingMath) {
|
|
163
|
+
addRange(ranges, commandStart, index + 2);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const range = { from: commandStart, to: index + 2 };
|
|
167
|
+
ranges.push(range);
|
|
168
|
+
pendingMath = { close: source[index + 1] === "(" ? "\\)" : "\\]", range };
|
|
169
|
+
}
|
|
170
|
+
index += 2;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const command = readCommand(source, commandStart);
|
|
174
|
+
let cursor = skipWhitespace(source, command.end);
|
|
175
|
+
if (command.name === "verb") {
|
|
176
|
+
addRange(ranges, commandStart, command.end);
|
|
177
|
+
const delimiter = source[cursor];
|
|
178
|
+
if (delimiter && !isWhitespace(delimiter)) {
|
|
179
|
+
const end = source.indexOf(delimiter, cursor + 1);
|
|
180
|
+
if (end >= 0) {
|
|
181
|
+
addRange(ranges, cursor, end + 1);
|
|
182
|
+
cursor = end + 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
index = Math.max(command.end, cursor);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (command.name === "begin" || command.name === "end") {
|
|
189
|
+
const environment = balancedArgument(source, cursor, "{", "}");
|
|
190
|
+
if (!environment) {
|
|
191
|
+
addRange(ranges, commandStart, command.end);
|
|
192
|
+
index = command.end;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const environmentName = source.slice(environment.from + 1, environment.to - 1).trim().toLocaleLowerCase("en-US");
|
|
196
|
+
cursor = skipWhitespace(source, environment.to);
|
|
197
|
+
let options = null;
|
|
198
|
+
if (command.name === "begin") {
|
|
199
|
+
options = balancedArgument(source, cursor, "[", "]");
|
|
200
|
+
if (options)
|
|
201
|
+
cursor = skipWhitespace(source, options.to);
|
|
202
|
+
if (nonProseEnvironments.has(environmentName)) {
|
|
203
|
+
const range = { from: commandStart, to: cursor };
|
|
204
|
+
ranges.push(range);
|
|
205
|
+
nonProseStack.push({ name: environmentName, range });
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
addRange(ranges, commandStart, command.end);
|
|
209
|
+
addRange(ranges, environment.from, environment.to);
|
|
210
|
+
if (options)
|
|
211
|
+
addRange(ranges, options.from, options.to);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const open = nonProseStack.at(-1);
|
|
216
|
+
if (open?.name === environmentName) {
|
|
217
|
+
nonProseStack.pop();
|
|
218
|
+
open.range.to = cursor;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
addRange(ranges, commandStart, command.end);
|
|
222
|
+
addRange(ranges, environment.from, environment.to);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
index = Math.max(command.end, cursor);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
addRange(ranges, commandStart, command.end);
|
|
229
|
+
const options = balancedArgument(source, cursor, "[", "]");
|
|
230
|
+
if (options) {
|
|
231
|
+
addRange(ranges, options.from, options.to);
|
|
232
|
+
cursor = skipWhitespace(source, options.to);
|
|
233
|
+
}
|
|
234
|
+
if (masksOpaqueArguments(command.name))
|
|
235
|
+
cursor = maskOpaqueArguments(source, cursor, command.name, ranges);
|
|
236
|
+
index = Math.max(command.end, cursor);
|
|
237
|
+
}
|
|
238
|
+
return mergeOrderedRanges(ranges);
|
|
239
|
+
}
|
|
240
|
+
/** Replace syntax with spaces while retaining Unicode-scalar offsets and line breaks. */
|
|
241
|
+
export function maskLatexSource(source) {
|
|
242
|
+
const ranges = ignoredLatexRanges(source);
|
|
243
|
+
const chars = [];
|
|
244
|
+
let offset = 0;
|
|
245
|
+
let rangeIndex = 0;
|
|
246
|
+
for (const character of source) {
|
|
247
|
+
while (rangeIndex < ranges.length && ranges[rangeIndex].to <= offset)
|
|
248
|
+
rangeIndex += 1;
|
|
249
|
+
const range = ranges[rangeIndex];
|
|
250
|
+
const masked = range && range.from <= offset && offset < range.to && character !== "\n" && character !== "\r";
|
|
251
|
+
// `harper-cli` reports Unicode scalar positions. One space for every
|
|
252
|
+
// source scalar keeps diagnostics aligned after masked astral characters.
|
|
253
|
+
chars.push(masked ? " " : character);
|
|
254
|
+
offset += character.length;
|
|
255
|
+
}
|
|
256
|
+
return chars.join("");
|
|
257
|
+
}
|
package/dist/server/projects.js
CHANGED
|
@@ -1,16 +1,4 @@
|
|
|
1
1
|
export function accessibleProject(db, projectId, user) {
|
|
2
|
-
if (user.role === "admin") {
|
|
3
|
-
const row = db.prepare(`
|
|
4
|
-
SELECT p.*, 'owner' AS permission, owner.username AS owner_username,
|
|
5
|
-
owner.display_name AS owner_display_name,
|
|
6
|
-
modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name
|
|
7
|
-
FROM projects p
|
|
8
|
-
JOIN users owner ON owner.id = p.owner_id
|
|
9
|
-
LEFT JOIN users modifier ON modifier.id = p.last_modified_by
|
|
10
|
-
WHERE p.id = ?
|
|
11
|
-
`).get(projectId);
|
|
12
|
-
return row ?? null;
|
|
13
|
-
}
|
|
14
2
|
const row = db.prepare(`
|
|
15
3
|
SELECT p.*,
|
|
16
4
|
CASE WHEN p.owner_id = ? THEN 'owner' ELSE pm.permission END AS permission,
|
|
@@ -129,7 +129,7 @@ export function requireEditableProject(db, projectId, user) {
|
|
|
129
129
|
throw httpError(403, "PROJECT_EDIT_FORBIDDEN");
|
|
130
130
|
return project;
|
|
131
131
|
}
|
|
132
|
-
/** Owner permission
|
|
132
|
+
/** Owner permission is granted only to the stored project owner. */
|
|
133
133
|
export function requireProjectOwnerPermission(db, projectId, user) {
|
|
134
134
|
requireActiveUser(db, user);
|
|
135
135
|
const project = accessibleProject(db, projectId, user);
|
|
@@ -9,6 +9,7 @@ import { isMainDocumentCandidate } from "../latexRoot.js";
|
|
|
9
9
|
import { accessibleProject, canEdit } from "../projects.js";
|
|
10
10
|
import { writeProjectArchive } from "../archive.js";
|
|
11
11
|
import { extractProjectZip, ZipValidationError } from "../zip.js";
|
|
12
|
+
import { HarperUnavailableError } from "../harper.js";
|
|
12
13
|
import { commentsSummaryForProject, commentsSummaryForProjects, dictionaryWord, escapeLikePattern, now, projectJson, requireProjectOwnerPermission, tagColors, tagsForProject, tagsForProjects, text } from "./projectShared.js";
|
|
13
14
|
/** Register project catalog, metadata, archive, dictionary, tag, export, and deletion routes. */
|
|
14
15
|
export function registerProjectCatalogRoutes(app, context) {
|
|
@@ -50,14 +51,11 @@ export function registerProjectCatalogRoutes(app, context) {
|
|
|
50
51
|
const archiveCondition = archivedOnly
|
|
51
52
|
? "EXISTS (SELECT 1 FROM user_project_archives archive WHERE archive.project_id = p.id AND archive.user_id = :userId)"
|
|
52
53
|
: "NOT EXISTS (SELECT 1 FROM user_project_archives archive WHERE archive.project_id = p.id AND archive.user_id = :userId)";
|
|
53
|
-
const from =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
: `FROM projects p JOIN users owner ON owner.id = p.owner_id
|
|
57
|
-
LEFT JOIN users modifier ON modifier.id = p.last_modified_by
|
|
58
|
-
LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = :userId`;
|
|
54
|
+
const from = `FROM projects p JOIN users owner ON owner.id = p.owner_id
|
|
55
|
+
LEFT JOIN users modifier ON modifier.id = p.last_modified_by
|
|
56
|
+
LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = :userId`;
|
|
59
57
|
const conditions = [
|
|
60
|
-
|
|
58
|
+
"(p.owner_id = :userId OR pm.user_id = :userId)",
|
|
61
59
|
archiveCondition
|
|
62
60
|
];
|
|
63
61
|
const params = { userId: user.id };
|
|
@@ -79,14 +77,10 @@ export function registerProjectCatalogRoutes(app, context) {
|
|
|
79
77
|
const totalPages = Math.ceil(total / pageSize);
|
|
80
78
|
const currentPage = totalPages === 0 ? 1 : Math.min(page, totalPages);
|
|
81
79
|
const rowsParams = { ...params, limit: pageSize, offset: (currentPage - 1) * pageSize };
|
|
82
|
-
const select =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
: `SELECT DISTINCT p.*,
|
|
87
|
-
CASE WHEN p.owner_id = :userId THEN 'owner' ELSE pm.permission END AS permission,
|
|
88
|
-
owner.username AS owner_username, owner.display_name AS owner_display_name,
|
|
89
|
-
modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name`;
|
|
80
|
+
const select = `SELECT DISTINCT p.*,
|
|
81
|
+
CASE WHEN p.owner_id = :userId THEN 'owner' ELSE pm.permission END AS permission,
|
|
82
|
+
owner.username AS owner_username, owner.display_name AS owner_display_name,
|
|
83
|
+
modifier.username AS last_modified_username, modifier.display_name AS last_modified_display_name`;
|
|
90
84
|
const rows = db.prepare(`${select} ${from} ${where}
|
|
91
85
|
ORDER BY ${sortColumn} DESC, p.name COLLATE NOCASE ASC
|
|
92
86
|
LIMIT :limit OFFSET :offset`).all(rowsParams);
|
|
@@ -259,16 +253,21 @@ export function registerProjectCatalogRoutes(app, context) {
|
|
|
259
253
|
if (!accessibleProject(db, id, user))
|
|
260
254
|
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
261
255
|
const body = request.body;
|
|
262
|
-
if (typeof body?.source !== "string")
|
|
256
|
+
if (typeof body?.source !== "string" || typeof body.path !== "string")
|
|
263
257
|
return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
|
|
264
258
|
if (Buffer.byteLength(body.source, "utf8") > maxCollaborativeFileBytes(config)) {
|
|
265
259
|
return apiError(reply, 413, "SPELLCHECK_SOURCE_TOO_LARGE");
|
|
266
260
|
}
|
|
267
261
|
try {
|
|
268
|
-
return { lints: await harper.lint(body.source) };
|
|
262
|
+
return { lints: await harper.lint(body.source, body.path) };
|
|
269
263
|
}
|
|
270
264
|
catch (error) {
|
|
271
|
-
|
|
265
|
+
// A missing optional command is an expected fallback condition. Keep it
|
|
266
|
+
// out of normal logs while preserving diagnostics for an actual failure.
|
|
267
|
+
if (error instanceof HarperUnavailableError)
|
|
268
|
+
request.log.debug({ projectId: id }, "Host Harper CLI unavailable; using browser fallback");
|
|
269
|
+
else
|
|
270
|
+
request.log.error({ err: error, projectId: id }, "Harper writing check failed");
|
|
272
271
|
return apiError(reply, 503, "HARPER_UNAVAILABLE");
|
|
273
272
|
}
|
|
274
273
|
});
|
|
@@ -459,7 +458,9 @@ export function registerProjectCatalogRoutes(app, context) {
|
|
|
459
458
|
return;
|
|
460
459
|
const { id } = request.params;
|
|
461
460
|
const project = accessibleProject(db, id, user);
|
|
462
|
-
if (!project
|
|
461
|
+
if (!project)
|
|
462
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
463
|
+
if (project.permission !== "owner")
|
|
463
464
|
return apiError(reply, 403, "PROJECT_DELETE_FORBIDDEN");
|
|
464
465
|
return await projectMutations.runExclusive(id, "project deletion", () => {
|
|
465
466
|
collaboration.resetProject(id);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { requireUser } from "../auth.js";
|
|
2
|
+
import { maxCollaborativeFileBytes } from "../collaboration.js";
|
|
3
|
+
import { compileMainFile } from "../compileArtifacts.js";
|
|
4
|
+
import { assertNoSourceSymlinks, safeRelativePath, sourceRoot } from "../files.js";
|
|
5
|
+
import { apiError, httpError } from "../http.js";
|
|
6
|
+
import { accessibleProject } from "../projects.js";
|
|
7
|
+
import { TexcountExecutionError, TexcountUnavailableError } from "../texcount.js";
|
|
8
|
+
/** Register host TeXcount-backed full-document and editor-selection counts. */
|
|
9
|
+
export function registerWordCountRoutes(app, context) {
|
|
10
|
+
const { config, db, projectMutations, texcount } = context;
|
|
11
|
+
app.post("/api/projects/:id/word-count", 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 body = (request.body ?? {});
|
|
20
|
+
const mode = body.mode;
|
|
21
|
+
if (mode !== "full" && mode !== "selection")
|
|
22
|
+
return apiError(reply, 400, "WORD_COUNT_SOURCE_INVALID");
|
|
23
|
+
try {
|
|
24
|
+
if (mode === "selection") {
|
|
25
|
+
return await countSelection(config, id, body, texcount);
|
|
26
|
+
}
|
|
27
|
+
return await countDocument(config, db, projectMutations, texcount, id, user, body.mainFile);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error instanceof TexcountUnavailableError)
|
|
31
|
+
return apiError(reply, 503, "WORD_COUNT_UNAVAILABLE");
|
|
32
|
+
if (error instanceof TexcountExecutionError)
|
|
33
|
+
return apiError(reply, 422, "WORD_COUNT_FAILED");
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function countSelection(config, projectId, body, texcount) {
|
|
39
|
+
if (typeof body.path !== "string" || typeof body.source !== "string") {
|
|
40
|
+
throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
|
|
41
|
+
}
|
|
42
|
+
let filePath;
|
|
43
|
+
try {
|
|
44
|
+
filePath = safeRelativePath(body.path);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
|
|
48
|
+
}
|
|
49
|
+
// TeXcount's stdin parser is intended for TeX-like source. Keep BibTeX and
|
|
50
|
+
// binary resources out of this endpoint while allowing .sty/.cls snippets.
|
|
51
|
+
if (!/\.(?:tex|sty|cls)$/i.test(filePath) || !body.source.trim()) {
|
|
52
|
+
throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
|
|
53
|
+
}
|
|
54
|
+
if (Buffer.byteLength(body.source, "utf8") > maxCollaborativeFileBytes(config)) {
|
|
55
|
+
throw httpError(413, "WORD_COUNT_SOURCE_TOO_LARGE");
|
|
56
|
+
}
|
|
57
|
+
const counts = await texcount.countSource(sourceRoot(config, projectId), body.source);
|
|
58
|
+
return { mode: "selection", path: filePath, ...counts };
|
|
59
|
+
}
|
|
60
|
+
async function countDocument(config, db, projectMutations, texcount, projectId, user, requestedMainFile) {
|
|
61
|
+
const initialProject = accessibleProject(db, projectId, user);
|
|
62
|
+
if (!initialProject)
|
|
63
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
64
|
+
const mainFile = compileMainFile(config, projectId, initialProject.main_file, requestedMainFile);
|
|
65
|
+
if (!mainFile)
|
|
66
|
+
throw httpError(400, "MAIN_DOCUMENT_INVALID");
|
|
67
|
+
return await projectMutations.runConsistentRead(projectId, async () => {
|
|
68
|
+
// TeXcount follows \input/\include paths. Reject links before handing it
|
|
69
|
+
// the source root so a checked-out link cannot expose files outside it.
|
|
70
|
+
assertNoSourceSymlinks(config, projectId);
|
|
71
|
+
const counts = await texcount.countFile(sourceRoot(config, projectId), mainFile);
|
|
72
|
+
return { mode: "full", path: mainFile, ...counts };
|
|
73
|
+
}, {
|
|
74
|
+
preflight: () => {
|
|
75
|
+
const current = accessibleProject(db, projectId, user);
|
|
76
|
+
if (!current)
|
|
77
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
78
|
+
const selected = compileMainFile(config, projectId, current.main_file, requestedMainFile);
|
|
79
|
+
if (!selected || selected !== mainFile)
|
|
80
|
+
throw httpError(400, "MAIN_DOCUMENT_INVALID");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|