texlite 0.9.2 → 0.9.4

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 (68) hide show
  1. package/DESIGN.md +29 -6
  2. package/OPERATIONS.md +9 -7
  3. package/README.md +6 -3
  4. package/README.zh-CN.md +6 -2
  5. package/dist/client/assets/AdminUsers-B12FJUMy.js +1 -0
  6. package/dist/client/assets/CitationLibraryDialog-BTmnCP4V.js +1 -0
  7. package/dist/client/assets/Dashboard-JA5_M3TK.js +2 -0
  8. package/dist/client/assets/{GitDialog-CTWAU8q6.js → GitDialog--MoUIW0n.js} +1 -1
  9. package/dist/client/assets/{HistoryDialog-Mo4p8HEg.js → HistoryDialog-LgKbjsTZ.js} +1 -1
  10. package/dist/client/assets/LatexEditor-Dj2zIaQm.js +138 -0
  11. package/dist/client/assets/{PdfPreview-BUpELZd-.js → PdfPreview-C_W7w360.js} +1 -1
  12. package/dist/client/assets/ProjectIconPickerDialog-GzRuM0oQ.js +1 -0
  13. package/dist/client/assets/{ProjectNavigationDialogs-CpjkXZTz.js → ProjectNavigationDialogs-BiArHmQ9.js} +1 -1
  14. package/dist/client/assets/ProjectSettings-DgJohWUv.js +1 -0
  15. package/dist/client/assets/ProjectWorkspace-C6sYiBF2.js +32 -0
  16. package/dist/client/assets/{SelectionHistoryDialog-9FWlc3Rb.js → SelectionHistoryDialog-Bpv6nvrC.js} +2 -2
  17. package/dist/client/assets/SystemMetricsDialog-hJQATmd4.js +1 -0
  18. package/dist/client/assets/{TagManagementDialog-BHIA0vEA.js → TagManagementDialog-BHC8qULq.js} +1 -1
  19. package/dist/client/assets/{WordCountDialog-DFIWvLrP.js → WordCountDialog-DZhhO94k.js} +1 -1
  20. package/dist/client/assets/check-C1db8iNn.js +1 -0
  21. package/dist/client/assets/{editor-3rgn4IYh.js → editor-B9HNwtsd.js} +1 -1
  22. package/dist/client/assets/hard-drive-BWZnOgnm.js +1 -0
  23. package/dist/client/assets/index-BiwJj0Np.css +1 -0
  24. package/dist/client/assets/{index-CNTGCh7t.js → index-kTamGfTl.js} +1 -1
  25. package/dist/client/assets/index-p1NHkGGG.js +12 -0
  26. package/dist/client/assets/{key-round-BjL9H8-H.js → key-round-CoDwyVJc.js} +1 -1
  27. package/dist/client/assets/{minimize-2-DDCq8c49.js → minimize-2-D100opW2.js} +1 -1
  28. package/dist/client/assets/{minus-DgKZCT9F.js → minus-DDTYB-Oz.js} +1 -1
  29. package/dist/client/assets/{plus-CezUzSIZ.js → plus-BVXOlQXP.js} +1 -1
  30. package/dist/client/assets/{refresh-cw-CDFNL-qu.js → refresh-cw-LtwnqlWh.js} +1 -1
  31. package/dist/client/assets/{search-CrDJjHoj.js → search-DXo_aAp0.js} +1 -1
  32. package/dist/client/assets/{sigma-DOcJr7VG.js → sigma-BgvHGPto.js} +1 -1
  33. package/dist/client/assets/{spellCheck-BgWyesvf.js → spellCheck-BtRppKcC.js} +1 -1
  34. package/dist/client/assets/{tags-BedQ75lh.js → tags-m9kppdi0.js} +1 -1
  35. package/dist/client/assets/{type-glZNdzZR.js → type-YO4aprqE.js} +1 -1
  36. package/dist/client/assets/{user-plus-gQ6q5Nom.js → user-plus-MRmKeDtX.js} +1 -1
  37. package/dist/client/assets/x-eisRQJv_.js +1 -0
  38. package/dist/client/index.html +2 -2
  39. package/dist/client/pdf-download.svg +2 -0
  40. package/dist/client/pdf-file.svg +19 -0
  41. package/dist/client/tex-file.svg +7 -0
  42. package/dist/server/cli.js +5 -2
  43. package/dist/server/collaboration.js +6 -19
  44. package/dist/server/db.js +91 -10
  45. package/dist/server/latexDocumentGraph.js +127 -16
  46. package/dist/server/projectOutline.js +103 -91
  47. package/dist/server/projectReferences.js +1 -1
  48. package/dist/server/routes/comments.js +10 -4
  49. package/dist/server/routes/projectFiles.js +6 -1
  50. package/dist/server/routes/projectShared.js +33 -12
  51. package/dist/shared/collaborationProtocol.js +31 -0
  52. package/dist/shared/latexDependencies.js +83 -23
  53. package/dist/shared/writingChecks.js +2 -2
  54. package/package.json +4 -3
  55. package/dist/client/assets/AdminUsers-Buf5ZclR.js +0 -1
  56. package/dist/client/assets/CitationLibraryDialog-DrKbIlD-.js +0 -1
  57. package/dist/client/assets/Dashboard-DLk2j3oq.js +0 -2
  58. package/dist/client/assets/LatexEditor-CwvakYva.js +0 -135
  59. package/dist/client/assets/ProjectIconPickerDialog-BiWSwDdt.js +0 -1
  60. package/dist/client/assets/ProjectSettings-DoxitHbX.js +0 -1
  61. package/dist/client/assets/ProjectWorkspace-iNqXZlsj.js +0 -29
  62. package/dist/client/assets/SystemMetricsDialog-24kifZO6.js +0 -1
  63. package/dist/client/assets/check-C3GZh6a1.js +0 -1
  64. package/dist/client/assets/chevron-left-l1oupvGO.js +0 -1
  65. package/dist/client/assets/hard-drive-B-NSDVVA.js +0 -1
  66. package/dist/client/assets/index-CrlfruX_.css +0 -1
  67. package/dist/client/assets/index-DIM_KiG2.js +0 -12
  68. package/dist/client/assets/x-CSzFikn2.js +0 -1
