texlite 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/client/assets/{AdminUsers-3CHb5012.js → AdminUsers-Dazar9cj.js} +1 -1
  2. package/dist/client/assets/{CitationLibraryDialog-zPpsUhWJ.js → CitationLibraryDialog-VSUiSPgG.js} +1 -1
  3. package/dist/client/assets/{Dashboard-CWfSLzD3.js → Dashboard-CoE5ESfb.js} +2 -2
  4. package/dist/client/assets/GitDialog-C9Loz4Al.js +3 -0
  5. package/dist/client/assets/HistoryDialog-3UNN_jhU.js +7 -0
  6. package/dist/client/assets/{LatexEditor-CM445Tk0.js → LatexEditor-Bq76v8sO.js} +2 -2
  7. package/dist/client/assets/{PdfPreview-BFVNczSk.js → PdfPreview-BKhcS-2c.js} +1 -1
  8. package/dist/client/assets/{ProjectIconPickerDialog-Cf0zBf8u.js → ProjectIconPickerDialog-BoWMjQVm.js} +1 -1
  9. package/dist/client/assets/{ProjectNavigationDialogs-TPbe11iR.js → ProjectNavigationDialogs-CZKTnxrl.js} +1 -1
  10. package/dist/client/assets/{ProjectSettings-CDQvIlQ4.js → ProjectSettings-DiOQVJ40.js} +1 -1
  11. package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +27 -0
  12. package/dist/client/assets/SelectionHistoryDialog-D3dO83lg.js +3 -0
  13. package/dist/client/assets/{SystemMetricsDialog-CuqHPUnn.js → SystemMetricsDialog-0zCwLtH_.js} +1 -1
  14. package/dist/client/assets/{TagManagementDialog-gRLM3lLx.js → TagManagementDialog-C6KDwtvd.js} +1 -1
  15. package/dist/client/assets/{WordCountDialog-B4PI0vEk.js → WordCountDialog-TwpyAP3m.js} +1 -1
  16. package/dist/client/assets/check-sZxOOKtS.js +1 -0
  17. package/dist/client/assets/chevron-left-32SrOwa5.js +1 -0
  18. package/dist/client/assets/{hard-drive-6uYFyezl.js → hard-drive-XHSiS_A7.js} +1 -1
  19. package/dist/client/assets/index-BRr3Kuxh.css +1 -0
  20. package/dist/client/assets/{index-C0t11tlT.js → index-BjzELSQ-.js} +3 -5
  21. package/dist/client/assets/{index-BRT5O-Hg.js → index-Cd6-F29b.js} +1 -1
  22. package/dist/client/assets/{key-round-DOAxDSfz.js → key-round-C-CXCWnz.js} +1 -1
  23. package/dist/client/assets/minimize-2-BMDPVaF-.js +1 -0
  24. package/dist/client/assets/{minus-9eVJnugf.js → minus-BIa7-KuM.js} +1 -1
  25. package/dist/client/assets/{plus-BGXipRoG.js → plus-BKDWy4Ui.js} +1 -1
  26. package/dist/client/assets/{refresh-cw-Dvp3O90f.js → refresh-cw-Bo7-a7-I.js} +1 -1
  27. package/dist/client/assets/{search-b1ORp1k8.js → search-Bz9Ob5mT.js} +1 -1
  28. package/dist/client/assets/{sigma-Bk0_MzWi.js → sigma-DxlHf9gv.js} +1 -1
  29. package/dist/client/assets/spellCheck-BfZEnpwY.js +1 -0
  30. package/dist/client/assets/{tags-JAkbmWns.js → tags-IMZehjzE.js} +1 -1
  31. package/dist/client/assets/{type-CE2DYkPH.js → type-DTWpFQNQ.js} +1 -1
  32. package/dist/client/assets/{user-plus-CKalKSsw.js → user-plus--aVpd8K6.js} +1 -1
  33. package/dist/client/assets/{x-Bq29njHL.js → x-Tbo3aA0J.js} +1 -1
  34. package/dist/client/index.html +2 -2
  35. package/dist/server/harper.js +205 -8
  36. package/dist/server/history.js +53 -3
  37. package/dist/server/i18n.js +8 -0
  38. package/dist/server/latexSpellMask.js +197 -43
  39. package/dist/server/routes/comments.js +13 -1
  40. package/dist/server/routes/projectHistory.js +13 -1
  41. package/dist/server/routes/projects.js +36 -4
  42. package/package.json +1 -1
  43. package/dist/client/assets/GitDialog-BUU2U_LV.js +0 -3
  44. package/dist/client/assets/HistoryDialog-B6aopsrw.js +0 -7
  45. package/dist/client/assets/ProjectWorkspace-C6GrvKN6.js +0 -27
  46. package/dist/client/assets/SelectionHistoryDialog-CyE0_YDs.js +0 -3
  47. package/dist/client/assets/check-BXvzrdoz.js +0 -1
  48. package/dist/client/assets/chevron-left-BB5MYb7g.js +0 -1
  49. package/dist/client/assets/index-DXWp2bzM.css +0 -1
  50. package/dist/client/assets/minimize-2-BWtbag2I.js +0 -1
  51. package/dist/client/assets/spellCheck-BJjWgnef.js +0 -1
@@ -1,16 +1,34 @@
1
1
  // This intentionally recognizes only the parts of LaTeX that are clearly not
2
2
  // prose. It is a single-pass lexer, not a parser: malformed delimiters remain
3
3
  // narrow, so they never hide the rest of a document or cause backtracking.
4
+ const literalEnvironments = new Set([
5
+ "verbatim", "verbatim*", "bverbatim", "bverbatim*", "lverbatim", "lverbatim*", "saveverbatim", "saveverbatim*", "verbatimout",
6
+ "lstlisting", "minted", "minted*", "filecontents", "filecontents*", "luacode", "luacodestar", "comment"
7
+ ]);
4
8
  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
+ // Display and helper math environments commonly used by amsmath/mathtools.
10
+ "math", "displaymath", "equation", "equation*", "align", "align*", "alignat", "alignat*", "aligned", "alignedat", "gather", "gather*", "gathered", "multline", "multline*", "multlined", "flalign", "flalign*", "split", "cases", "dcases", "rcases", "drcases",
11
+ "array", "matrix", "pmatrix", "bmatrix", "vmatrix", "smallmatrix", "psmallmatrix", "bsmallmatrix", "vsmallmatrix",
12
+ // Diagram, table, and literal/code environments. Keep prose containers such
13
+ // as figure, table, theorem, and caption outside this list.
14
+ "tikzpicture", "tikzcd", "axis", "scope", "pgfonlayer", "pgfpicture",
15
+ "tabular", "tabularx", "tabulary", "longtable",
16
+ "verbatim", "verbatim*", "bverbatim", "bverbatim*", "lverbatim", "lverbatim*", "saveverbatim", "saveverbatim*", "verbatimout",
17
+ "lstlisting", "minted", "minted*", "filecontents", "filecontents*", "luacode", "luacodestar", "comment", "algorithmic"
9
18
  ]);
10
19
  const opaqueArgumentCommands = new Set([
11
20
  "documentclass", "usepackage", "requirepackage", "includegraphics", "tikzset", "pgfplotsset", "hypersetup", "lstset",
12
21
  "label", "hypertarget", "ref", "pageref", "autoref", "nameref", "hyperref", "index", "gls", "glspl",
13
- "bibliography", "addbibresource", "input", "include", "url", "path", "setlength", "setcounter", "color", "textcolor", "colorbox", "pagecolor"
22
+ "bibliography", "addbibresource", "input", "include", "url", "path", "setlength", "setcounter", "color", "textcolor", "colorbox", "pagecolor",
23
+ // These arguments are code, identifiers, or paths rather than prose.
24
+ "texttt", "detokenize", "lstinputlisting"
25
+ ]);
26
+ /** Number of brace arguments after a macro/environment target. */
27
+ const macroDefinitionArgumentCounts = new Map([
28
+ ["newcommand", 1], ["renewcommand", 1], ["providecommand", 1], ["declarerobustcommand", 1],
29
+ ["newenvironment", 2], ["renewenvironment", 2],
30
+ ["newdocumentcommand", 2], ["renewdocumentcommand", 2], ["providedocumentcommand", 2], ["declaredocumentcommand", 2],
31
+ ["declaremathoperator", 1], ["declarepaireddelimiter", 2]
14
32
  ]);
