texlite 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +36 -6
- package/OPERATIONS.md +70 -3
- package/README.md +11 -3
- package/README.zh-CN.md +11 -2
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/client/assets/AdminUsers-huwQK7Ly.js +1 -0
- package/dist/client/assets/CitationLibraryDialog-C5C3sPdw.js +1 -0
- package/dist/client/assets/Dashboard-DtHSc55D.js +2 -0
- package/dist/client/assets/{GitDialog-C9Loz4Al.js → GitDialog-BI7QIMsh.js} +1 -1
- package/dist/client/assets/{HistoryDialog-3UNN_jhU.js → HistoryDialog-DA7O9aF2.js} +1 -1
- package/dist/client/assets/LatexEditor-BYWWx5Km.js +138 -0
- package/dist/client/assets/{PdfPreview-BKhcS-2c.js → PdfPreview-mO9WS01q.js} +1 -1
- package/dist/client/assets/ProjectIconPickerDialog-B0q_r5dX.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-CZKTnxrl.js → ProjectNavigationDialogs-DwWYbB1A.js} +1 -1
- package/dist/client/assets/ProjectSettings-BpPFgdoj.js +1 -0
- package/dist/client/assets/ProjectWorkspace-CI2zKon5.js +32 -0
- package/dist/client/assets/{SelectionHistoryDialog-D3dO83lg.js → SelectionHistoryDialog-pWMI5izu.js} +2 -2
- package/dist/client/assets/SystemMetricsDialog-BmlO7oNg.js +1 -0
- package/dist/client/assets/TagManagementDialog-h4Ves4FR.js +1 -0
- package/dist/client/assets/{WordCountDialog-TwpyAP3m.js → WordCountDialog-DlBSP4y7.js} +1 -1
- package/dist/client/assets/check-BAdi_Lxq.js +1 -0
- package/dist/client/assets/editor-DZS7-wrd.js +14 -0
- package/dist/client/assets/{hard-drive-XHSiS_A7.js → hard-drive-DP2OHKMC.js} +1 -1
- package/dist/client/assets/index-CBrIrzgg.css +1 -0
- package/dist/client/assets/{index-Cd6-F29b.js → index-i5IY_PMw.js} +1 -1
- package/dist/client/assets/index-teah8iB2.js +12 -0
- package/dist/client/assets/katex-D9PXdmFF.css +1 -0
- package/dist/client/assets/{key-round-C-CXCWnz.js → key-round-D8sCqL2b.js} +1 -1
- package/dist/client/assets/{latexFormatterWorker-DYkYtcFy.js → latexFormatterWorker-BRmsns8a.js} +2 -2
- package/dist/client/assets/{minimize-2-BMDPVaF-.js → minimize-2-6tf519Ts.js} +1 -1
- package/dist/client/assets/{minus-BIa7-KuM.js → minus-CqvESp9M.js} +1 -1
- package/dist/client/assets/{pdf-BB1XvBIF.js → pdf-DUYcbPMK.js} +1 -1
- package/dist/client/assets/{plus-BKDWy4Ui.js → plus-DIrweesj.js} +1 -1
- package/dist/client/assets/{refresh-cw-Bo7-a7-I.js → refresh-cw-BDcwKu_d.js} +1 -1
- package/dist/client/assets/{search-Bz9Ob5mT.js → search-C9_Jyrnx.js} +1 -1
- package/dist/client/assets/{sigma-DxlHf9gv.js → sigma-p9-fBP0d.js} +1 -1
- package/dist/client/assets/spellCheck-Y0RlmXbl.js +1 -0
- package/dist/client/assets/{tags-IMZehjzE.js → tags-DjF3z21M.js} +1 -1
- package/dist/client/assets/{type-DTWpFQNQ.js → type-CNrDJnzR.js} +1 -1
- package/dist/client/assets/{user-plus--aVpd8K6.js → user-plus-C7exNawX.js} +1 -1
- package/dist/client/assets/x-BEVz0wdw.js +1 -0
- package/dist/client/index.html +6 -4
- package/dist/server/app.js +130 -83
- package/dist/server/cli.js +4 -2
- package/dist/server/collaboration.js +6 -19
- package/dist/server/compileArtifacts.js +3 -2
- package/dist/server/config.js +27 -0
- package/dist/server/db.js +91 -10
- package/dist/server/index.js +4 -2
- package/dist/server/instanceLock.js +4 -2
- package/dist/server/latexCompletion.js +126 -63
- package/dist/server/latexDocumentGraph.js +217 -0
- package/dist/server/latexSpellMask.js +3 -4
- package/dist/server/pm2.js +3 -2
- package/dist/server/projectOutline.js +103 -91
- package/dist/server/projectReferences.js +109 -0
- package/dist/server/routes/auth.js +5 -2
- package/dist/server/routes/comments.js +10 -4
- package/dist/server/routes/compile.js +2 -2
- package/dist/server/routes/projectFiles.js +12 -3
- package/dist/server/routes/projectReferences.js +36 -0
- package/dist/server/routes/projectShared.js +33 -12
- package/dist/server/routes/projects.js +5 -0
- package/dist/server/routes/system.js +1 -0
- package/dist/server/zip.js +2 -16
- package/dist/shared/basePath.js +44 -0
- package/dist/shared/bibtexReferences.js +135 -0
- package/dist/shared/collaborationProtocol.js +31 -0
- package/dist/shared/latexCommandSnippets.js +91 -0
- package/dist/shared/latexDependencies.js +131 -0
- package/dist/shared/latexLiterals.js +215 -0
- package/dist/shared/latexReferenceCommands.js +75 -0
- package/dist/shared/latexReferences.js +193 -0
- package/dist/shared/latexRoot.js +21 -0
- package/dist/shared/latexScanner.js +183 -0
- package/dist/shared/writingChecks.js +4 -0
- package/package.json +11 -4
- package/texlite.config.example.json +2 -4
- package/dist/client/assets/AdminUsers-Dazar9cj.js +0 -1
- package/dist/client/assets/CitationLibraryDialog-VSUiSPgG.js +0 -1
- package/dist/client/assets/Dashboard-CoE5ESfb.js +0 -2
- package/dist/client/assets/LatexEditor-Bq76v8sO.js +0 -65
- package/dist/client/assets/ProjectIconPickerDialog-BoWMjQVm.js +0 -1
- package/dist/client/assets/ProjectSettings-DiOQVJ40.js +0 -1
- package/dist/client/assets/ProjectWorkspace-DqLwkGpT.js +0 -27
- package/dist/client/assets/SystemMetricsDialog-0zCwLtH_.js +0 -1
- package/dist/client/assets/TagManagementDialog-C6KDwtvd.js +0 -1
- package/dist/client/assets/check-sZxOOKtS.js +0 -1
- package/dist/client/assets/chevron-left-32SrOwa5.js +0 -1
- package/dist/client/assets/editor-CiK7TK0p.js +0 -14
- package/dist/client/assets/index-BRr3Kuxh.css +0 -1
- package/dist/client/assets/index-BjzELSQ-.js +0 -12
- package/dist/client/assets/katex-CEK31ho9.css +0 -1
- package/dist/client/assets/spellCheck-BfZEnpwY.js +0 -1
- package/dist/client/assets/x-Tbo3aA0J.js +0 -1
|
@@ -1,45 +1,55 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
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 =
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
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 =
|
|
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 =
|
|
67
|
+
content = await fs.promises.readFile(resolveSourcePath(config, projectId, filePath), "utf8");
|
|
58
68
|
}
|
|
59
69
|
catch {
|
|
60
70
|
return;
|
|
61
71
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
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 =
|
|
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
|
|
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
|
|
133
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { listProjectFilesAsync, resolveSourcePath } from "./files.js";
|
|
3
|
+
import { findLatexReferenceDefinitions, lineAndColumnAt } from "../shared/latexReferences.js";
|
|
4
|
+
import { documentSourceOrder, latexDocumentDirectives, resolveProjectReferencePath, uniqueProjectPaths } from "./latexDocumentGraph.js";
|
|
5
|
+
const definitionExtensions = /\.(?:tex|sty|cls)$/i;
|
|
6
|
+
const texExtension = /\.tex$/i;
|
|
7
|
+
const bibExtension = /\.bib$/i;
|
|
8
|
+
const MAX_REFERENCE_KEY_LENGTH = 512;
|
|
9
|
+
export function validReferenceKey(value) {
|
|
10
|
+
return typeof value === "string"
|
|
11
|
+
&& value.trim().length > 0
|
|
12
|
+
&& value.trim().length <= MAX_REFERENCE_KEY_LENGTH
|
|
13
|
+
&& !/[\u0000-\u001f\u007f]/.test(value);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a source-level citation or label target from the project tree.
|
|
17
|
+
*
|
|
18
|
+
* A document's own input graph and declared bibliography files are checked
|
|
19
|
+
* before unrelated files in the project. This keeps a stale \bibitem in an
|
|
20
|
+
* archive or template from shadowing the bibliography used by the selected
|
|
21
|
+
* main document.
|
|
22
|
+
*/
|
|
23
|
+
export async function resolveProjectReference(config, projectId, kind, keyInput, options = {}) {
|
|
24
|
+
// Keep the former preferred-path argument working for internal callers
|
|
25
|
+
// during the transition to the richer main-document-aware lookup options.
|
|
26
|
+
const lookup = typeof options === "string" ? { preferredPath: options } : options;
|
|
27
|
+
const key = keyInput.trim();
|
|
28
|
+
const files = (await listProjectFilesAsync(config, projectId))
|
|
29
|
+
.filter((entry) => entry.type === "file" && (definitionExtensions.test(entry.path) || bibExtension.test(entry.path)));
|
|
30
|
+
const sourceEntries = files.filter((entry) => definitionExtensions.test(entry.path));
|
|
31
|
+
const bibEntries = files.filter((entry) => bibExtension.test(entry.path));
|
|
32
|
+
const sourceContents = await readProjectSources(config, projectId, sourceEntries.map((entry) => entry.path));
|
|
33
|
+
const sourcePaths = [...sourceContents.keys()].sort((left, right) => left.localeCompare(right));
|
|
34
|
+
const bibPaths = bibEntries.map((entry) => entry.path).sort((left, right) => left.localeCompare(right));
|
|
35
|
+
const sourceDirectives = new Map();
|
|
36
|
+
for (const [entryPath, source] of sourceContents)
|
|
37
|
+
sourceDirectives.set(entryPath, latexDocumentDirectives(source));
|
|
38
|
+
const preferredPath = sourceContents.has(lookup.preferredPath ?? "") ? lookup.preferredPath ?? "" : "";
|
|
39
|
+
const mainFile = sourceContents.has(lookup.mainFile ?? "") && texExtension.test(lookup.mainFile ?? "")
|
|
40
|
+
? lookup.mainFile ?? ""
|
|
41
|
+
: "";
|
|
42
|
+
const documentSources = documentSourceOrder(sourceDirectives, mainFile);
|
|
43
|
+
const prioritizedSources = uniqueProjectPaths([
|
|
44
|
+
...(preferredPath ? [preferredPath] : []),
|
|
45
|
+
...documentSources
|
|
46
|
+
]);
|
|
47
|
+
const orderedPaths = kind === "label"
|
|
48
|
+
? uniqueProjectPaths([...prioritizedSources, ...sourcePaths])
|
|
49
|
+
: citationLookupOrder(sourceDirectives, prioritizedSources, bibPaths, sourcePaths);
|
|
50
|
+
for (const entryPath of orderedPaths) {
|
|
51
|
+
const source = sourceContents.get(entryPath)
|
|
52
|
+
?? await readProjectFile(config, projectId, entryPath);
|
|
53
|
+
if (source === null)
|
|
54
|
+
continue;
|
|
55
|
+
const definition = findLatexReferenceDefinitions(source, kind, bibExtension.test(entryPath))
|
|
56
|
+
.find((candidate) => candidate.key === key);
|
|
57
|
+
if (!definition)
|
|
58
|
+
continue;
|
|
59
|
+
const location = lineAndColumnAt(source, definition.targetFrom);
|
|
60
|
+
return { path: entryPath, ...location, kind, source: definition.source };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
async function readProjectSources(config, projectId, paths) {
|
|
65
|
+
const contents = new Map();
|
|
66
|
+
await Promise.all(paths.map(async (entryPath) => {
|
|
67
|
+
const source = await readProjectFile(config, projectId, entryPath);
|
|
68
|
+
if (source !== null)
|
|
69
|
+
contents.set(entryPath, source);
|
|
70
|
+
}));
|
|
71
|
+
return contents;
|
|
72
|
+
}
|
|
73
|
+
async function readProjectFile(config, projectId, entryPath) {
|
|
74
|
+
try {
|
|
75
|
+
return await fs.promises.readFile(resolveSourcePath(config, projectId, entryPath), "utf8");
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// A concurrent file deletion is harmless for navigation. The project
|
|
79
|
+
// mutation barrier prevents partial writes; simply continue to the next
|
|
80
|
+
// possible definition when a file has disappeared.
|
|
81
|
+
if (error.code === "ENOENT")
|
|
82
|
+
return null;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function citationLookupOrder(sourceDirectives, documentSources, bibPaths, sourcePaths) {
|
|
87
|
+
// Callers without a selected root retain the conservative legacy behavior:
|
|
88
|
+
// prefer in-source thebibliography entries before standalone .bib files.
|
|
89
|
+
if (documentSources.length === 0)
|
|
90
|
+
return uniqueProjectPaths([...sourcePaths, ...bibPaths]);
|
|
91
|
+
const bibPathSet = new Set(bibPaths);
|
|
92
|
+
const declaredBibliographies = [];
|
|
93
|
+
for (const sourcePath of documentSources) {
|
|
94
|
+
const directives = sourceDirectives.get(sourcePath);
|
|
95
|
+
if (!directives)
|
|
96
|
+
continue;
|
|
97
|
+
for (const bibliography of directives.bibliographies) {
|
|
98
|
+
const resolved = resolveProjectReferencePath(bibliography, sourcePath, ".bib", bibPathSet, "project-root-then-source-file");
|
|
99
|
+
if (resolved)
|
|
100
|
+
declaredBibliographies.push(resolved);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return uniqueProjectPaths([
|
|
104
|
+
...documentSources,
|
|
105
|
+
...declaredBibliographies,
|
|
106
|
+
...bibPaths,
|
|
107
|
+
...sourcePaths
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { publicUser, requireUser } from "../auth.js";
|
|
2
2
|
import { createSessionToken, digestToken, hashPassword, verifyPassword } from "../security.js";
|
|
3
3
|
import { apiError, ValidationError } from "../http.js";
|
|
4
|
+
import { basePathHref } from "../../shared/basePath.js";
|
|
4
5
|
const now = () => new Date().toISOString();
|
|
5
6
|
function text(value, max = 200) {
|
|
6
7
|
if (typeof value !== "string" || !value.trim() || value.length > max) {
|
|
@@ -35,8 +36,9 @@ export function registerAuthRoutes(app, context) {
|
|
|
35
36
|
db.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
|
|
36
37
|
.run(session.digest, user.id, expires.toISOString(), createdAt);
|
|
37
38
|
const isSecure = request.protocol === "https" || request.headers["x-forwarded-proto"] === "https";
|
|
39
|
+
const cookiePath = basePathHref(config.basePath);
|
|
38
40
|
reply.setCookie("texlite_session", session.token, {
|
|
39
|
-
path:
|
|
41
|
+
path: cookiePath,
|
|
40
42
|
httpOnly: true,
|
|
41
43
|
sameSite: "strict",
|
|
42
44
|
secure: isSecure,
|
|
@@ -48,7 +50,8 @@ export function registerAuthRoutes(app, context) {
|
|
|
48
50
|
const token = request.cookies.texlite_session;
|
|
49
51
|
if (token)
|
|
50
52
|
db.prepare("DELETE FROM sessions WHERE id = ?").run(digestToken(token));
|
|
51
|
-
|
|
53
|
+
const cookiePath = basePathHref(config.basePath);
|
|
54
|
+
reply.clearCookie("texlite_session", { path: cookiePath });
|
|
52
55
|
return { ok: true };
|
|
53
56
|
});
|
|
54
57
|
app.get("/api/me", async (request, reply) => {
|
|
@@ -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
|
-
|
|
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,
|
|
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");
|
|
@@ -85,7 +85,7 @@ export function registerCompileRoutes(app, context) {
|
|
|
85
85
|
} : null
|
|
86
86
|
} : null,
|
|
87
87
|
hasPdf: Boolean(pdf),
|
|
88
|
-
pdfUrl: pdf ? compilePdfUrl(id, mainFile, pdfVersion ?? pdf.version) : null,
|
|
88
|
+
pdfUrl: pdf ? compilePdfUrl(config, id, mainFile, pdfVersion ?? pdf.version) : null,
|
|
89
89
|
pdfCompiledAt: publishedRun?.finished_at ?? latestSuccess?.finished_at ?? null,
|
|
90
90
|
...pdfMetadata
|
|
91
91
|
};
|
|
@@ -674,7 +674,7 @@ export function registerCompileRoutes(app, context) {
|
|
|
674
674
|
].filter(Boolean).join(", "));
|
|
675
675
|
return {
|
|
676
676
|
mainFile, runId: result.runId, ok: result.ok, cancelled: result.cancelled === true, skipped: result.skipped === true, log: result.log, diagnostics: result.diagnostics,
|
|
677
|
-
pdfUrl: result.ok ? compilePdfUrl(id, mainFile, result.runId) : null,
|
|
677
|
+
pdfUrl: result.ok ? compilePdfUrl(config, id, mainFile, result.runId) : null,
|
|
678
678
|
pdfCompiledAt: result.ok ? completed?.finished_at ?? null : null,
|
|
679
679
|
...pdfMetadata,
|
|
680
680
|
timings
|
|
@@ -149,14 +149,23 @@ export function registerProjectFileRoutes(app, context) {
|
|
|
149
149
|
if (!user)
|
|
150
150
|
return;
|
|
151
151
|
const { id } = request.params;
|
|
152
|
-
|
|
152
|
+
const project = accessibleProject(db, id, user);
|
|
153
|
+
if (!project)
|
|
153
154
|
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
155
|
+
const query = request.query;
|
|
156
|
+
const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
|
|
157
|
+
if (!mainFile)
|
|
158
|
+
return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
|
|
154
159
|
const startedAt = performance.now();
|
|
155
160
|
try {
|
|
156
|
-
return await projectMutations.runConsistentRead(id, async () => ({ index: await latexCompletions.build(id) }), {
|
|
161
|
+
return await projectMutations.runConsistentRead(id, async () => ({ index: await latexCompletions.build(id, mainFile) }), {
|
|
157
162
|
preflight: () => {
|
|
158
|
-
|
|
163
|
+
const current = accessibleProject(db, id, user);
|
|
164
|
+
if (!current)
|
|
159
165
|
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
166
|
+
const selected = compileMainFile(config, id, current.main_file, query.mainFile);
|
|
167
|
+
if (!selected || selected !== mainFile)
|
|
168
|
+
throw httpError(400, "MAIN_DOCUMENT_INVALID");
|
|
160
169
|
}
|
|
161
170
|
});
|
|
162
171
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { requireUser } from "../auth.js";
|
|
2
|
+
import { safeRelativePath } from "../files.js";
|
|
3
|
+
import { apiError, httpError } from "../http.js";
|
|
4
|
+
import { resolveProjectReference, validReferenceKey } from "../projectReferences.js";
|
|
5
|
+
import { accessibleProject } from "../projects.js";
|
|
6
|
+
/** Register Ctrl/Cmd-click source-reference navigation routes. */
|
|
7
|
+
export function registerProjectReferenceRoutes(app, context) {
|
|
8
|
+
const { config, db, projectMutations } = context;
|
|
9
|
+
app.get("/api/projects/:id/references/resolve", async (request, reply) => {
|
|
10
|
+
const user = requireUser(request, reply, db);
|
|
11
|
+
if (!user)
|
|
12
|
+
return;
|
|
13
|
+
const { id } = request.params;
|
|
14
|
+
const project = accessibleProject(db, id, user);
|
|
15
|
+
if (!project)
|
|
16
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
17
|
+
const query = request.query;
|
|
18
|
+
const kind = query.kind === "citation" || query.kind === "label" ? query.kind : null;
|
|
19
|
+
if (!kind || !validReferenceKey(query.key))
|
|
20
|
+
return apiError(reply, 400, "REQUEST_INVALID");
|
|
21
|
+
const key = query.key;
|
|
22
|
+
const preferredPath = typeof query.path === "string" && query.path ? safeRelativePath(query.path) : "";
|
|
23
|
+
const requestedMainFile = typeof query.mainFile === "string" && query.mainFile
|
|
24
|
+
? safeRelativePath(query.mainFile)
|
|
25
|
+
: "";
|
|
26
|
+
const mainFile = /\.tex$/i.test(requestedMainFile) ? requestedMainFile : project.main_file;
|
|
27
|
+
return await projectMutations.runConsistentRead(id, async () => ({
|
|
28
|
+
target: await resolveProjectReference(config, id, kind, key, { preferredPath, mainFile })
|
|
29
|
+
}), {
|
|
30
|
+
preflight: () => {
|
|
31
|
+
if (!accessibleProject(db, id, user))
|
|
32
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
193
|
-
const
|
|
194
|
-
const
|
|
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 =
|
|
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 =
|
|
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(
|
|
231
|
-
endLine: offsetToLine(
|
|
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
|
+
}
|
|
@@ -12,6 +12,7 @@ import { writeProjectArchive } from "../archive.js";
|
|
|
12
12
|
import { extractProjectZip, ZipValidationError } from "../zip.js";
|
|
13
13
|
import { HarperLintSupersededError, HarperUnavailableError } from "../harper.js";
|
|
14
14
|
import { digestToken } from "../security.js";
|
|
15
|
+
import { supportsWritingChecks } from "../../shared/writingChecks.js";
|
|
15
16
|
import { unreadMentionCountsForProjects } from "../commentMentions.js";
|
|
16
17
|
import { commentsSummaryForProject, commentsSummaryForProjects, dictionaryWord, escapeLikePattern, now, projectJson, requireProjectOwnerPermission, tagColors, tagsForProject, tagsForProjects, text } from "./projectShared.js";
|
|
17
18
|
const clientIdPattern = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i;
|
|
@@ -390,6 +391,10 @@ export function registerProjectCatalogRoutes(app, context) {
|
|
|
390
391
|
catch {
|
|
391
392
|
return apiError(reply, 400, "SPELLCHECK_SOURCE_INVALID");
|
|
392
393
|
}
|
|
394
|
+
// Bibliography files are structured reference data rather than prose.
|
|
395
|
+
// Keep this server-side guard for legacy clients and direct API callers.
|
|
396
|
+
if (!supportsWritingChecks(filePath))
|
|
397
|
+
return { lints: [] };
|
|
393
398
|
// A login session is shared by browser tabs. Pair the authenticated user
|
|
394
399
|
// with the page-local client ID so each new editor can replace only its
|
|
395
400
|
// own obsolete waiting work. Legacy clients retain their former session
|
|
@@ -8,6 +8,7 @@ export function registerSystemRoutes(app, context) {
|
|
|
8
8
|
app.get("/api/config", async () => ({
|
|
9
9
|
siteName: config.siteName,
|
|
10
10
|
adminEmail: config.adminEmail,
|
|
11
|
+
basePath: config.basePath,
|
|
11
12
|
minPasswordLength: MIN_PASSWORD_LENGTH,
|
|
12
13
|
maxCitationBibtexBytes: MAX_CITATION_BIBTEX_BYTES,
|
|
13
14
|
maxUploadSizeMB: Math.floor(config.maxUploadBytes / 1024 / 1024),
|