@@ -1,45 +1,55 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
- import { listProjectFilesAsync, resolveSourcePath, safeRelativePath } from "./files.js";
4
- const levels = { part: 0, chapter: 0, section: 1, subsection: 2, subsubsection: 3, paragraph: 4 };
5
- const commandPattern = /\\(part|chapter|section|subsection|subsubsection|paragraph)\*?(?:\s*\[[^\]]*\])?\s*\{|\\(?:input|include|subfile)\s*\{/g;
2
+ import { maskLatexComments } from "../shared/latexLiterals.js";
3
+ import { findLatexSourceIncludes } from "../shared/latexDependencies.js";
4
+ import { forEachLatexCommand, readLatexMandatoryArguments } from "../shared/latexScanner.js";
5
+ import { resolveLatexInclude, toLatexIncludeDirective } from "./latexDocumentGraph.js";
6
+ import { listProjectFiles, listProjectFilesAsync, resolveSourcePath, safeRelativePath } from "./files.js";
7
+ const levels = new Map([
8
+ ["part", 0],
9
+ ["chapter", 0],
10
+ ["section", 1],
11
+ ["subsection", 2],
12
+ ["subsubsection", 3],
13
+ ["paragraph", 4]
14
+ ]);
6
15
  export function buildProjectOutline(config, projectId, mainFileInput) {
7
16
  const mainFile = safeRelativePath(mainFileInput);
17
+ return buildProjectOutlineFromFiles(config, projectId, mainFile, sourceFilePaths(listProjectFiles(config, projectId)));
18
+ }
19
+ function buildProjectOutlineFromFiles(config, projectId, mainFile, availableFiles) {
8
20
  const result = [];
9
21
  const visited = new Set();
10
- const visit = (filePath) => {
22
+ const visit = (filePath, importBase) => {
11
23
  if (visited.has(filePath) || visited.size >= 200)
12
24
  return;
13
25
  visited.add(filePath);
14
26
  const absolute = resolveSourcePath(config, projectId, filePath);
15
27
  if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size > 3 * 1024 * 1024)
16
28
  return;
17
- const content = stripCommentsPreserveLines(fs.readFileSync(absolute, "utf8"));
18
- const lineStarts = sourceLineStarts(content);
19
- commandPattern.lastIndex = 0;
20
- for (const command of content.matchAll(commandPattern)) {
21
- const start = (command.index ?? 0) + command[0].length;
22
- const argument = balancedArgument(content, start);
23
- if (!argument)
24
- continue;
25
- if (command[1]) {
26
- result.push({ path: filePath, line: lineAtOffset(lineStarts, command.index ?? 0), level: levels[command[1]], title: cleanTitle(argument.value) });
29
+ const content = fs.readFileSync(absolute, "utf8");
30
+ for (const event of scanOutlineSource(content)) {
31
+ if (event.type === "heading") {
32
+ result.push({ path: filePath, line: event.line, level: event.level, title: event.title });
27
33
  continue;
28
34
  }
29
- const included = resolveIncludedFile(config, projectId, filePath, argument.value);
35
+ const included = resolveLatexInclude(event.directive, importBase, availableFiles);
30
36
  if (included)
31
- visit(included);
37
+ visit(included.path, included.importBase);
32
38
  }
33
39
  };
34
- visit(mainFile);
40
+ visit(mainFile, "");
35
41
  return result;
36
42
  }
37
43
  /** Async outline builder used by the HTTP path so large projects do not block the event loop. */
38
44
  export async function buildProjectOutlineAsync(config, projectId, mainFileInput) {
39
45
  const mainFile = safeRelativePath(mainFileInput);
46
+ const availableFiles = sourceFilePaths(await listProjectFilesAsync(config, projectId));
47
+ return buildProjectOutlineAsyncFromFiles(config, projectId, mainFile, availableFiles);
48
+ }
49
+ async function buildProjectOutlineAsyncFromFiles(config, projectId, mainFile, availableFiles) {
40
50
  const result = [];
41
51
  const visited = new Set();
42
- const visit = async (filePath) => {
52
+ const visit = async (filePath, importBase) => {
43
53
  if (visited.has(filePath) || visited.size >= 200)
44
54
  return;
45
55
  visited.add(filePath);
@@ -54,28 +64,22 @@ export async function buildProjectOutlineAsync(config, projectId, mainFileInput)
54
64
  return;
55
65
  let content;
56
66
  try {
57
- content = stripCommentsPreserveLines(await fs.promises.readFile(resolveSourcePath(config, projectId, filePath), "utf8"));
67
+ content = await fs.promises.readFile(resolveSourcePath(config, projectId, filePath), "utf8");
58
68
  }
59
69
  catch {
60
70
  return;
61
71
  }
62
- const lineStarts = sourceLineStarts(content);
63
- const pattern = new RegExp(commandPattern.source, commandPattern.flags);
64
- for (const command of content.matchAll(pattern)) {
65
- const start = (command.index ?? 0) + command[0].length;
66
- const argument = balancedArgument(content, start);
67
- if (!argument)
68
- continue;
69
- if (command[1]) {
70
- result.push({ path: filePath, line: lineAtOffset(lineStarts, command.index ?? 0), level: levels[command[1]], title: cleanTitle(argument.value) });
72
+ for (const event of scanOutlineSource(content)) {
73
+ if (event.type === "heading") {
74
+ result.push({ path: filePath, line: event.line, level: event.level, title: event.title });
71
75
  continue;
72
76
  }
73
- const included = await resolveIncludedFileAsync(config, projectId, filePath, argument.value);
77
+ const included = resolveLatexInclude(event.directive, importBase, availableFiles);
74
78
  if (included)
75
- await visit(included);
79
+ await visit(included.path, included.importBase);
76
80
  }
77
81
  };
78
- await visit(mainFile);
82
+ await visit(mainFile, "");
79
83
  return result;
80
84
  }
81
85
  /**
@@ -119,7 +123,7 @@ export class ProjectOutlineService {
119
123
  cached.touched = Date.now();
120
124
  return cached.outline;
121
125
  }
122
- const outline = await buildProjectOutlineAsync(this.config, projectId, mainFile);
126
+ const outline = await buildProjectOutlineAsyncFromFiles(this.config, projectId, mainFile, sourceFilePaths(entries));
123
127
  this.cache.set(key, { signature, outline, touched: Date.now() });
124
128
  if (this.cache.size > 64) {
125
129
  const oldest = [...this.cache.entries()].sort((left, right) => left[1].touched - right[1].touched)[0];
@@ -129,65 +133,51 @@ export class ProjectOutlineService {
129
133
  return outline;
130
134
  }
131
135
  }
132
- function resolveIncludedFile(config, projectId, currentFile, value) {
133
- const raw = value.trim();
134
- if (!raw || /[\\#]/.test(raw))
135
- return null;
136
- const withExtension = path.posix.extname(raw) ? raw : `${raw}.tex`;
137
- const candidates = [withExtension, path.posix.join(path.posix.dirname(currentFile), withExtension)];
138
- for (const candidate of [...new Set(candidates)]) {
139
- try {
140
- const safe = safeRelativePath(candidate);
141
- if (fs.existsSync(resolveSourcePath(config, projectId, safe)))
142
- return safe;
143
- }
144
- catch { /* Ignore includes outside the project. */ }
145
- }
146
- return null;
136
+ function sourceFilePaths(entries) {
137
+ return new Set(entries.filter((entry) => entry.type === "file").map((entry) => entry.path));
147
138
  }
148
- async function resolveIncludedFileAsync(config, projectId, currentFile, value) {
149
- const raw = value.trim();
150
- if (!raw || /[\\#]/.test(raw))
151
- return null;
152
- const withExtension = path.posix.extname(raw) ? raw : `${raw}.tex`;
153
- const candidates = [withExtension, path.posix.join(path.posix.dirname(currentFile), withExtension)];
154
- for (const candidate of [...new Set(candidates)]) {
155
- try {
156
- const safe = safeRelativePath(candidate);
157
- const stat = await fs.promises.stat(resolveSourcePath(config, projectId, safe));
158
- if (stat.isFile())
159
- return safe;
160
- }
161
- catch { /* Ignore includes outside the project. */ }
162
- }
163
- return null;
164
- }
165
- function balancedArgument(source, start) {
166
- let depth = 1;
167
- for (let index = start; index < source.length; index += 1) {
168
- if (source[index] === "{" && !escapedAt(source, index))
169
- depth += 1;
170
- if (source[index] === "}" && !escapedAt(source, index))
171
- depth -= 1;
172
- if (depth === 0)
173
- return { value: source.slice(start, index), end: index };
139
+ /**
140
+ * Read one source file into ordered outline events. The shared scanner keeps
141
+ * offsets from the original source while skipping comments and literal TeX
142
+ * forms, so the synchronous and asynchronous builders cannot drift in their
143
+ * interpretation of headings and arguments.
144
+ */
145
+ function scanOutlineSource(source) {
146
+ const lineStarts = sourceLineStarts(source);
147
+ const events = [];
148
+ forEachLatexCommand(source, (command) => {
149
+ const level = levels.get(command.name);
150
+ if (level === undefined)
151
+ return;
152
+ const argument = readLatexMandatoryArguments(source, command.to, 1)[0];
153
+ if (!argument)
154
+ return;
155
+ const value = source.slice(argument.contentFrom, argument.contentTo);
156
+ events.push({
157
+ type: "heading",
158
+ from: command.from,
159
+ line: lineAtOffset(lineStarts, command.from),
160
+ level,
161
+ title: cleanTitle(maskLatexComments(value))
162
+ });
163
+ });
164
+ for (const reference of findLatexSourceIncludes(source)) {
165
+ const directive = toLatexIncludeDirective(reference);
166
+ if (!isStaticOutlineInclude(directive))
167
+ continue;
168
+ events.push({
169
+ type: "include",
170
+ from: reference.from,
171
+ directive
172
+ });
174
173
  }
175
- return null;
176
- }
177
- function stripCommentsPreserveLines(source) {
178
- return source.split(/(?<=\n)/).map((line) => {
179
- for (let index = 0; index < line.length; index += 1) {
180
- if (line[index] === "%" && !escapedAt(line, index))
181
- return `${line.slice(0, index)}${line.endsWith("\n") ? "\n" : ""}`;
182
- }
183
- return line;
184
- }).join("");
185
- }
186
- function escapedAt(source, index) {
187
- let slashes = 0;
188
- for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1)
189
- slashes += 1;
190
- return slashes % 2 === 1;
174
+ return events.sort((left, right) => {
175
+ if (left.from !== right.from)
176
+ return left.from - right.from;
177
+ if (left.type === right.type)
178
+ return 0;
179
+ return left.type === "heading" ? -1 : 1;
180
+ });
191
181
  }
192
182
  function sourceLineStarts(source) {
193
183
  const starts = [0];
@@ -209,5 +199,27 @@ function lineAtOffset(starts, offset) {
209
199
  return Math.max(1, low);
210
200
  }
211
201
  function cleanTitle(value) {
212
- return value.replace(/\\(?:texorpdfstring|MakeUppercase)\s*\{([^{}]*)\}(?:\{[^{}]*\})?/g, "$1").replace(/\\[a-zA-Z@]+\*?/g, "").replace(/[{}]/g, "").trim() || value.trim();
202
+ const cleaned = value
203
+ .replace(/\\(?:texorpdfstring|MakeUppercase)\s*\{([^{}]*)\}(?:\{[^{}]*\})?/g, "$1")
204
+ .replace(/\\[a-zA-Z@]+\*?/g, "")
205
+ .replace(/[{}]/g, "");
206
+ return compactOutlineWhitespace(cleaned) || compactOutlineWhitespace(value);
207
+ }
208
+ /**
209
+ * `maskLatexComments` intentionally keeps source offsets intact, so a long
210
+ * comment becomes a long run of spaces. Outline titles are display-only: keep
211
+ * their original line breaks but collapse that offset-preserving padding.
212
+ */
213
+ function compactOutlineWhitespace(value) {
214
+ return value.replace(/[^\S\r\n]{2,}/g, " ").trim();
215
+ }
216
+ /**
217
+ * TeX paths containing a command or parameter marker need macro expansion.
218
+ * Do not guess their target while building an outline. The generic document
219
+ * graph deliberately supports backslash-separated paths for other consumers,
220
+ * while the legacy outline already skipped these dynamic forms.
221
+ */
222
+ function isStaticOutlineInclude(directive) {
223
+ return !/[\\#]/.test(directive.path)
224
+ && (directive.directory === undefined || !/[\\#]/.test(directive.directory));
213
225
  }
@@ -95,7 +95,7 @@ function citationLookupOrder(sourceDirectives, documentSources, bibPaths, source
95
95
  if (!directives)
96
96
  continue;
97
97
  for (const bibliography of directives.bibliographies) {
98
- const resolved = resolveProjectReferencePath(bibliography, sourcePath, ".bib", bibPathSet, true);
98
+ const resolved = resolveProjectReferencePath(bibliography, sourcePath, ".bib", bibPathSet, "project-root-then-source-file");
99
99
  if (resolved)
100
100
  declaredBibliographies.push(resolved);
101
101
  }
@@ -6,7 +6,7 @@ import { resolveSourcePath, safeRelativePath } from "../files.js";
6
6
  import { apiError, httpError } from "../http.js";
7
7
  import { accessibleProject } from "../projects.js";
8
8
  import { commentMentionForUser, createCommentMentions, listCommentMentions, markAllCommentMentionsRead, markCommentMentionRead, markCommentMentionsResolved, mentionableUsersForProject } from "../commentMentions.js";
9
- import { commentsForFile, now, repliesForComment, text } from "./projectShared.js";
9
+ import { commentsForFile, commentsForProject, now, repliesForComment, text } from "./projectShared.js";
10
10
  /** Register source-anchored comments and reply routes. */
11
11
  export function registerCommentRoutes(app, context) {
12
12
  const { config, db, collaboration, projectMutations } = context;
@@ -75,12 +75,18 @@ export function registerCommentRoutes(app, context) {
75
75
  if (!user)
76
76
  return;
77
77
  const { id } = request.params;
78
- const { path: filePath } = request.query;
78
+ const { path: filePath, scope } = request.query;
79
79
  if (!accessibleProject(db, id, user))
80
80
  return apiError(reply, 404, "PROJECT_NOT_FOUND");
81
- const relative = safeRelativePath(filePath ?? "");
81
+ if (scope !== undefined && scope !== "project")
82
+ return apiError(reply, 400, "REQUEST_INVALID");
83
+ if (scope === "project" && filePath !== undefined)
84
+ return apiError(reply, 400, "REQUEST_INVALID");
85
+ const load = scope === "project"
86
+ ? () => commentsForProject(db, config, id)
87
+ : () => commentsForFile(db, config, id, safeRelativePath(filePath ?? ""));
82
88
  return {
83
- comments: await projectMutations.runConsistentRead(id, () => commentsForFile(db, config, id, relative), {
89
+ comments: await projectMutations.runConsistentRead(id, load, {
84
90
  preflight: () => {
85
91
  if (!accessibleProject(db, id, user))
86
92
  throw httpError(404, "PROJECT_NOT_FOUND");
@@ -263,7 +263,12 @@ export function registerProjectFileRoutes(app, context) {
263
263
  throw httpError(409, "PATH_EXISTS", { path: destination });
264
264
  }
265
265
  };
266
- return await projectMutations.runExclusive(id, "move project path", () => {
266
+ // Moving/renaming is collaboration-aware: movePath() remaps the live Yjs
267
+ // texts and publishes a files event. It therefore needs the ordinary
268
+ // durable write lock, not maintenance mode. Maintenance resets the Yjs
269
+ // epoch and makes every connected browser reload after an otherwise safe
270
+ // drag-and-drop move.
271
+ return await projectMutations.runWrite(id, () => {
267
272
  fs.renameSync(sourceAbsolute, destinationAbsolute);
268
273
  let hasCommentUpdates = false;
269
274
  db.exec("BEGIN IMMEDIATE");
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { reanchorFileComments, offsetToLine } from "../anchors.js";
3
- import { maxCollaborativeFileBytes } from "../collaboration.js";
3
+ import { isCollaborativeTextFile, maxCollaborativeFileBytes } from "../collaboration.js";
4
4
  import { listProjectFiles, resolveSourcePath } from "../files.js";
5
5
  import { httpError, ValidationError } from "../http.js";
6
6
  import { accessibleProject, canEdit } from "../projects.js";
@@ -154,8 +154,7 @@ export function requireActualProjectOwner(db, projectId, user) {
154
154
  return project;
155
155
  }
156
156
  export function projectTextSnapshot(config, projectId) {
157
- const versionedText = (filePath) => /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
158
- return new Map(listProjectFiles(config, projectId).filter((entry) => entry.type === "file" && versionedText(entry.path)).map((entry) => {
157
+ return new Map(listProjectFiles(config, projectId).filter((entry) => entry.type === "file" && isCollaborativeTextFile(entry.path)).map((entry) => {
159
158
  const absolute = resolveSourcePath(config, projectId, entry.path);
160
159
  return [entry.path, fs.statSync(absolute).size <= maxCollaborativeFileBytes(config) ? fs.readFileSync(absolute, "utf8") : ""];
161
160
  }));
@@ -189,21 +188,34 @@ export function repliesForComment(db, commentId) {
189
188
  editedAt: reply.edited_at
190
189
  }));
191
190
  }
192
- export function commentsForFile(db, config, projectId, filePath) {
193
- const absolute = resolveSourcePath(config, projectId, filePath);
194
- const source = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "";
191
+ function commentsForScope(db, config, projectId, filePath) {
192
+ const hasFileScope = typeof filePath === "string";
193
+ const fileClause = hasFileScope ? " AND c.file_path = ?" : "";
194
+ // File-scoped reads retain chronological order for backwards compatibility.
195
+ // A project review is grouped by file and source position so next/previous
196
+ // follows the manuscript rather than arbitrary database insertion order.
197
+ const commentOrder = hasFileScope
198
+ ? "c.created_at"
199
+ : "c.file_path COLLATE NOCASE, c.start_line, c.created_at";
195
200
  const rows = db.prepare(`SELECT c.*, u.username AS author_username, u.display_name AS author_display_name FROM comments c
196
- LEFT JOIN users u ON u.id = c.author_id WHERE c.project_id = ? AND c.file_path = ? ORDER BY c.created_at`)
197
- .all(projectId, filePath);
201
+ LEFT JOIN users u ON u.id = c.author_id WHERE c.project_id = ?${fileClause} ORDER BY ${commentOrder}`)
202
+ .all(projectId, ...(hasFileScope ? [filePath] : []));
198
203
  if (!rows.length)
199
204
  return [];
205
+ const sourceByPath = new Map();
206
+ for (const row of rows) {
207
+ if (sourceByPath.has(row.file_path))
208
+ continue;
209
+ const absolute = resolveSourcePath(config, projectId, row.file_path);
210
+ sourceByPath.set(row.file_path, fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : "");
211
+ }
200
212
  const replies = db.prepare(`SELECT reply.*, user.username AS author_username, user.display_name AS author_display_name
201
213
  FROM comment_replies reply
202
214
  JOIN comments c ON c.id = reply.comment_id
203
215
  LEFT JOIN users user ON user.id = reply.author_id
204
- WHERE c.project_id = ? AND c.file_path = ?
216
+ WHERE c.project_id = ?${fileClause}
205
217
  ORDER BY reply.created_at`)
206
- .all(projectId, filePath);
218
+ .all(projectId, ...(hasFileScope ? [filePath] : []));
207
219
  const replyMap = new Map();
208
220
  for (const reply of replies) {
209
221
  const list = replyMap.get(reply.comment_id) ?? [];
@@ -221,14 +233,15 @@ export function commentsForFile(db, config, projectId, filePath) {
221
233
  }
222
234
  return rows.map((comment) => ({
223
235
  id: comment.id,
236
+ filePath: comment.file_path,
224
237
  authorId: comment.author_id,
225
238
  authorUsername: comment.author_username,
226
239
  authorDisplayName: comment.author_display_name,
227
240
  selectedText: comment.selected_text,
228
241
  startOffset: comment.start_offset,
229
242
  endOffset: comment.end_offset,
230
- startLine: offsetToLine(source, comment.start_offset),
231
- endLine: offsetToLine(source, comment.end_offset),
243
+ startLine: offsetToLine(sourceByPath.get(comment.file_path) ?? "", comment.start_offset),
244
+ endLine: offsetToLine(sourceByPath.get(comment.file_path) ?? "", comment.end_offset),
232
245
  content: comment.content,
233
246
  resolved: Boolean(comment.resolved),
234
247
  orphaned: Boolean(comment.orphaned),
@@ -238,3 +251,11 @@ export function commentsForFile(db, config, projectId, filePath) {
238
251
  replies: replyMap.get(comment.id) ?? []
239
252
  }));
240
253
  }
254
+ /** Comments anchored in one source file, used for editor decorations. */
255
+ export function commentsForFile(db, config, projectId, filePath) {
256
+ return commentsForScope(db, config, projectId, filePath);
257
+ }
258
+ /** All project comments, grouped in manuscript order for the review drawer. */
259
+ export function commentsForProject(db, config, projectId) {
260
+ return commentsForScope(db, config, projectId);
261
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Wire-level constants and payload shapes shared by the browser and server.
3
+ *
4
+ * These values are part of the persisted collaboration contract. Do not
5
+ * renumber a message or change a payload shape in place: make an intentional
6
+ * protocol-version migration instead.
7
+ */
8
+ export const CollaborationMessageType = {
9
+ Sync: 0,
10
+ Awareness: 1,
11
+ QueryAwareness: 3,
12
+ Flush: 4,
13
+ Protocol: 5,
14
+ Maintenance: 6,
15
+ Permission: 7,
16
+ // A small, non-Yjs handshake message for ephemeral compile metadata.
17
+ // Source text still uses the normal Yjs sync protocol.
18
+ CompileStates: 8,
19
+ // Ephemeral per-file formatter leases, intentionally outside Yjs state.
20
+ FormatLease: 9
21
+ };
22
+ /** Increment only for an incompatible collaboration wire change. */
23
+ export const COLLABORATION_PROTOCOL_VERSION = 3;
24
+ const compileStatuses = ["queued", "running", "succeeded", "failed", "cleaned"];
25
+ const cleanModes = ["cache", "artifacts"];
26
+ export function isCollaborationCompileStatus(value) {
27
+ return typeof value === "string" && compileStatuses.includes(value);
28
+ }
29
+ export function isCollaborationCleanMode(value) {
30
+ return typeof value === "string" && cleanModes.includes(value);
31
+ }
@@ -1,23 +1,76 @@
1
1
  /** Lightweight extraction of source and bibliography dependencies from TeX. */
2
- import { forEachLatexCommand, readLatexMandatoryArguments } from "./latexScanner.js";
2
+ import { forEachLatexCommand, isLatexCommentStart, readLatexMandatoryArguments, skipLatexComment, skipLatexTrivia } from "./latexScanner.js";
3
3
  function pathRanges(source, argument, splitOnComma) {
4
4
  const paths = [];
5
5
  let segmentStart = argument.contentFrom;
6
- for (let index = argument.contentFrom; index <= argument.contentTo; index += 1) {
7
- if (index !== argument.contentTo && (!splitOnComma || source[index] !== ","))
6
+ let index = argument.contentFrom;
7
+ while (index <= argument.contentTo) {
8
+ if (index < argument.contentTo && isLatexCommentStart(source, index)) {
9
+ index = skipLatexComment(source, index);
10
+ if (source[index] === "\n")
11
+ index += 1;
8
12
  continue;
9
- let from = segmentStart;
10
- let to = index;
11
- while (from < to && /\s/.test(source[from]))
12
- from += 1;
13
- while (to > from && /\s/.test(source[to - 1]))
14
- to -= 1;
15
- if (to > from)
16
- paths.push({ path: source.slice(from, to), command: "", from, to });
13
+ }
14
+ if (index !== argument.contentTo && (!splitOnComma || source[index] !== ",")) {
15
+ index += 1;
16
+ continue;
17
+ }
18
+ const reference = pathReferenceFromRange(source, segmentStart, index);
19
+ if (reference)
20
+ paths.push(reference);
17
21
  segmentStart = index + 1;
22
+ index += 1;
18
23
  }
19
24
  return paths;
20
25
  }
26
+ /**
27
+ * Turn a raw argument segment into a project path while preserving the source
28
+ * range that produced it. Comments disappear in TeX before filename handling,
29
+ * so a comment between path fragments must not become part of the path.
30
+ */
31
+ function pathReferenceFromRange(source, start, end) {
32
+ const characters = [];
33
+ let index = start;
34
+ while (index < end) {
35
+ if (isLatexCommentStart(source, index)) {
36
+ index = skipLatexComment(source, index);
37
+ if (source[index] === "\n")
38
+ index += 1;
39
+ continue;
40
+ }
41
+ characters.push({ character: source[index], index });
42
+ index += 1;
43
+ }
44
+ let first = 0;
45
+ let last = characters.length;
46
+ while (first < last && /\s/.test(characters[first].character))
47
+ first += 1;
48
+ while (last > first && /\s/.test(characters[last - 1].character))
49
+ last -= 1;
50
+ if (first === last)
51
+ return null;
52
+ return {
53
+ path: characters.slice(first, last).map((entry) => entry.character).join(""),
54
+ command: "",
55
+ from: characters[first].index,
56
+ to: characters[last - 1].index + 1
57
+ };
58
+ }
59
+ /** Read TeX's common `\\input filename` form without guessing macro expansion. */
60
+ function readUnbracedInputPath(source, start) {
61
+ const from = skipLatexTrivia(source, start);
62
+ if (from >= source.length || /[\\{}\[\]#~]/.test(source[from]))
63
+ return null;
64
+ let to = from;
65
+ while (to < source.length && !/\s/.test(source[to]) && !isLatexCommentStart(source, to)) {
66
+ if (/[\\{}\[\]#~]/.test(source[to]))
67
+ break;
68
+ to += 1;
69
+ }
70
+ if (to === from)
71
+ return null;
72
+ return { path: source.slice(from, to), command: "", from, to };
73
+ }
21
74
  /**
22
75
  * Find source files pulled into a LaTeX document. Paths intentionally remain
23
76
  * raw: project-level code is responsible for resolving them safely.
@@ -28,27 +81,34 @@ export function findLatexSourceIncludes(source) {
28
81
  const name = command.name.toLowerCase();
29
82
  if (name === "input" || name === "include" || name === "subfile") {
30
83
  const argument = readLatexMandatoryArguments(source, command.to, 1)[0];
31
- if (!argument)
32
- return;
33
- for (const include of pathRanges(source, argument, false))
34
- includes.push({ ...include, command: command.name });
84
+ if (argument) {
85
+ for (const include of pathRanges(source, argument, false))
86
+ includes.push({ ...include, command: command.name });
87
+ }
88
+ else if (name === "input") {
89
+ const include = readUnbracedInputPath(source, command.to);
90
+ if (include)
91
+ includes.push({ ...include, command: command.name });
92
+ }
35
93
  return;
36
94
  }
37
- if (name !== "import" && name !== "subimport" && name !== "includefrom" && name !== "inputfrom")
95
+ if (name !== "import" && name !== "subimport" && name !== "includefrom" && name !== "inputfrom"
96
+ && name !== "subinputfrom" && name !== "subincludefrom")
38
97
  return;
39
98
  const argumentsFound = readLatexMandatoryArguments(source, command.to, 2);
40
99
  if (argumentsFound.length !== 2)
41
100
  return;
42
- const directory = source.slice(argumentsFound[0].contentFrom, argumentsFound[0].contentTo).trim();
43
- const file = source.slice(argumentsFound[1].contentFrom, argumentsFound[1].contentTo).trim();
44
- if (!directory || !file)
101
+ const directory = pathRanges(source, argumentsFound[0], false)[0]?.path ?? "";
102
+ const file = pathRanges(source, argumentsFound[1], false)[0];
103
+ if (!file)
45
104
  return;
46
- const separator = directory.endsWith("/") ? "" : "/";
105
+ const separator = directory && !directory.endsWith("/") ? "/" : "";
47
106
  includes.push({
48
- path: directory + separator + file,
107
+ path: directory + separator + file.path,
49
108
  command: command.name,
50
- from: argumentsFound[1].contentFrom,
51
- to: argumentsFound[1].contentTo
109
+ from: file.from,
110
+ to: file.to,
111
+ directory
52
112
  });
53
113
  });
54
114
  return includes;
@@ -1,4 +1,4 @@
1
- /** Bibliographies are structured reference data, not prose. */
1
+ /** Bibliographies and BibTeX style programs are structured data/code, not prose. */
2
2
  export function supportsWritingChecks(path) {
3
- return !/\.bib$/i.test(path.trim());
3
+ return !/\.(?:bib|bst)$/i.test(path.trim());
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "texlite",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "A lightweight local-first collaborative LaTeX workspace",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -41,8 +41,9 @@
41
41
  },
42
42
  "type": "module",
43
43
  "scripts": {
44
- "dev": "npm run generate:lucide-icons && tsx watch src/server/index.ts",
45
- "dev:web": "npm run generate:bibtex-parser && vite",
44
+ "dev": "npm run generate:lucide-icons && node scripts/run-dev.mjs server",
45
+ "dev:init": "node scripts/run-dev.mjs init",
46
+ "dev:web": "npm run generate:bibtex-parser && node scripts/run-dev.mjs web",
46
47
  "build": "npm run build:web && npm run build:server",
47
48
  "build:web": "npm run generate:bibtex-parser && vite build",
48
49
  "build:server": "tsc -p tsconfig.server.json && npm run generate:lucide-icons",
@@ -1 +0,0 @@
1
- import{u as Y,r as n,j as e}from"./react-BVUjbTTn.js";import{c as h,M as D,d as _,C as ee,g as u,h as m}from"./index-DIM_KiG2.js";import{U as se,T as R,X as O}from"./x-CSzFikn2.js";import{U as $}from"./user-plus-gQ6q5Nom.js";import{S as ae}from"./Dashboard-DLk2j3oq.js";import{K as B}from"./key-round-BjL9H8-H.js";import"./hard-drive-B-NSDVVA.js";import"./sigma-DOcJr7VG.js";import"./tags-BedQ75lh.js";import"./chevron-left-l1oupvGO.js";const re=h("Dices",[["rect",{width:"12",height:"12",x:"2",y:"10",rx:"2",ry:"2",key:"6agr2n"}],["path",{d:"m17.92 14 3.5-3.5a2.24 2.24 0 0 0 0-3l-5-4.92a2.24 2.24 0 0 0-3 0L10 6",key:"1o487t"}],["path",{d:"M6 18h.01",key:"uhywen"}],["path",{d:"M10 14h.01",key:"ssrbsk"}],["path",{d:"M15 6h.01",key:"cblpky"}],["path",{d:"M18 9h.01",key:"2061c0"}]]);const ne=h("FolderCheck",[["path",{d:"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z",key:"1kt360"}],["path",{d:"m9 13 2 2 4-4",key:"6343dt"}]]);const te=h("FolderX",[["path",{d:"M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z",key:"1kt360"}],["path",{d:"m9.5 10.5 5 5",key:"ra9qjz"}],["path",{d:"m14.5 10.5-5 5",key:"l2rkpq"}]]);const ie=h("ShieldOff",[["path",{d:"m2 2 20 20",key:"1ooewy"}],["path",{d:"M5 5a1 1 0 0 0-1 1v7c0 5 3.5 7.5 7.67 8.94a1 1 0 0 0 .67.01c2.35-.82 4.48-1.97 5.9-3.71",key:"1jlk70"}],["path",{d:"M9.309 3.652A12.252 12.252 0 0 0 11.24 2.28a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1v7a9.784 9.784 0 0 1-.08 1.264",key:"18rp1v"}]]);const oe=h("UserCheck",[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}],["polyline",{points:"16 11 18 13 22 9",key:"1pwet4"}]]);const le=h("UserX",[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}],["line",{x1:"17",x2:"22",y1:"8",y2:"13",key:"3nzzx3"}],["line",{x1:"22",x2:"17",y1:"8",y2:"13",key:"1swrse"}]]);function de(p=10){const t="ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%",a=crypto.getRandomValues(new Uint8Array(p));return Array.from(a,S=>t[S%t.length]).join("")}function Ce({currentUser:p,minPasswordLength:t}){const{t:a}=Y(),[S,J]=n.useState([]),[E,d]=n.useState(""),[V,C]=n.useState(!1),[r,o]=n.useState({username:"",displayName:"",password:"",role:"user",canCreateProjects:!1}),[k,g]=n.useState(null),[j,P]=n.useState(""),[x,w]=n.useState(null),[z,N]=n.useState(!1),[i,F]=n.useState(!1),[U,y]=n.useState(""),[l,f]=n.useState(null),[A,H]=n.useState(!1),[X,c]=n.useState(""),b=()=>u("/api/admin/users").then(({users:s})=>J(s)).catch(s=>d(m(s)));n.useEffect(()=>{b()},[]);const q=async()=>{if(r.password.length<t)return d(a("auth.passwordMinimum",{count:t}));try{await u("/api/admin/users",{method:"POST",body:JSON.stringify({username:r.username,displayName:r.displayName||r.username,password:r.password,role:r.role,canCreateProjects:r.canCreateProjects})}),C(!1),o({username:"",displayName:"",password:"",role:"user",canCreateProjects:!1}),await b()}catch(s){d(m(s))}},M=async(s,v)=>{try{await u(`/api/admin/users/${s.id}`,{method:"PATCH",body:JSON.stringify(v)}),await b()}catch(T){d(m(T))}},K=async s=>{if(!s.disabled){f({target:s,setting:"disable"}),c("");return}await M(s,{disabled:!1})},Z=async s=>{if(s.role==="admin"){f({target:s,setting:"demote"}),c("");return}await M(s,{role:"admin"})},G=async s=>{if(s.canCreateProjects){f({target:s,setting:"denyProjectCreation"}),c("");return}await M(s,{canCreateProjects:!0})},I=async()=>{if(!l)return;const{target:s,setting:v}=l,T=v==="disable"?{disabled:!0}:v==="demote"?{role:"user"}:{canCreateProjects:!1};H(!0),c("");try{await u(`/api/admin/users/${s.id}`,{method:"PATCH",body:JSON.stringify(T)}),await b(),f(null)}catch(W){c(m(W))}finally{H(!1)}},L=async()=>{if(!(!k||!j)){if(j.length<t)return d(a("auth.passwordMinimum",{count:t}));try{await u(`/api/admin/users/${k.id}`,{method:"PATCH",body:JSON.stringify({password:j})}),g(null),P("")}catch(s){d(m(s))}}},Q=async()=>{if(!(!x||i)){F(!0),y("");try{await u(`/api/admin/users/${x.id}`,{method:"DELETE",body:JSON.stringify({deleteProjects:z})}),w(null),N(!1),await b()}catch(s){y(m(s))}finally{F(!1)}}};return e.jsxs("main",{className:"dashboard",children:[e.jsxs("div",{className:"section-title",children:[e.jsxs("div",{children:[e.jsxs("h1",{children:[e.jsx(se,{"aria-hidden":!0,size:25}),a("users.manage")]}),e.jsx("p",{className:"muted",children:a("users.onlyAdmin")})]}),e.jsxs("button",{className:"primary icon-button",onClick:()=>C(!0),children:[e.jsx($,{"aria-hidden":!0,size:15}),a("users.add")]})]}),E&&e.jsx("p",{className:"error",children:E}),e.jsx("div",{className:"table-card",children:e.jsxs("table",{children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:a("common.user")}),e.jsx("th",{children:a("users.role")}),e.jsx("th",{children:a("users.createProjects")}),e.jsx("th",{children:a("users.ownedProjects")}),e.jsx("th",{children:a("users.status")}),e.jsx("th",{children:a("users.actions")})]})}),e.jsx("tbody",{children:S.map(s=>e.jsxs("tr",{children:[e.jsxs("td",{children:[e.jsx("strong",{children:s.displayName}),e.jsxs("small",{children:["@",s.username]})]}),e.jsx("td",{children:s.role==="admin"?a("common.admin"):a("common.user")}),e.jsx("td",{children:s.canCreateProjects?a("users.allow"):a("users.deny")}),e.jsx("td",{children:s.ownedProjects}),e.jsx("td",{children:s.disabled?a("common.disabled"):a("common.normal")}),e.jsxs("td",{children:[e.jsxs("button",{className:"icon-button",disabled:s.id===p.id,onClick:()=>K(s),children:[s.disabled?e.jsx(oe,{"aria-hidden":!0,size:13}):e.jsx(le,{"aria-hidden":!0,size:13}),s.disabled?a("users.enable"):a("users.disable")]}),e.jsxs("button",{className:"icon-button",disabled:s.id===p.id,onClick:()=>Z(s),children:[s.role==="admin"?e.jsx(ie,{"aria-hidden":!0,size:13}):e.jsx(ae,{"aria-hidden":!0,size:13}),s.role==="admin"?a("users.demote"):a("users.promote")]}),e.jsxs("button",{className:"icon-button",disabled:s.role==="admin",onClick:()=>G(s),children:[s.canCreateProjects?e.jsx(te,{"aria-hidden":!0,size:13}):e.jsx(ne,{"aria-hidden":!0,size:13}),s.canCreateProjects?a("users.denyCreate"):a("users.allowCreate")]}),e.jsxs("button",{className:"icon-button",onClick:()=>{g(s),P("")},children:[e.jsx(B,{"aria-hidden":!0,size:13}),a("users.resetPassword")]}),e.jsxs("button",{className:"icon-button danger-text",disabled:s.id===p.id,onClick:()=>{w(s),N(!1),y("")},children:[e.jsx(R,{"aria-hidden":!0,size:13}),a("common.delete")]})]})]},s.id))})]})}),e.jsx(D,{open:V,title:a("users.add"),description:a("users.addDescription"),onOpenChange:C,footer:e.jsxs(e.Fragment,{children:[e.jsxs("button",{className:"icon-button",onClick:()=>C(!1),children:[e.jsx(O,{"aria-hidden":!0,size:14}),a("common.cancel")]}),e.jsxs("button",{className:"primary icon-button",disabled:r.password.length<t,onClick:()=>{q()},children:[e.jsx($,{"aria-hidden":!0,size:14}),a("users.createUser")]})]}),children:e.jsxs("div",{className:"form-stack",children:[e.jsxs("label",{className:"form-field",children:[a("auth.username"),e.jsx("input",{value:r.username,onChange:s=>o({...r,username:s.target.value})})]}),e.jsxs("label",{className:"form-field",children:[a("users.displayName"),e.jsx("input",{value:r.displayName,onChange:s=>o({...r,displayName:s.target.value})})]}),e.jsxs("label",{className:"form-field",children:[a("users.initialPassword"),e.jsxs("span",{className:"password-generator",children:[e.jsx("input",{minLength:t,autoComplete:"new-password",value:r.password,onChange:s=>o({...r,password:s.target.value})}),e.jsxs("button",{type:"button",title:a("users.generatePassword"),onClick:()=>o({...r,password:de()}),children:[e.jsx(re,{size:15}),a("users.randomPassword")]})]}),e.jsx("small",{className:"field-hint",children:a("auth.passwordMinimum",{count:t})})]}),e.jsxs("label",{className:"form-field",children:[a("users.role"),e.jsxs("select",{value:r.role,onChange:s=>o({...r,role:s.target.value}),children:[e.jsx("option",{value:"user",children:a("common.user")}),e.jsx("option",{value:"admin",children:a("common.admin")})]})]}),e.jsxs("label",{className:"checkbox-field",children:[e.jsx("input",{type:"checkbox",checked:r.canCreateProjects||r.role==="admin",disabled:r.role==="admin",onChange:s=>o({...r,canCreateProjects:s.target.checked})})," ",a("users.allowCreate")]})]})}),e.jsx(D,{open:!!k,title:a("users.resetPassword"),description:a("users.resetDescription",{username:k?.username??""}),onOpenChange:s=>{s||g(null)},footer:e.jsxs(e.Fragment,{children:[e.jsxs("button",{className:"icon-button",onClick:()=>g(null),children:[e.jsx(O,{"aria-hidden":!0,size:14}),a("common.cancel")]}),e.jsxs("button",{className:"primary icon-button",disabled:j.length<t,onClick:()=>{L()},children:[e.jsx(B,{"aria-hidden":!0,size:14}),a("users.reset")]})]}),children:e.jsxs("label",{className:"form-field",children:[a("auth.newPassword"),e.jsx("input",{autoFocus:!0,type:"password",minLength:t,autoComplete:"new-password",value:j,onChange:s=>P(s.target.value)}),e.jsx("small",{className:"field-hint",children:a("auth.passwordMinimum",{count:t})})]})}),e.jsx(D,{open:!!x,title:a("users.deleteTitle"),description:a("users.deleteDescription",{username:x?.username??""}),onOpenChange:s=>{!s&&!i&&(w(null),y(""))},footer:e.jsxs(e.Fragment,{children:[e.jsxs("button",{className:"icon-button",disabled:i,onClick:()=>{w(null),y("")},children:[e.jsx(O,{"aria-hidden":!0,size:14}),a("common.cancel")]}),e.jsxs("button",{className:"danger icon-button",disabled:i,"aria-busy":i,onClick:()=>{Q()},children:[i?e.jsx(_,{className:"spin","aria-hidden":!0,size:14}):e.jsx(R,{"aria-hidden":!0,size:14}),a(i?"common.loading":"users.deleteTitle")]})]}),children:e.jsxs(e.Fragment,{children:[U&&e.jsx("p",{className:"error dialog-error",children:U}),e.jsxs("fieldset",{className:"choice-group",disabled:i,children:[e.jsx("legend",{children:a("users.ownedChoice",{count:x?.ownedProjects??0})}),e.jsxs("label",{children:[e.jsx("input",{type:"radio",checked:!z,onChange:()=>N(!1)})," ",a("users.transferProjects")]}),e.jsxs("label",{children:[e.jsx("input",{type:"radio",checked:z,onChange:()=>N(!0)})," ",a("users.deleteProjects")]})]})]})}),e.jsx(ee,{open:!!l,title:a(`userConfirmations.${l?.setting??"disable"}Title`),description:a(`userConfirmations.${l?.setting??"disable"}Description`,{username:l?.target.username??""}),confirmLabel:a(`userConfirmations.${l?.setting??"disable"}Confirm`),danger:!0,busy:A,error:X,onCancel:()=>{A||(f(null),c(""))},onConfirm:()=>{I()}})]})}export{Ce as AdminUsers};