15
33
  function addRange(ranges, from, to) {
16
34
  if (to > from)
@@ -26,7 +44,7 @@ function isCommandNameCharacter(character) {
26
44
  return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || (code >= 48 && code <= 57)
27
45
  || character === "@" || character === ":" || character === "_";
28
46
  }
29
- function balancedArgument(source, start, open, close) {
47
+ function balancedArgument(source, start, open, close, literal = false) {
30
48
  if (source[start] !== open)
31
49
  return null;
32
50
  let depth = 1;
@@ -35,6 +53,13 @@ function balancedArgument(source, start, open, close) {
35
53
  index += 1;
36
54
  continue;
37
55
  }
56
+ if (!literal && source[index] === "%") {
57
+ const lineEnd = source.indexOf("\n", index);
58
+ if (lineEnd < 0)
59
+ return null;
60
+ index = lineEnd;
61
+ continue;
62
+ }
38
63
  if (source[index] === open)
39
64
  depth += 1;
40
65
  else if (source[index] === close && --depth === 0)
@@ -48,6 +73,17 @@ function skipWhitespace(source, start) {
48
73
  index += 1;
49
74
  return index;
50
75
  }
76
+ /** Skip whitespace and TeX comments between arguments while masking comments. */
77
+ function skipTrivia(source, start, ranges) {
78
+ let index = skipWhitespace(source, start);
79
+ while (source[index] === "%") {
80
+ const lineEnd = source.indexOf("\n", index);
81
+ const end = lineEnd < 0 ? source.length : lineEnd;
82
+ addRange(ranges, index, end);
83
+ index = skipWhitespace(source, lineEnd < 0 ? source.length : lineEnd + 1);
84
+ }
85
+ return index;
86
+ }
51
87
  function readCommand(source, start) {
52
88
  let end = start + 1;
53
89
  if (isCommandNameCharacter(source[end]))
@@ -64,23 +100,103 @@ function isCitationCommand(name) {
64
100
  const normalized = name.toLocaleLowerCase("en-US");
65
101
  return normalized === "cite" || normalized.startsWith("cite") || normalized.endsWith("cite");
66
102
  }
67
- function masksOpaqueArguments(name) {
103
+ function opaqueArgumentCount(name) {
68
104
  const normalized = name.toLocaleLowerCase("en-US");
69
- return opaqueArgumentCommands.has(normalized) || isCitationCommand(normalized) || normalized === "definecolor" || normalized === "colorlet" || normalized === "href";
105
+ if (normalized === "definecolor" || normalized === "colorlet")
106
+ return 3;
107
+ if (normalized === "mint" || normalized === "inputminted")
108
+ return 2;
109
+ return opaqueArgumentCommands.has(normalized) || isCitationCommand(normalized) || normalized === "href" ? 1 : 0;
70
110
  }
71
- function maskOpaqueArguments(source, start, name, ranges) {
111
+ function maskBraceArguments(source, start, count, ranges) {
72
112
  let cursor = start;
73
- const normalized = name.toLocaleLowerCase("en-US");
74
- const count = normalized === "definecolor" || normalized === "colorlet" ? 3 : 1;
75
113
  for (let index = 0; index < count; index += 1) {
76
114
  const argument = balancedArgument(source, cursor, "{", "}");
77
115
  if (!argument)
78
116
  break;
79
117
  addRange(ranges, argument.from, argument.to);
80
- cursor = skipWhitespace(source, argument.to);
118
+ cursor = skipTrivia(source, argument.to, ranges);
81
119
  }
82
120
  return cursor;
83
121
  }
122
+ function maskMacroTarget(source, start, ranges) {
123
+ const bracedTarget = balancedArgument(source, start, "{", "}");
124
+ if (bracedTarget) {
125
+ addRange(ranges, bracedTarget.from, bracedTarget.to);
126
+ return skipTrivia(source, bracedTarget.to, ranges);
127
+ }
128
+ if (source[start] === "\\") {
129
+ const target = readCommand(source, start);
130
+ addRange(ranges, start, target.end);
131
+ return skipTrivia(source, target.end, ranges);
132
+ }
133
+ return start;
134
+ }
135
+ function maskMacroDefinition(source, start, definitionArgumentCount, ranges) {
136
+ let cursor = maskMacroTarget(source, start, ranges);
137
+ if (cursor === start)
138
+ return cursor;
139
+ // newcommand-style macros may have both an argument-count option and a
140
+ // default-argument option. Mask every immediately following option before
141
+ // their definition bodies, while keeping malformed input narrow.
142
+ for (;;) {
143
+ const option = balancedArgument(source, cursor, "[", "]");
144
+ if (!option)
145
+ break;
146
+ addRange(ranges, option.from, option.to);
147
+ cursor = skipTrivia(source, option.to, ranges);
148
+ }
149
+ return maskBraceArguments(source, cursor, definitionArgumentCount, ranges);
150
+ }
151
+ function maskDelimitedCode(source, start, ranges) {
152
+ const delimiter = source[start];
153
+ if (!delimiter || isWhitespace(delimiter))
154
+ return start;
155
+ // TeX's delimiter-based inline literal commands cannot span a line. If a
156
+ // user is still typing the closing delimiter, hide only the incomplete
157
+ // current line rather than accidentally suppressing all later prose until a
158
+ // matching character happens to occur.
159
+ for (let cursor = start + 1; cursor < source.length; cursor += 1) {
160
+ const character = source[cursor];
161
+ if (character === delimiter) {
162
+ addRange(ranges, start, cursor + 1);
163
+ return skipTrivia(source, cursor + 1, ranges);
164
+ }
165
+ if (character === "\r" || character === "\n") {
166
+ addRange(ranges, start, cursor);
167
+ return cursor;
168
+ }
169
+ }
170
+ addRange(ranges, start, source.length);
171
+ return source.length;
172
+ }
173
+ function maskDelimitedOrBracedCode(source, start, ranges) {
174
+ if (source[start] !== "{")
175
+ return maskDelimitedCode(source, start, ranges);
176
+ // The brace form is also inline literal code. Preserve nested braces and
177
+ // escaped characters, but never let an unfinished argument conceal a later
178
+ // source line from the writing checker.
179
+ let depth = 1;
180
+ for (let cursor = start + 1; cursor < source.length; cursor += 1) {
181
+ const character = source[cursor];
182
+ if (character === "\\" && source[cursor + 1] !== "\r" && source[cursor + 1] !== "\n") {
183
+ cursor += 1;
184
+ continue;
185
+ }
186
+ if (character === "\r" || character === "\n") {
187
+ addRange(ranges, start, cursor);
188
+ return cursor;
189
+ }
190
+ if (character === "{")
191
+ depth += 1;
192
+ else if (character === "}" && --depth === 0) {
193
+ addRange(ranges, start, cursor + 1);
194
+ return cursor + 1;
195
+ }
196
+ }
197
+ addRange(ranges, start, source.length);
198
+ return source.length;
199
+ }
84
200
  function rawUrlEnd(source, start) {
85
201
  const first = source[start];
86
202
  if (first !== "h" && first !== "H" && first !== "f" && first !== "F")
@@ -115,6 +231,33 @@ export function ignoredLatexRanges(source) {
115
231
  // The same technique keeps unmatched $ / \[ delimiters cheap and local.
116
232
  let pendingMath = null;
117
233
  for (let index = 0; index < source.length;) {
234
+ // An open literal environment owns its full body. Do not parse
235
+ // dollars, comments, or nested commands within it: literal code often
236
+ // contains all of those. Only look for its matching \end marker.
237
+ const activeNonProse = nonProseStack.at(-1);
238
+ if (activeNonProse && literalEnvironments.has(activeNonProse.name)) {
239
+ if (source[index] === "\\") {
240
+ const command = readCommand(source, index);
241
+ if (command.name === "end") {
242
+ const cursor = skipTrivia(source, command.end, ranges);
243
+ const environment = balancedArgument(source, cursor, "{", "}");
244
+ const environmentName = environment
245
+ ? source.slice(environment.from + 1, environment.to - 1).trim().toLocaleLowerCase("en-US")
246
+ : "";
247
+ if (environment && environmentName === activeNonProse.name) {
248
+ activeNonProse.range.to = skipTrivia(source, environment.to, ranges);
249
+ nonProseStack.pop();
250
+ index = activeNonProse.range.to;
251
+ continue;
252
+ }
253
+ }
254
+ index = Math.max(index + 1, command.end);
255
+ }
256
+ else {
257
+ index += 1;
258
+ }
259
+ continue;
260
+ }
118
261
  if (pendingMath?.close.startsWith("\\") && source.startsWith(pendingMath.close, index)) {
119
262
  pendingMath.range.to = index + pendingMath.close.length;
120
263
  index += pendingMath.close.length;
@@ -171,44 +314,41 @@ export function ignoredLatexRanges(source) {
171
314
  continue;
172
315
  }
173
316
  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
- }
317
+ const commandName = command.name.toLocaleLowerCase("en-US");
318
+ // Reserve the command span before scanning trivia, which may append spans.
319
+ const commandRange = { from: commandStart, to: command.end };
320
+ ranges.push(commandRange);
321
+ let cursor = commandName === "verb" || commandName === "lstinline"
322
+ ? command.end : skipTrivia(source, command.end, ranges);
323
+ if (commandName === "verb") {
324
+ const options = command.name === "Verb" ? balancedArgument(source, cursor, "[", "]") : null;
325
+ if (options) {
326
+ addRange(ranges, options.from, options.to);
327
+ cursor = skipWhitespace(source, options.to);
184
328
  }
329
+ cursor = maskDelimitedOrBracedCode(source, cursor, ranges);
185
330
  index = Math.max(command.end, cursor);
186
331
  continue;
187
332
  }
188
333
  if (command.name === "begin" || command.name === "end") {
189
334
  const environment = balancedArgument(source, cursor, "{", "}");
190
335
  if (!environment) {
191
- addRange(ranges, commandStart, command.end);
192
336
  index = command.end;
193
337
  continue;
194
338
  }
195
339
  const environmentName = source.slice(environment.from + 1, environment.to - 1).trim().toLocaleLowerCase("en-US");
196
- cursor = skipWhitespace(source, environment.to);
340
+ addRange(ranges, environment.from, environment.to);
341
+ cursor = skipTrivia(source, environment.to, ranges);
197
342
  let options = null;
198
343
  if (command.name === "begin") {
199
344
  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 });
345
+ if (options) {
346
+ addRange(ranges, options.from, options.to);
347
+ cursor = skipTrivia(source, options.to, ranges);
206
348
  }
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);
349
+ if (nonProseEnvironments.has(environmentName)) {
350
+ commandRange.to = cursor;
351
+ nonProseStack.push({ name: environmentName, range: commandRange });
212
352
  }
213
353
  }
214
354
  else {
@@ -217,22 +357,36 @@ export function ignoredLatexRanges(source) {
217
357
  nonProseStack.pop();
218
358
  open.range.to = cursor;
219
359
  }
220
- else {
221
- addRange(ranges, commandStart, command.end);
222
- addRange(ranges, environment.from, environment.to);
223
- }
224
360
  }
225
361
  index = Math.max(command.end, cursor);
226
362
  continue;
227
363
  }
228
- addRange(ranges, commandStart, command.end);
229
364
  const options = balancedArgument(source, cursor, "[", "]");
230
365
  if (options) {
231
366
  addRange(ranges, options.from, options.to);
232
- cursor = skipWhitespace(source, options.to);
367
+ cursor = commandName === "lstinline" ? skipWhitespace(source, options.to) : skipTrivia(source, options.to, ranges);
368
+ }
369
+ if (commandName === "lstinline") {
370
+ cursor = maskDelimitedOrBracedCode(source, cursor, ranges);
371
+ }
372
+ else if (commandName === "mintinline") {
373
+ const language = balancedArgument(source, cursor, "{", "}");
374
+ if (language) {
375
+ addRange(ranges, language.from, language.to);
376
+ cursor = maskDelimitedOrBracedCode(source, skipWhitespace(source, language.to), ranges);
377
+ }
378
+ }
379
+ else {
380
+ const macroDefinitionArgumentCount = macroDefinitionArgumentCounts.get(commandName);
381
+ if (macroDefinitionArgumentCount !== undefined) {
382
+ cursor = maskMacroDefinition(source, cursor, macroDefinitionArgumentCount, ranges);
383
+ }
384
+ else {
385
+ const opaqueCount = opaqueArgumentCount(commandName);
386
+ if (opaqueCount > 0)
387
+ cursor = maskBraceArguments(source, cursor, opaqueCount, ranges);
388
+ }
233
389
  }
234
- if (masksOpaqueArguments(command.name))
235
- cursor = maskOpaqueArguments(source, cursor, command.name, ranges);
236
390
  index = Math.max(command.end, cursor);
237
391
  }
238
392
  return mergeOrderedRanges(ranges);
@@ -1,5 +1,5 @@
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";
@@ -106,6 +106,18 @@ export function registerCommentRoutes(app, context) {
106
106
  if (!Number.isInteger(body.startOffset) || !Number.isInteger(body.endOffset)) {
107
107
  return apiError(reply, 400, "COMMENT_RANGE_INVALID");
108
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
+ }
109
121
  const anchor = createSourceAnchor(source, Number(body.startOffset), Number(body.endOffset));
110
122
  const comment = {
111
123
  id: randomUUID(), filePath,
@@ -155,7 +155,12 @@ export function registerProjectHistoryRoutes(app, context) {
155
155
  if (!project || !canEdit(project))
156
156
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
157
157
  const body = request.body;
158
- const label = body.label === null || body.label === "" ? null : text(body.label, 80);
158
+ if (body?.snapshotHash !== undefined && (typeof body.snapshotHash !== "string" || !/^[a-f0-9]{64}$/.test(body.snapshotHash))) {
159
+ return apiError(reply, 400, "REQUEST_INVALID");
160
+ }
161
+ const snapshotHash = typeof body?.snapshotHash === "string" ? body.snapshotHash : undefined;
162
+ history.assertSnapshotHash(id, versionId, snapshotHash);
163
+ const label = body?.label === null || body?.label === "" ? null : text(body?.label, 80);
159
164
  const version = history.setLabel(id, versionId, label);
160
165
  if (!version)
161
166
  return apiError(reply, 404, "HISTORY_VERSION_NOT_FOUND");
@@ -206,8 +211,15 @@ export function registerProjectHistoryRoutes(app, context) {
206
211
  return apiError(reply, 403, "PROJECT_EDIT_FORBIDDEN");
207
212
  const body = request.body;
208
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;
209
220
  return await projectMutations.runExclusive(id, "history restore", () => {
210
221
  const currentProject = requireEditableProject(db, id, user);
222
+ history.assertSnapshotHash(id, versionId, snapshotHash);
211
223
  recordHistory(id, user.id, "checkpoint");
212
224
  const before = projectTextSnapshot(config, id);
213
225
  const restored = history.restore(id, versionId, filePath);
@@ -10,9 +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";
14
15
  import { unreadMentionCountsForProjects } from "../commentMentions.js";
15
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;
16
18
  /** Register project catalog, metadata, archive, dictionary, tag, export, and deletion routes. */
17
19
  export function registerProjectCatalogRoutes(app, context) {
18
20
  const { config, db, collaboration, projectMutations, latexCompletions, projectOutlines, harper, recordHistory } = context;
@@ -362,15 +364,45 @@ export function registerProjectCatalogRoutes(app, context) {
362
364
  if (!accessibleProject(db, id, user))
363
365
  return apiError(reply, 404, "PROJECT_NOT_FOUND");
364
366
  const body = request.body;
365
- if (typeof body?.source !== "string" || typeof body.path !== "string")
367
+ const source = body?.source;
368
+ const sourcePath = body?.path;
369
+ const clientId = body?.clientId;
370
+ const sequence = body?.sequence;
371
+ if (typeof source !== "string" || typeof sourcePath !== "string") {
366
372
  return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
367
- if (Buffer.byteLength(body.source, "utf8") > maxCollaborativeFileBytes(config)) {
373
+ }
374
+ // Accept already-open clients during a rolling update. Current clients
375
+ // pair a page-local UUID with a monotonically increasing sequence so a
376
+ // delayed HTTP request cannot replace a newer queued check.
377
+ if (clientId !== undefined && (typeof clientId !== "string" || !clientIdPattern.test(clientId))) {
378
+ return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
379
+ }
380
+ if (sequence !== undefined && (typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence <= 0 || clientId === undefined)) {
381
+ return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
382
+ }
383
+ if (Buffer.byteLength(source, "utf8") > maxCollaborativeFileBytes(config)) {
368
384
  return apiError(reply, 413, "SPELLCHECK_SOURCE_TOO_LARGE");
369
385
  }
386
+ let filePath;
370
387
  try {
371
- return { lints: await harper.lint(body.source, body.path) };
388
+ filePath = safeRelativePath(sourcePath);
389
+ }
390
+ catch {
391
+ return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
392
+ }
393
+ // A login session is shared by browser tabs. Pair the authenticated user
394
+ // with the page-local client ID so each new editor can replace only its
395
+ // own obsolete waiting work. Legacy clients retain their former session
396
+ // grouping until their page is refreshed.
397
+ const laneClientId = clientId ?? `legacy:${digestToken(request.cookies.texlite_session ?? "")}`;
398
+ const lane = `${id}\0${user.id}\0${laneClientId}\0${filePath}`;
399
+ try {
400
+ return { lints: await harper.lint(source, filePath, lane, typeof sequence === "number" ? sequence : undefined) };
372
401
  }
373
402
  catch (error) {
403
+ if (error instanceof HarperLintSupersededError) {
404
+ return apiError(reply, 409, "SPELLCHECK_SUPERSEDED");
405
+ }
374
406
  // A missing optional command is an expected fallback condition. Keep it
375
407
  // out of normal logs while preserving diagnostics for an actual failure.
376
408
  if (error instanceof HarperUnavailableError)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "texlite",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "A lightweight local-first collaborative LaTeX workspace",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -1,3 +0,0 @@
1
- import{u as be,r as n,j as e}from"./react-BVUjbTTn.js";import{c as p,M as H,b as x,f as l}from"./index-C0t11tlT.js";import{G as q,C as ie,T as ke}from"./x-Bq29njHL.js";import{K as ye}from"./key-round-DOAxDSfz.js";import{C as ne,R as ve}from"./ProjectWorkspace-C6GrvKN6.js";import{G as ae,M as Ce}from"./minimize-2-BWtbag2I.js";import{M as Ne,a as we}from"./minus-9eVJnugf.js";import{P as ze}from"./plus-BGXipRoG.js";import"./check-BXvzrdoz.js";import"./search-b1ORp1k8.js";import"./user-plus-CKalKSsw.js";import"./refresh-cw-Dvp3O90f.js";const Se=p("CloudUpload",[["path",{d:"M12 13v8",key:"1l5pq0"}],["path",{d:"M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242",key:"1pljnt"}],["path",{d:"m8 17 4-4 4 4",key:"1quai1"}]]);const oe=p("ExternalLink",[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]]);const G=p("Eye",[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0",key:"1nclc0"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]]);const W=p("Github",[["path",{d:"M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4",key:"tonef"}],["path",{d:"M9 18c-4.51 2-5-2-7-2",key:"9comsn"}]]);const Ee=p("RefreshCcw",[["path",{d:"M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",key:"14sxne"}],["path",{d:"M3 3v5h5",key:"1xhq8a"}],["path",{d:"M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16",key:"1hlbsb"}],["path",{d:"M16 16h5v5",key:"ccwih5"}]]);const Te=p("Undo2",[["path",{d:"M9 14 4 9l5-5",key:"102s5s"}],["path",{d:"M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11",key:"f3b9sd"}]]);function Ge({open:h,project:o,onOpenChange:b,onBeforeMutation:r}){const{t:s,i18n:E}=be(),[i,u]=n.useState(null),[J,K]=n.useState([]),[T,k]=n.useState(""),[F,V]=n.useState(()=>re(o.name)),[_,ce]=n.useState(!0),[D,Z]=n.useState(""),[y,M]=n.useState(null),[m,j]=n.useState(null),[U,R]=n.useState(!1),[le,v]=n.useState(!1),[$,L]=n.useState(!0),[P,A]=n.useState(!0),[a,C]=n.useState(""),[Q,N]=n.useState(""),[X,B]=n.useState(""),[g,f]=n.useState(!1),[w,Y]=n.useState(10),O=n.useRef(null),z=async(t=!1)=>{const c=await l(`/api/projects/${o.id}/git`);if(u(c.status),t&&(L(!c.status.tokenConfigured),A(!c.status.remoteUrl)),c.status.initialized&&c.status.latestCommit){const S=await l(`/api/projects/${o.id}/git/history`);K(S.commits)}else K([])};n.useEffect(()=>{if(!h){document.fullscreenElement&&document.exitFullscreen().catch(()=>{}),f(!1),k("");return}N(""),B(""),M(null),f(!1),V(re(o.name)),C("load"),r().then(()=>z(!0)).catch(t=>N(t instanceof Error?t.message:s("errors.generic"))).finally(()=>C(""))},[h,o.id]),n.useEffect(()=>{const t=()=>f(document.fullscreenElement===O.current);return document.addEventListener("fullscreenchange",t),()=>document.removeEventListener("fullscreenchange",t)},[]),n.useEffect(()=>{if(!g)return;const t=c=>{c.key==="Escape"&&!document.fullscreenElement&&f(!1)};return window.addEventListener("keydown",t),()=>window.removeEventListener("keydown",t)},[g]);const d=async(t,c,S,ee)=>{C(t),N(""),B("");try{await c(),S&&B(S)}catch(se){const te=se instanceof Error?se.message:s("errors.generic");N(ee?`${ee}: ${te}`:te)}finally{C("")}},de=()=>d("token",async()=>{const t=await l(`/api/projects/${o.id}/git/token`,{method:"PUT",body:JSON.stringify({token:T})});u(t.status),k(""),L(!1)},s("git.tokenSaved")),he=()=>d("remove-token",async()=>{const t=await l(`/api/projects/${o.id}/git/token`,{method:"DELETE"});u(t.status),k("")},s("git.tokenRemoved")),ge=()=>d("repository",async()=>{const t=await l(`/api/projects/${o.id}/git/repository`,{method:"POST",body:JSON.stringify({name:F,private:_})});u(t.status),A(!1)},s("git.repositoryCreated")),ue=()=>d("commit",async()=>{if(!await r())throw new Error(s("errors.collaborationUnavailable"));const t=await l(`/api/projects/${o.id}/git/commit`,{method:"POST",body:JSON.stringify({message:D})});u(t.status),Z(""),M(null),await z()},s("git.committed")),me=()=>d("push",async()=>{if(!await r())throw new Error(s("errors.collaborationUnavailable"));const t=await l(`/api/projects/${o.id}/git/push`,{method:"POST"});u(t.status),await z()},s("git.pushed"),s("git.pushFailed")),I=t=>d("diff",async()=>{if(!await r())throw new Error(s("errors.collaborationUnavailable"));const c=t?`?revision=${encodeURIComponent(t)}`:"";M(await l(`/api/projects/${o.id}/git/diff${c}`))}),fe=async()=>{if(g){document.fullscreenElement&&await document.exitFullscreen().catch(()=>{}),f(!1);return}const t=O.current;if(t?.requestFullscreen)try{await t.requestFullscreen()}catch{}f(!0)},xe=()=>{const t=m;t&&(j(null),d("checkout",async()=>{if(!await r())throw new Error(s("errors.collaborationUnavailable"));await l(`/api/projects/${o.id}/git/checkout`,{method:"POST",body:JSON.stringify({revision:t==="branch"?null:t.sha,force:U})}),window.location.reload()}))},pe=()=>{v(!1),d("discard",async()=>{if(!await r())throw new Error(s("errors.collaborationUnavailable"));await l(`/api/projects/${o.id}/git/discard`,{method:"POST"}),window.location.reload()})},je=a==="load"&&!i;return e.jsxs(e.Fragment,{children:[e.jsx(H,{open:h,extraWide:!0,className:g?"git-dialog-modal-fullscreen":void 0,title:s("git.title"),description:s("git.description"),onOpenChange:b,footer:e.jsx("button",{onClick:()=>b(!1),children:s("common.close")}),children:e.jsxs("div",{className:"git-dialog",children:[je&&e.jsxs("div",{className:"git-loading",children:[e.jsx(x,{className:"spin",size:22}),s("common.loading")]}),Q&&e.jsx("p",{className:"git-message error",children:Q}),X&&e.jsxs("p",{className:"git-message success",children:[e.jsx(q,{size:14}),X]}),i&&e.jsxs(e.Fragment,{children:[e.jsxs("section",{className:"git-section",children:[e.jsxs("button",{className:"git-section-toggle",type:"button","aria-expanded":$,onClick:()=>L(t=>!t),children:[e.jsxs("span",{className:"git-section-heading",children:[e.jsx(ye,{size:16}),e.jsxs("span",{children:[e.jsx("strong",{children:s("git.authentication")}),e.jsx("small",{children:i.tokenConfigured?s("git.connectedAs",{login:i.githubLogin}):s("git.authenticationHint")})]})]}),$?e.jsx(ne,{size:16}):e.jsx(ie,{size:16})]}),$&&e.jsxs("div",{className:"git-section-content",children:[e.jsxs("div",{className:"git-token-guidance",children:[e.jsx("strong",{children:s("git.recommendedAccessTitle")}),e.jsx("span",{children:s("git.recommendedAccess")})]}),i.tokenConfigured&&e.jsxs("div",{className:"git-account",children:[e.jsx(W,{size:17}),e.jsx("span",{children:s("git.connectedAs",{login:i.githubLogin})}),e.jsxs("button",{className:"danger-text",disabled:!!a,onClick:he,children:[e.jsx(ke,{size:14}),s("git.removeToken")]})]}),e.jsxs("label",{className:"form-field",children:[i.tokenConfigured?s("git.replaceToken"):s("git.token"),e.jsx("input",{type:"password",autoComplete:"new-password",spellCheck:!1,autoCapitalize:"none",autoCorrect:"off",value:T,placeholder:s("git.tokenPlaceholder"),onChange:t=>k(t.target.value)})]}),e.jsxs("div",{className:"git-inline-actions",children:[e.jsxs("a",{href:"https://github.com/settings/personal-access-tokens/new",target:"_blank",rel:"noreferrer",children:[s("git.createToken"),e.jsx(oe,{size:12})]}),e.jsxs("button",{disabled:!!a||T.trim().length<20,onClick:de,children:[a==="token"&&e.jsx(x,{className:"spin",size:13}),s("git.saveToken")]})]})]})]}),e.jsxs("section",{className:"git-section",children:[e.jsxs("button",{className:"git-section-toggle",type:"button","aria-expanded":P,onClick:()=>A(t=>!t),children:[e.jsxs("span",{className:"git-section-heading",children:[e.jsx(W,{size:16}),e.jsxs("span",{children:[e.jsx("strong",{children:s("git.repository")}),e.jsx("small",{children:i.remoteUrl?i.repositoryName??i.remoteUrl:i.initialized?s("git.localReady"):s("git.localPending")})]})]}),P?e.jsx(ne,{size:16}):e.jsx(ie,{size:16})]}),P&&e.jsx("div",{className:"git-section-content",children:i.remoteUrl?e.jsxs("div",{className:"git-repository-card",children:[e.jsxs("div",{children:[e.jsx("strong",{children:i.repositoryName??s("git.origin")}),e.jsxs("small",{children:[i.branch??s("git.detachedAt",{revision:i.latestCommit?.shortSha??"HEAD"})," · ",i.dirty?s("git.changedFiles",{count:i.changedFiles}):s("git.clean")]})]}),i.repositoryHtmlUrl&&e.jsxs("a",{href:i.repositoryHtmlUrl,target:"_blank",rel:"noreferrer",children:[e.jsx(oe,{size:14}),s("git.openGitHub")]})]}):e.jsxs(e.Fragment,{children:[e.jsxs("label",{className:"form-field",children:[s("git.repositoryName"),e.jsx("input",{value:F,onChange:t=>V(t.target.value)})]}),e.jsxs("label",{className:"checkbox-field",children:[e.jsx("input",{type:"checkbox",checked:_,onChange:t=>ce(t.target.checked)}),s("git.privateRepository")]}),e.jsxs("button",{className:"git-primary-action",disabled:!!a||!i.tokenConfigured||!F.trim(),onClick:ge,children:[a==="repository"?e.jsx(x,{className:"spin",size:14}):e.jsx(W,{size:14}),s("git.createRepository")]})]})})]}),i.initialized&&e.jsxs("section",{className:"git-section",children:[e.jsxs("div",{className:"git-section-heading",children:[e.jsx(ae,{size:16}),e.jsxs("div",{children:[e.jsx("strong",{children:s("git.commitAndPush")}),e.jsx("small",{children:i.branch?s("git.identity",{username:o.ownerUsername}):s("git.detachedHead")})]})]}),e.jsxs("label",{className:"form-field",children:[s("git.commitMessage"),e.jsx("textarea",{rows:3,value:D,placeholder:s("git.commitPlaceholder"),onChange:t=>Z(t.target.value)})]}),e.jsxs("div",{className:"git-action-row",children:[e.jsxs("button",{title:i.dirty?void 0:s("git.noChangesToCommit"),disabled:!!a||!i.branch||!i.dirty||!D.trim(),onClick:ue,children:[a==="commit"?e.jsx(x,{className:"spin",size:14}):e.jsx(ae,{size:14}),s("git.commit")]}),e.jsxs("button",{disabled:!!a||!i.branch||!i.tokenConfigured||!i.remoteUrl||!i.latestCommit,onClick:me,children:[a==="push"?e.jsx(x,{className:"spin",size:14}):e.jsx(Se,{size:14}),s("git.push"),i.ahead>0&&e.jsx("span",{className:"git-count",children:i.ahead})]}),e.jsxs("button",{disabled:!!a||!i.latestCommit,onClick:()=>{I()},children:[a==="diff"?e.jsx(x,{className:"spin",size:14}):e.jsx(G,{size:14}),s("git.workingDiff")]}),e.jsxs("button",{className:"git-discard-button",title:i.restorable?s("git.discardChanges"):s("git.noChangesToDiscard"),disabled:!!a||!i.latestCommit||!i.restorable,onClick:()=>v(!0),children:[e.jsx(ve,{size:14}),s("git.discardChanges")]}),!i.branch&&e.jsxs("button",{disabled:!!a,onClick:()=>{R(!1),j("branch")},children:[e.jsx(q,{size:14}),s("git.returnToBranch",{branch:i.defaultBranch})]}),e.jsxs("button",{disabled:!!a,onClick:()=>{d("refresh",z)},children:[e.jsx(Ee,{className:a==="refresh"?"spin":"",size:14}),s("git.refresh")]})]})]}),y&&e.jsxs("section",{ref:O,className:`git-section git-diff-section${g?" is-fullscreen":""}`,children:[e.jsxs("div",{className:"git-diff-heading",children:[e.jsxs("div",{className:"git-section-heading",children:[e.jsx(G,{size:16}),e.jsxs("div",{children:[e.jsx("strong",{children:s("git.diffTitle")}),e.jsxs("small",{children:[y.title,y.truncated?` · ${s("git.diffTruncated")}`:""]})]})]}),e.jsxs("div",{className:"git-diff-controls",children:[e.jsxs("div",{className:"git-diff-font-controls",role:"group","aria-label":s("git.diffFontSize"),children:[e.jsx("button",{type:"button",className:"git-diff-font-button",disabled:w<=8,title:s("git.diffFontDecrease"),"aria-label":s("git.diffFontDecrease"),onClick:()=>Y(t=>Math.max(8,t-1)),children:e.jsx(Ne,{size:14})}),e.jsxs("span",{className:"git-diff-font-value","aria-live":"polite",children:[w,"px"]}),e.jsx("button",{type:"button",className:"git-diff-font-button",disabled:w>=24,title:s("git.diffFontIncrease"),"aria-label":s("git.diffFontIncrease"),onClick:()=>Y(t=>Math.min(24,t+1)),children:e.jsx(ze,{size:14})})]}),e.jsx("button",{type:"button",className:"git-diff-fullscreen",title:s(g?"git.exitFullscreenDiff":"git.fullscreenDiff"),"aria-label":s(g?"git.exitFullscreenDiff":"git.fullscreenDiff"),onClick:()=>{fe()},children:g?e.jsx(Ce,{size:15}):e.jsx(we,{size:15})})]})]}),e.jsx(Fe,{content:y.diff,empty:s("git.noChanges"),fontSize:w})]}),i.initialized&&e.jsxs("section",{className:"git-section",children:[e.jsxs("div",{className:"git-section-heading",children:[e.jsx(q,{size:16}),e.jsxs("div",{children:[e.jsx("strong",{children:s("git.history")}),e.jsx("small",{children:s("git.historyHint")})]})]}),e.jsxs("div",{className:"git-history",children:[J.map(t=>e.jsxs("article",{children:[e.jsx("code",{children:t.shortSha}),e.jsxs("div",{children:[e.jsx("strong",{children:t.message}),e.jsxs("small",{children:[t.authorName," · ",new Date(t.authoredAt).toLocaleString(E.resolvedLanguage)]})]}),e.jsxs("span",{children:[e.jsx("button",{title:s("git.viewCommitDiff"),disabled:!!a,onClick:()=>{I(t.sha)},children:e.jsx(G,{size:14})}),e.jsx("button",{title:s("git.checkoutVersion"),disabled:!!a,onClick:()=>{R(!1),j(t)},children:e.jsx(Te,{size:14})})]})]},t.sha)),J.length===0&&e.jsx("p",{className:"muted",children:s("git.noCommits")})]})]})]})]})}),e.jsx(H,{open:!!m,title:s(m==="branch"?"git.returnTitle":"git.checkoutTitle"),description:m==="branch"?s("git.returnDescription",{branch:i?.defaultBranch??"main"}):s("git.checkoutDescription",{revision:m?.shortSha??"",message:m?.message??""}),onOpenChange:t=>{t||j(null)},footer:e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>j(null),children:s("common.cancel")}),e.jsx("button",{className:U?"danger":"primary",onClick:xe,children:s("git.checkout")})]}),children:e.jsxs("label",{className:"git-force-checkout",children:[e.jsx("input",{type:"checkbox",checked:U,onChange:t=>R(t.target.checked)}),e.jsxs("span",{children:[e.jsx("strong",{children:s("git.forceCheckout")}),e.jsx("small",{children:s("git.forceCheckoutDescription")})]})]})}),e.jsx(H,{open:le,title:s("git.discardTitle"),description:s("git.discardDescription"),onOpenChange:v,footer:e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>v(!1),children:s("common.cancel")}),e.jsx("button",{className:"danger",onClick:pe,children:s("git.discardConfirm")})]}),children:e.jsx(e.Fragment,{})})]})}function Fe({content:h,empty:o,fontSize:b}){return h.trim()?e.jsx("pre",{className:"git-diff",style:{fontSize:`${b}px`},children:h.split(`
2
- `).map((r,s)=>{const E=r.startsWith("+")&&!r.startsWith("+++")?"addition":r.startsWith("-")&&!r.startsWith("---")?"deletion":r.startsWith("@@")?"hunk":r.startsWith("diff ")?"header":"";return e.jsxs("span",{className:E,children:[r,`
3
- `]},s)})}):e.jsx("div",{className:"git-diff-empty",children:o})}function re(h){return h.trim().replace(/\s+/g,"-").replace(/[^A-Za-z0-9._-]/g,"-").replace(/-+/g,"-").replace(/^[.-]+|[.-]+$/g,"").slice(0,100)||"texlite-project"}export{Ge as GitDialog};
@@ -1,7 +0,0 @@
1
- import{u as Se,r as o,j as e}from"./react-BVUjbTTn.js";import{c as Te,f as A,M as Le,b as ee,C as fe}from"./index-C0t11tlT.js";import{d as Fe,C as me}from"./index-BRT5O-Hg.js";import{H as ze,F as De}from"./hard-drive-6uYFyezl.js";import{T as ge,d as Ee}from"./x-Bq29njHL.js";import{F as re,R as pe,S as ke,i as Me}from"./ProjectWorkspace-C6GrvKN6.js";import{G as Pe,M as Ae}from"./minimize-2-BWtbag2I.js";import{M as Be,a as Re}from"./minus-9eVJnugf.js";import{P as Ve}from"./plus-BGXipRoG.js";import"./check-BXvzrdoz.js";import"./search-b1ORp1k8.js";import"./user-plus-CKalKSsw.js";import"./refresh-cw-Dvp3O90f.js";const _e=Te("Tag",[["path",{d:"M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z",key:"vktsd0"}],["circle",{cx:"7.5",cy:"7.5",r:".5",fill:"currentColor",key:"kqv944"}]]);function xe(n,l,j,p="历史版本",s="当前版本",m=3){const L=l.replace(/\r\n/g,`
2
- `),M=j.replace(/\r\n/g,`
3
- `);if(L===M)return{diffText:"",additions:0,deletions:0,hasChanges:!1};const c=new Fe,z=c.diff_linesToChars_(L,M),S=c.diff_main(z.chars1,z.chars2,!1);c.diff_charsToLines_(S,z.lineArray);const N=[];let r=1,D=1,g=0,P=0;for(const[h,y]of S){if(!y)continue;const d=y.split(`
4
- `);d[d.length-1]===""&&d.pop();for(const C of d)h===0?N.push({op:0,text:C,oldNum:r++,newNum:D++}):h===-1?(P++,N.push({op:-1,text:C,oldNum:r++,newNum:D})):h===1&&(g++,N.push({op:1,text:C,oldNum:r,newNum:D++}))}if(g===0&&P===0)return{diffText:"",additions:0,deletions:0,hasChanges:!1};const x=[];let b=-1,u=-1;for(let h=0;h<N.length;h++)if(N[h].op!==0){const y=Math.max(0,h-m),d=Math.min(N.length-1,h+m);b===-1?(b=y,u=d):y<=u+1?u=Math.max(u,d):(x.push({start:b,end:u}),b=y,u=d)}b!==-1&&x.push({start:b,end:u});const V=[`--- a/${n} (${p})`,`+++ b/${n} (${s})`];for(const h of x){const y=N.slice(h.start,h.end+1),d=y.filter($=>$.op<=0).length,C=y.filter($=>$.op>=0).length,_=y[0],J=d===0?0:_.oldNum,B=C===0?0:_.newNum;V.push(`@@ -${J},${d} +${B},${C} @@`);for(const $ of y){const T=$.op===-1?"-":$.op===1?"+":" ";V.push(`${T}${$.text}`)}}return{diffText:V.join(`
5
- `),additions:g,deletions:P,hasChanges:!0}}function oe(n,l){try{const j=new Date(n);if(Number.isNaN(j.getTime()))return n;const p=new Date;return j.getFullYear()===p.getFullYear()&&j.getMonth()===p.getMonth()&&j.getDate()===p.getDate()?j.toLocaleTimeString(l,{hour:"2-digit",minute:"2-digit"}):j.toLocaleString(l,{month:"numeric",day:"numeric",hour:"2-digit",minute:"2-digit"})}catch{return n}}function ae(n,l,j){if(n.label)return n.label;const p=oe(n.createdAt,j);if(n.changedPaths.length===1)return`${n.changedPaths[0].split("/").pop()||n.changedPaths[0]} (${p})`;if(n.changedPaths.length===2){const m=n.changedPaths[0].split("/").pop()||n.changedPaths[0],L=n.changedPaths[1].split("/").pop()||n.changedPaths[1];return`${m}, ${L} (${p})`}if(n.changedPaths.length>2)return`${n.changedPaths[0].split("/").pop()||n.changedPaths[0]} +${n.changedPaths.length-1} (${p})`;const s=n.author?.name||n.author?.username;return s?`${s} @ ${p}`:`${l} (${p})`}const ye=100;function et({open:n,project:l,onOpenChange:j,onBeforeMutation:p}){const{t:s,i18n:m}=Se(),[L,M]=o.useState([]),[c,z]=o.useState(null),[S,N]=o.useState(""),[r,D]=o.useState(null),[g,P]=o.useState(""),[x,b]=o.useState(null),[u,V]=o.useState("commit"),[h,y]=o.useState(11),[d,C]=o.useState(!1),[_,J]=o.useState(""),[B,$]=o.useState(null),[T,K]=o.useState(null),[U,w]=o.useState(""),[le,Z]=o.useState(null),[W,Q]=o.useState(!1),[ce,v]=o.useState(""),se=o.useRef(null),F=o.useRef(null),E=o.useRef(null),ie=l.permission!=="read",de=l.permission==="owner",H=!!U||W;o.useEffect(()=>{if(!n){E.current!==null&&window.clearTimeout(E.current),E.current=null,F.current?.abort(),F.current=null,Q(!1),document.fullscreenElement&&document.exitFullscreen().catch(()=>{}),C(!1);return}F.current?.abort(),F.current=null;const t=new AbortController;return w("load"),v(""),b(null),Z(null),Q(!1),A(`/api/projects/${l.id}/history?limit=${ye}`,{signal:t.signal}).then(i=>{M(i.versions),z(i.stats),Z(i.nextCursor),N(a=>i.versions.some(f=>f.id===a)?a:i.versions[0]?.id??"")}).catch(i=>{te(i)||v(R(i))}).finally(()=>{t.signal.aborted||w("")}),()=>t.abort()},[n,l.id]),o.useEffect(()=>()=>{F.current?.abort(),F.current=null,E.current!==null&&window.clearTimeout(E.current)},[l.id]);const je=async()=>{const t=le;if(!t||W||U)return;F.current?.abort();const i=new AbortController;F.current=i,Q(!0),v("");try{const a=new URLSearchParams({limit:String(ye),before:t}),f=await A(`/api/projects/${l.id}/history?${a.toString()}`,{signal:i.signal});if(i.signal.aborted)return;M(O=>{const we=new Set(O.map(ne=>ne.id));return[...O,...f.versions.filter(ne=>!we.has(ne.id))]}),z(f.stats),Z(f.nextCursor)}catch(a){te(a)||v(R(a))}finally{F.current===i&&(F.current=null,Q(!1))}};o.useEffect(()=>{const t=()=>C(document.fullscreenElement===se.current);return document.addEventListener("fullscreenchange",t),()=>document.removeEventListener("fullscreenchange",t)},[]),o.useEffect(()=>{if(!d)return;const t=i=>{i.key==="Escape"&&!document.fullscreenElement&&C(!1)};return window.addEventListener("keydown",t),()=>window.removeEventListener("keydown",t)},[d]),o.useEffect(()=>{if(!n||!S){D(null);return}const t=new AbortController;return w("detail"),b(null),A(`/api/projects/${l.id}/history/${S}`,{signal:t.signal}).then(i=>{D(i),J(i.version.label??"");const a=i.version.changedPaths.find(f=>i.files.some(O=>O.path===f))??"";P(a)}).catch(i=>{te(i)||v(R(i))}).finally(()=>{t.signal.aborted||w("")}),()=>t.abort()},[n,l.id,S]);const I=x?.previousVersion??null;o.useEffect(()=>{if(!n||!S||!g){b(null);return}const t=new AbortController;w("compare"),v("");const i=u==="commit"?"&against=__previous__":"";return A(`/api/projects/${l.id}/history/${S}/file?path=${encodeURIComponent(g)}${i}`,{signal:t.signal}).then(a=>{b(a)}).catch(a=>{te(a)||v(R(a))}).finally(()=>{t.signal.aborted||w(a=>a==="compare"?"":a)}),()=>t.abort()},[n,l.id,S,g,u]);const k=o.useMemo(()=>{if(!r)return[];const t=new Set(r.version.changedPaths);return r.files.filter(i=>t.has(i.path))},[r]);o.useEffect(()=>{k.length>0&&!k.some(t=>t.path===g)?P(k[0].path):k.length===0&&g&&P("")},[k,g]);const be=async()=>{if(d){document.fullscreenElement&&await document.exitFullscreen().catch(()=>{}),C(!1);return}const t=se.current;if(t?.requestFullscreen)try{await t.requestFullscreen()}catch{}C(!0)},ve=async()=>{if(r){w("label"),v("");try{const t=await A(`/api/projects/${l.id}/history/${r.version.id}`,{method:"PATCH",body:JSON.stringify({label:_.trim()||null})});D(i=>i&&{...i,version:t.version}),M(i=>i.map(a=>a.id===t.version.id?t.version:a)),z(t.stats),t.retentionScheduled&&(E.current!==null&&window.clearTimeout(E.current),E.current=window.setTimeout(()=>{E.current=null,A(`/api/projects/${l.id}/history?limit=1`).then(i=>{z(i.stats)}).catch(()=>{})},t.retentionRefreshAfterMs))}catch(t){v(R(t))}finally{w("")}}},Ne=async()=>{const t=T;if(t){K(null),w("delete"),v("");try{if(!await p())throw new Error(s("errors.collaborationUnavailable"));const i=t==="all"?`/api/projects/${l.id}/history`:`/api/projects/${l.id}/history/${t.id}`,a=await A(i,{method:"DELETE"});if(z(a.stats),t==="all")M([]),N(""),D(null),b(null),Z(null);else{const f=L.filter(O=>O.id!==t.id);M(f),S===t.id&&(N(f[0]?.id??""),D(null),b(null))}}catch(i){v(R(i))}finally{w("")}}},Ce=async()=>{const t=B;if(!(!t||!r)){$(null),w("restore"),v("");try{if(!await p())throw new Error(s("errors.collaborationUnavailable"));await A(`/api/projects/${l.id}/history/${r.version.id}/restore`,{method:"POST",body:JSON.stringify(t==="project"?{}:{path:t})}),window.location.reload()}catch(i){v(R(i)),w("")}}},X=r?.version.author?.name||r?.version.author?.username||(r?.version.reason==="autosave"?s("history.reasons.autosave"):s("editor.deletedUser")),q=r?oe(r.version.createdAt,m.resolvedLanguage):"",G=o.useMemo(()=>{if(!x||!r)return{diffText:"",additions:0,deletions:0,hasChanges:!1};if(u==="commit"){const t=x.comparison,i=x.historical,a=I?`${I.label||s(`history.reasons.${I.reason}`)} (${oe(I.createdAt,m.resolvedLanguage)})`:"/dev/null",f=`${X} @ ${q}`;return xe(x.path,t,i,a,f)}else{const t=x.historical,i=x.comparison,a=`${r.version.label||s(`history.reasons.${r.version.reason}`)} (${q})`,f=s("history.currentVersion");return xe(x.path,t,i,a,f)}},[x,r,u,I,X,q,s,m.resolvedLanguage]),he=r?s(`history.reasons.${r.version.reason}`):"",ue=r?ae(r.version,he,m.resolvedLanguage):"",$e=T&&T!=="all"?ae(T,s(`history.reasons.${T.reason}`),m.resolvedLanguage):"";return e.jsxs(e.Fragment,{children:[e.jsx(Le,{open:n,extraWide:!0,draggable:!0,className:"history-dialog-modal",title:s("history.projectSnapshots"),description:s("history.description"),onOpenChange:j,footer:e.jsx("button",{onClick:()=>j(!1),children:s("common.close")}),children:e.jsxs("div",{className:"history-dialog",children:[ce&&e.jsx("p",{className:"error history-message",children:ce}),U==="load"&&L.length===0?e.jsxs("div",{className:"history-loading",children:[e.jsx(ee,{className:"spin",size:22}),s("common.loading")]}):e.jsxs("div",{className:"history-dialog-layout",children:[e.jsxs("aside",{className:"history-timeline",children:[de&&c&&e.jsxs("div",{className:`history-stats${c.storageLimitExceeded?" exceeded":""}`,children:[e.jsxs("header",{children:[e.jsxs("span",{children:[e.jsx(ze,{size:14}),s("history.storage")]}),e.jsx("button",{type:"button",disabled:H||L.length===0,title:s("history.clearAll"),"aria-label":s("history.clearAll"),onClick:()=>K("all"),children:e.jsx(ge,{size:13})})]}),e.jsx("strong",{children:s("history.storageUsage",{used:Y(c.totalBytes),limit:Y(c.maxStorageBytes)})}),e.jsx("progress",{max:c.maxStorageBytes,value:Math.min(c.totalBytes,c.maxStorageBytes)}),e.jsx("small",{children:s("history.storageBreakdown",{objects:Y(c.objectBytes),metadata:Y(c.metadataBytes),protected:Y(c.protectedBytes)})}),e.jsxs("small",{children:[s("history.versionUsage",{count:c.ordinaryVersionCount,limit:c.maxVersions>0?c.maxVersions:"∞"}),c.labeledVersionCount>0?` · ${s("history.protectedVersions",{count:c.labeledVersionCount})}`:""]}),c.storageLimitExceeded&&e.jsx("small",{className:"history-storage-warning",children:s("history.storageExceeded")})]}),L.map(t=>{const i=s(`history.reasons.${t.reason}`),a=ae(t,i,m.resolvedLanguage),f=t.author?.name||t.author?.username||(t.reason==="autosave"?s("history.reasons.autosave"):s("editor.deletedUser"));return e.jsxs("button",{className:t.id===S?"active":"",onClick:()=>N(t.id),children:[e.jsx(re,{size:15}),e.jsxs("span",{children:[e.jsxs("div",{className:"history-item-heading",children:[e.jsx("strong",{title:a,children:a}),e.jsx("span",{className:`history-reason-badge reason-${t.reason}`,children:i})]}),e.jsx("small",{children:new Date(t.createdAt).toLocaleString(m.resolvedLanguage)}),e.jsxs("small",{children:[f," · ",s("history.changedCount",{count:t.changedPaths.length})]})]})]},t.id)}),le&&e.jsx("div",{className:"history-load-more",children:e.jsxs("button",{type:"button",disabled:H,"aria-busy":W,onClick:()=>{je()},children:[W?e.jsx(ee,{className:"spin",size:13}):e.jsx(me,{size:13}),s(W?"common.loading":"history.loadOlder")]})}),L.length===0&&e.jsx("p",{className:"muted padded",children:s("history.empty")})]}),e.jsx("section",{className:"history-detail",children:r&&e.jsxs(e.Fragment,{children:[e.jsxs("header",{className:"history-detail-header",children:[e.jsxs("div",{children:[e.jsxs("div",{className:"history-detail-title-row",children:[e.jsx("strong",{title:ue,children:ue}),e.jsx("span",{className:`history-reason-badge reason-${r.version.reason}`,children:he})]}),e.jsxs("small",{children:[e.jsx(me,{size:12}),new Date(r.version.createdAt).toLocaleString(m.resolvedLanguage)," · ",X," · ",s("history.fileCount",{count:r.version.fileCount})]})]}),e.jsxs("span",{className:"history-detail-actions",children:[de&&e.jsxs("button",{className:"danger-text",disabled:H,onClick:()=>K(r.version),children:[e.jsx(ge,{size:14}),s("history.deleteVersion")]}),ie&&e.jsxs("button",{className:"danger-text",disabled:H,onClick:()=>$("project"),children:[e.jsx(pe,{size:14}),s("history.restoreProject")]})]})]}),ie&&e.jsxs("div",{className:"history-label",children:[e.jsxs("label",{children:[e.jsx(_e,{size:14}),e.jsx("input",{value:_,maxLength:80,placeholder:s("history.labelPlaceholder"),onChange:t=>J(t.target.value)})]}),e.jsxs("button",{disabled:H||_.trim()===(r.version.label??""),onClick:()=>{ve()},children:[U==="label"?e.jsx(ee,{className:"spin",size:13}):e.jsx(ke,{size:13}),s("history.saveLabel")]})]}),e.jsxs("div",{className:"history-files",children:[e.jsxs("div",{className:"history-files-toolbar",children:[e.jsxs("div",{className:"history-files-heading",children:[e.jsx(De,{size:13}),e.jsx("span",{children:s("history.changedFilesHeading",{count:k.length})})]}),e.jsx("div",{className:"history-file-actions",children:ie&&e.jsxs("button",{disabled:!g||H,onClick:()=>$(g),children:[e.jsx(pe,{size:13}),s("history.restoreFile")]})})]}),k.length>0?e.jsx("div",{className:"history-file-list",children:k.map(t=>e.jsxs("button",{type:"button",className:t.path===g?"active":"",title:t.path,onClick:()=>P(t.path),children:[e.jsx(Ee,{size:13}),e.jsx("span",{children:t.path})]},t.path))}):e.jsx("p",{className:"muted padded-small",children:s("history.noChangedFiles")})]}),e.jsx("div",{ref:se,className:`history-comparison${d?" is-fullscreen":""}`,children:g?e.jsxs(e.Fragment,{children:[e.jsxs("header",{children:[e.jsxs("div",{className:"history-comparison-meta",children:[e.jsx("strong",{children:g}),e.jsxs("small",{className:"history-commit-summary",children:[u==="commit"?e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"history-commit-author",children:X}),e.jsx("span",{children:I?s("history.changedAt",{time:q}):s("history.createdAt",{time:q})})]}):e.jsx("span",{children:s("history.comparingCurrent")}),G.hasChanges&&e.jsxs("span",{className:"history-diff-stats",children:[e.jsxs("span",{className:"addition",children:["+",G.additions]})," ",e.jsxs("span",{className:"deletion",children:["-",G.deletions]})]})]})]}),e.jsxs("div",{className:"history-diff-controls",children:[e.jsxs("div",{className:"history-mode-toggle",role:"group","aria-label":s("history.diffMode"),children:[e.jsxs("button",{type:"button",className:u==="commit"?"active":"",onClick:()=>V("commit"),title:s("history.diffModeCommitHint"),children:[e.jsx(Pe,{size:13}),e.jsx("span",{children:s("history.diffModeCommit")})]}),e.jsxs("button",{type:"button",className:u==="current"?"active":"",onClick:()=>V("current"),title:s("history.diffModeCurrentHint"),children:[e.jsx(re,{size:13}),e.jsx("span",{children:s("history.diffModeCurrent")})]})]}),e.jsxs("div",{className:"git-diff-font-controls",role:"group","aria-label":s("git.diffFontSize"),children:[e.jsx("button",{type:"button",className:"git-diff-font-button",disabled:h<=8,title:s("git.diffFontDecrease"),"aria-label":s("git.diffFontDecrease"),onClick:()=>y(t=>Math.max(8,t-1)),children:e.jsx(Be,{size:14})}),e.jsxs("span",{className:"git-diff-font-value","aria-live":"polite",children:[h,"px"]}),e.jsx("button",{type:"button",className:"git-diff-font-button",disabled:h>=24,title:s("git.diffFontIncrease"),"aria-label":s("git.diffFontIncrease"),onClick:()=>y(t=>Math.min(24,t+1)),children:e.jsx(Ve,{size:14})})]}),e.jsx("button",{type:"button",className:"git-diff-fullscreen",title:s(d?"git.exitFullscreenDiff":"git.fullscreenDiff"),"aria-label":s(d?"git.exitFullscreenDiff":"git.fullscreenDiff"),onClick:()=>{be()},children:d?e.jsx(Ae,{size:15}):e.jsx(Re,{size:15})})]})]}),U==="compare"&&!x?e.jsxs("div",{className:"history-comparison-empty",children:[e.jsx(ee,{className:"spin",size:24}),e.jsx("span",{children:s("common.loading")})]}):G.hasChanges?e.jsx("pre",{className:"git-diff history-git-diff",style:{fontSize:`${h}px`},children:G.diffText.split(`
6
- `).map((t,i)=>{const a=t.startsWith("+")&&!t.startsWith("+++")?"addition":t.startsWith("-")&&!t.startsWith("---")?"deletion":t.startsWith("@@")?"hunk":t.startsWith("diff ")||t.startsWith("---")||t.startsWith("+++")?"header":"";return e.jsxs("span",{className:a,children:[t,`
7
- `]},i)})}):e.jsxs("div",{className:"git-diff-empty history-diff-empty",children:[e.jsx(Me,{size:24}),e.jsx("span",{children:s(u==="commit"?"history.noDifferencesInCommit":"history.noDifferences")})]})]}):e.jsxs("div",{className:"history-comparison-empty",children:[e.jsx(re,{size:25}),e.jsx("span",{children:k.length===0?s("history.noChangedFiles"):s("history.chooseFile")})]})})]})})]})]})}),e.jsx(fe,{open:!!B,title:s(B==="project"?"history.restoreProjectTitle":"history.restoreFileTitle"),description:B==="project"?s("history.restoreProjectDescription"):s("history.restoreFileDescription",{path:B??""}),confirmLabel:s("history.restore"),danger:!0,onCancel:()=>$(null),onConfirm:()=>{Ce()}}),e.jsx(fe,{open:!!T,title:s(T==="all"?"history.clearAllTitle":"history.deleteVersionTitle"),description:T==="all"?s("history.clearAllDescription"):s("history.deleteVersionDescription",{name:$e}),confirmLabel:s(T==="all"?"history.clearAll":"history.deleteVersion"),danger:!0,onCancel:()=>K(null),onConfirm:()=>{Ne()}})]})}function Y(n){return n<1024?`${n} B`:n<1024*1024?`${Math.round(n/102.4)/10} KB`:n<1024*1024*1024?`${Math.round(n/(1024*1024)*10)/10} MB`:`${Math.round(n/(1024*1024*1024)*10)/10} GB`}function R(n){return n instanceof Error?n.message:String(n)}function te(n){return n instanceof Error&&n.name==="AbortError"}export{et as HistoryDialog};