texlite 0.8.0 → 0.8.2
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 +7 -4
- package/NPM_TESTING.md +8 -2
- package/OPERATIONS.md +21 -6
- package/README.md +42 -15
- package/README.zh-CN.md +39 -15
- package/dist/client/assets/{GitDialog-DdLrbz_H.js → GitDialog-ULwrGkKd.js} +1 -1
- package/dist/client/assets/{HistoryDialog-AlF9iozX.js → HistoryDialog-BE7jyWWP.js} +3 -3
- package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/client/assets/LatexEditor-DbU8fMFZ.js +65 -0
- package/dist/client/assets/{PdfPreview-CanYnyfJ.js → PdfPreview-BputjYeR.js} +1 -1
- package/dist/client/assets/{ProjectNavigationDialogs-DiXtkTj1.js → ProjectNavigationDialogs-BVE1JsHd.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-DimBI7er.js → SystemMetricsDialog-DxQsGcIH.js} +1 -1
- package/dist/client/assets/{editor-DMLs-XWi.js → editor-CiK7TK0p.js} +13 -13
- package/dist/client/assets/index-Dx9UqMyC.js +64 -0
- package/dist/client/assets/index-DzzqNm9b.css +1 -0
- package/dist/client/assets/katex-CEK31ho9.css +1 -0
- package/dist/client/assets/katex-COYOr4nK.js +257 -0
- package/dist/client/assets/{minus-DR-ttrx3.js → minus-B08vgSQJ.js} +1 -1
- package/dist/client/assets/{spellCheck-Wn7_eF9j.js → spellCheck-9fdyhJtV.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +5 -0
- package/dist/server/cli.js +170 -32
- package/dist/server/db.js +4 -0
- package/dist/server/environment.js +168 -38
- package/dist/server/i18n.js +12 -0
- package/dist/server/lucideIcons.js +46 -0
- package/dist/server/projectIcons.js +96 -0
- package/dist/server/routes/projectShared.js +1 -0
- package/dist/server/routes/projects.js +114 -7
- package/dist/server/routes/wordCount.js +83 -0
- package/dist/server/texcount.js +244 -0
- package/package.json +4 -2
- package/preview-1.png +0 -0
- package/preview-2.png +0 -0
- package/dist/client/assets/LatexEditor-DH9v1wgS.js +0 -59
- package/dist/client/assets/index-CdiutUgf.css +0 -1
- package/dist/client/assets/index-vpWlpUOr.js +0 -64
- package/preview.png +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { icons } from "lucide-react";
|
|
4
|
+
import { normalizeLucideIconName } from "./projectIcons.js";
|
|
5
|
+
/** Convert public Lucide component exports, e.g. AlarmClockMinus, to slugs. */
|
|
6
|
+
function lucideSlug(componentName) {
|
|
7
|
+
return componentName
|
|
8
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
|
9
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
10
|
+
.toLocaleLowerCase();
|
|
11
|
+
}
|
|
12
|
+
// `icons` is Lucide's public all-icons export. Do not rely on its generated
|
|
13
|
+
// dynamic import table: that is an implementation detail and has differed
|
|
14
|
+
// across package versions and module loaders. This index stays server-only,
|
|
15
|
+
// so supporting the complete installed icon set does not enlarge the browser
|
|
16
|
+
// bundle.
|
|
17
|
+
const installedIcons = icons;
|
|
18
|
+
const iconComponents = new Map();
|
|
19
|
+
for (const [componentName, component] of Object.entries(installedIcons)) {
|
|
20
|
+
const slug = lucideSlug(componentName);
|
|
21
|
+
if (!iconComponents.has(slug))
|
|
22
|
+
iconComponents.set(slug, component);
|
|
23
|
+
}
|
|
24
|
+
const renderedIcons = new Map();
|
|
25
|
+
/** Resolve an official Lucide icon slug to an installed icon. */
|
|
26
|
+
export function resolveLucideIconName(value) {
|
|
27
|
+
const name = normalizeLucideIconName(value);
|
|
28
|
+
return name && iconComponents.has(name) ? name : null;
|
|
29
|
+
}
|
|
30
|
+
/** Render a verified Lucide icon once for use as a small, cacheable SVG mask. */
|
|
31
|
+
export async function lucideIconSvg(name) {
|
|
32
|
+
const Icon = iconComponents.get(name);
|
|
33
|
+
if (!Icon)
|
|
34
|
+
return null;
|
|
35
|
+
const cached = renderedIcons.get(name);
|
|
36
|
+
if (cached)
|
|
37
|
+
return cached;
|
|
38
|
+
const svg = renderToStaticMarkup(createElement(Icon, {
|
|
39
|
+
size: 24,
|
|
40
|
+
strokeWidth: 1.9,
|
|
41
|
+
color: "#000",
|
|
42
|
+
"aria-hidden": true
|
|
43
|
+
}));
|
|
44
|
+
renderedIcons.set(name, svg);
|
|
45
|
+
return svg;
|
|
46
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A curated project-icon vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* The client renders these with the Lucide icons already used throughout the
|
|
5
|
+
* application. They are the fast, searchable defaults; the server separately
|
|
6
|
+
* validates advanced choices against the installed Lucide catalogue.
|
|
7
|
+
*/
|
|
8
|
+
export const projectIconNames = [
|
|
9
|
+
"file-text",
|
|
10
|
+
"book-open",
|
|
11
|
+
"notebook-pen",
|
|
12
|
+
"book-marked",
|
|
13
|
+
"scroll-text",
|
|
14
|
+
"library",
|
|
15
|
+
"graduation-cap",
|
|
16
|
+
"presentation",
|
|
17
|
+
"image",
|
|
18
|
+
"folder-kanban",
|
|
19
|
+
"microscope",
|
|
20
|
+
"flask-conical",
|
|
21
|
+
"test-tube-diagonal",
|
|
22
|
+
"atom",
|
|
23
|
+
"dna",
|
|
24
|
+
"telescope",
|
|
25
|
+
"earth",
|
|
26
|
+
"map",
|
|
27
|
+
"languages",
|
|
28
|
+
"university",
|
|
29
|
+
"code-2",
|
|
30
|
+
"file-code-2",
|
|
31
|
+
"database",
|
|
32
|
+
"server",
|
|
33
|
+
"network",
|
|
34
|
+
"circuit-board",
|
|
35
|
+
"brain",
|
|
36
|
+
"brain-circuit",
|
|
37
|
+
"bot",
|
|
38
|
+
"shield-check",
|
|
39
|
+
"workflow",
|
|
40
|
+
"sigma",
|
|
41
|
+
"calculator",
|
|
42
|
+
"chart-no-axes-combined",
|
|
43
|
+
"line-chart",
|
|
44
|
+
"bar-chart-3",
|
|
45
|
+
"pie-chart",
|
|
46
|
+
"chart-scatter",
|
|
47
|
+
"file-spreadsheet",
|
|
48
|
+
"file-chart-column-increasing",
|
|
49
|
+
"lightbulb",
|
|
50
|
+
"rocket",
|
|
51
|
+
"target",
|
|
52
|
+
"sparkles",
|
|
53
|
+
"file-pen-line",
|
|
54
|
+
"file-stack",
|
|
55
|
+
"folder",
|
|
56
|
+
"folder-open",
|
|
57
|
+
"folder-archive",
|
|
58
|
+
"archive",
|
|
59
|
+
"bookmark",
|
|
60
|
+
"newspaper",
|
|
61
|
+
"notepad-text",
|
|
62
|
+
"sticky-note",
|
|
63
|
+
"pen-tool",
|
|
64
|
+
"quote",
|
|
65
|
+
"list-tree",
|
|
66
|
+
"file-search",
|
|
67
|
+
"beaker",
|
|
68
|
+
"test-tubes",
|
|
69
|
+
"folder-git-2",
|
|
70
|
+
"git-branch",
|
|
71
|
+
"terminal",
|
|
72
|
+
"braces",
|
|
73
|
+
"regex",
|
|
74
|
+
"cpu",
|
|
75
|
+
"hard-drive",
|
|
76
|
+
"cloud",
|
|
77
|
+
"users-round",
|
|
78
|
+
"calendar-days"
|
|
79
|
+
];
|
|
80
|
+
const projectIconNameSet = new Set(projectIconNames);
|
|
81
|
+
export function isProjectIconName(value) {
|
|
82
|
+
return typeof value === "string" && projectIconNameSet.has(value);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Normalize an official Lucide icon slug (`file-text`). The optional
|
|
86
|
+
* `lucide:` prefix makes values copied from Iconify equally convenient.
|
|
87
|
+
* Availability is intentionally checked by the server-only Lucide module.
|
|
88
|
+
*/
|
|
89
|
+
export function normalizeLucideIconName(value) {
|
|
90
|
+
if (typeof value !== "string")
|
|
91
|
+
return null;
|
|
92
|
+
const compact = value.trim().replace(/^lucide:/i, "");
|
|
93
|
+
if (!compact || compact.length > 96)
|
|
94
|
+
return null;
|
|
95
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(compact) ? compact : null;
|
|
96
|
+
}
|
|
@@ -96,6 +96,7 @@ export function projectJson(project, tags = [], commentsSummary) {
|
|
|
96
96
|
mainFile: project.main_file,
|
|
97
97
|
latexmkrc: project.latexmkrc,
|
|
98
98
|
engine: project.engine,
|
|
99
|
+
icon: project.icon,
|
|
99
100
|
permission: project.permission,
|
|
100
101
|
tags,
|
|
101
102
|
unresolvedCommentCount: commentsSummary?.unresolvedCount ?? 0,
|
|
@@ -6,6 +6,7 @@ import { maxCollaborativeFileBytes } from "../collaboration.js";
|
|
|
6
6
|
import { createProjectFiles, duplicateProjectFiles, outputRoot, removeProjectDirectory, resolveSourcePath, safeRelativePath, sourceRoot } from "../files.js";
|
|
7
7
|
import { apiError, contentDisposition, httpError } from "../http.js";
|
|
8
8
|
import { isMainDocumentCandidate } from "../latexRoot.js";
|
|
9
|
+
import { lucideIconSvg, resolveLucideIconName } from "../lucideIcons.js";
|
|
9
10
|
import { accessibleProject, canEdit } from "../projects.js";
|
|
10
11
|
import { writeProjectArchive } from "../archive.js";
|
|
11
12
|
import { extractProjectZip, ZipValidationError } from "../zip.js";
|
|
@@ -14,6 +15,25 @@ import { commentsSummaryForProject, commentsSummaryForProjects, dictionaryWord,
|
|
|
14
15
|
/** Register project catalog, metadata, archive, dictionary, tag, export, and deletion routes. */
|
|
15
16
|
export function registerProjectCatalogRoutes(app, context) {
|
|
16
17
|
const { config, db, collaboration, projectMutations, latexCompletions, projectOutlines, harper, recordHistory } = context;
|
|
18
|
+
// Advanced project icons are served as cacheable SVG masks. The browser
|
|
19
|
+
// therefore does not have to bundle Lucide's entire icon catalogue merely
|
|
20
|
+
// because an owner selected an icon outside the curated picker.
|
|
21
|
+
app.get("/api/project-icons/:name", async (request, reply) => {
|
|
22
|
+
const user = requireUser(request, reply, db);
|
|
23
|
+
if (!user)
|
|
24
|
+
return;
|
|
25
|
+
const { name } = request.params;
|
|
26
|
+
const iconName = resolveLucideIconName(name);
|
|
27
|
+
if (!iconName)
|
|
28
|
+
return apiError(reply, 404, "PROJECT_ICON_INVALID");
|
|
29
|
+
const svg = await lucideIconSvg(iconName);
|
|
30
|
+
if (!svg)
|
|
31
|
+
return apiError(reply, 404, "PROJECT_ICON_INVALID");
|
|
32
|
+
return reply
|
|
33
|
+
.header("Cache-Control", "private, max-age=604800")
|
|
34
|
+
.type("image/svg+xml; charset=utf-8")
|
|
35
|
+
.send(svg);
|
|
36
|
+
});
|
|
17
37
|
app.get("/api/tags", async (request, reply) => {
|
|
18
38
|
const user = requireUser(request, reply, db);
|
|
19
39
|
if (!user)
|
|
@@ -21,20 +41,84 @@ export function registerProjectCatalogRoutes(app, context) {
|
|
|
21
41
|
const tags = db.prepare("SELECT id, name, color FROM user_tags WHERE user_id = ? ORDER BY name COLLATE NOCASE").all(user.id);
|
|
22
42
|
return { tags };
|
|
23
43
|
});
|
|
44
|
+
/**
|
|
45
|
+
* Return the current user's tag catalog together with usage counts. This is
|
|
46
|
+
* intentionally separate from /api/tags: the lightweight catalog is loaded
|
|
47
|
+
* with every project-list refresh, while counts are only needed by the tag
|
|
48
|
+
* management dialog.
|
|
49
|
+
*/
|
|
50
|
+
app.get("/api/tags/management", async (request, reply) => {
|
|
51
|
+
const user = requireUser(request, reply, db);
|
|
52
|
+
if (!user)
|
|
53
|
+
return;
|
|
54
|
+
const rows = db.prepare(`SELECT tag.id, tag.name, tag.color, COUNT(link.project_id) AS project_count
|
|
55
|
+
FROM user_tags tag
|
|
56
|
+
LEFT JOIN user_project_tag_links link ON link.tag_id = tag.id
|
|
57
|
+
WHERE tag.user_id = ?
|
|
58
|
+
GROUP BY tag.id
|
|
59
|
+
ORDER BY tag.name COLLATE NOCASE`).all(user.id);
|
|
60
|
+
return {
|
|
61
|
+
tags: rows.map(({ id, name, color, project_count }) => ({
|
|
62
|
+
id,
|
|
63
|
+
name,
|
|
64
|
+
color,
|
|
65
|
+
projectCount: Number(project_count) || 0
|
|
66
|
+
}))
|
|
67
|
+
};
|
|
68
|
+
});
|
|
24
69
|
app.post("/api/tags", async (request, reply) => {
|
|
25
70
|
const user = requireUser(request, reply, db);
|
|
26
71
|
if (!user)
|
|
27
72
|
return;
|
|
28
|
-
const body = request.body;
|
|
73
|
+
const body = (request.body ?? {});
|
|
29
74
|
const name = text(body.name, 32);
|
|
30
75
|
const color = tagColors.includes(body.color)
|
|
31
76
|
? body.color : "gray";
|
|
32
77
|
const tag = { id: randomUUID(), name, color };
|
|
33
78
|
const createdAt = now();
|
|
79
|
+
if (db.prepare("SELECT 1 FROM user_tags WHERE user_id = ? AND name = ?").get(user.id, tag.name)) {
|
|
80
|
+
return apiError(reply, 409, "TAG_NAME_EXISTS");
|
|
81
|
+
}
|
|
34
82
|
db.prepare("INSERT INTO user_tags (id, name, color, user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
35
83
|
.run(tag.id, tag.name, tag.color, user.id, createdAt, createdAt);
|
|
36
84
|
return reply.code(201).send({ tag });
|
|
37
85
|
});
|
|
86
|
+
app.patch("/api/tags/:tagId", async (request, reply) => {
|
|
87
|
+
const user = requireUser(request, reply, db);
|
|
88
|
+
if (!user)
|
|
89
|
+
return;
|
|
90
|
+
const { tagId } = request.params;
|
|
91
|
+
const body = (request.body ?? {});
|
|
92
|
+
const name = text(body.name, 32);
|
|
93
|
+
const color = tagColors.includes(body.color)
|
|
94
|
+
? body.color : "gray";
|
|
95
|
+
const existing = db.prepare("SELECT id FROM user_tags WHERE id = ? AND user_id = ?").get(tagId, user.id);
|
|
96
|
+
if (!existing)
|
|
97
|
+
return apiError(reply, 404, "TAG_NOT_FOUND");
|
|
98
|
+
if (db.prepare("SELECT 1 FROM user_tags WHERE user_id = ? AND name = ? AND id <> ?").get(user.id, name, tagId)) {
|
|
99
|
+
return apiError(reply, 409, "TAG_NAME_EXISTS");
|
|
100
|
+
}
|
|
101
|
+
db.prepare("UPDATE user_tags SET name = ?, color = ?, updated_at = ? WHERE id = ? AND user_id = ?")
|
|
102
|
+
.run(name, color, now(), tagId, user.id);
|
|
103
|
+
return { tag: { id: tagId, name, color } };
|
|
104
|
+
});
|
|
105
|
+
app.delete("/api/tags/:tagId", async (request, reply) => {
|
|
106
|
+
const user = requireUser(request, reply, db);
|
|
107
|
+
if (!user)
|
|
108
|
+
return;
|
|
109
|
+
const { tagId } = request.params;
|
|
110
|
+
const tag = db.prepare(`SELECT tag.id, COUNT(link.project_id) AS project_count
|
|
111
|
+
FROM user_tags tag
|
|
112
|
+
LEFT JOIN user_project_tag_links link ON link.tag_id = tag.id
|
|
113
|
+
WHERE tag.id = ? AND tag.user_id = ?
|
|
114
|
+
GROUP BY tag.id`).get(tagId, user.id);
|
|
115
|
+
if (!tag)
|
|
116
|
+
return apiError(reply, 404, "TAG_NOT_FOUND");
|
|
117
|
+
// Foreign-key cascading removes only this user's project/tag links. The
|
|
118
|
+
// projects and their source files are deliberately left untouched.
|
|
119
|
+
db.prepare("DELETE FROM user_tags WHERE id = ? AND user_id = ?").run(tag.id, user.id);
|
|
120
|
+
return { deletedId: tag.id, projectCount: Number(tag.project_count) || 0 };
|
|
121
|
+
});
|
|
38
122
|
app.get("/api/projects", async (request, reply) => {
|
|
39
123
|
const user = requireUser(request, reply, db);
|
|
40
124
|
if (!user)
|
|
@@ -103,7 +187,7 @@ export function registerProjectCatalogRoutes(app, context) {
|
|
|
103
187
|
const body = request.body;
|
|
104
188
|
const project = {
|
|
105
189
|
id: randomUUID(), owner_id: user.id, last_modified_by: user.id, name: text(body?.name, 120),
|
|
106
|
-
main_file: "main.tex", latexmkrc: null, engine: config.defaultEngine, created_at: now(), updated_at: now()
|
|
190
|
+
main_file: "main.tex", latexmkrc: null, engine: config.defaultEngine, icon: null, created_at: now(), updated_at: now()
|
|
107
191
|
};
|
|
108
192
|
createProjectFiles(config, project.id);
|
|
109
193
|
try {
|
|
@@ -135,7 +219,7 @@ export function registerProjectCatalogRoutes(app, context) {
|
|
|
135
219
|
const fallbackName = path.basename(part.filename, path.extname(part.filename));
|
|
136
220
|
const project = {
|
|
137
221
|
id: randomUUID(), owner_id: user.id, last_modified_by: user.id, name: text(query.name || fallbackName, 120),
|
|
138
|
-
main_file: "", latexmkrc: null, engine: config.defaultEngine, created_at: now(), updated_at: now()
|
|
222
|
+
main_file: "", latexmkrc: null, engine: config.defaultEngine, icon: null, created_at: now(), updated_at: now()
|
|
139
223
|
};
|
|
140
224
|
fs.mkdirSync(sourceRoot(config, project.id), { recursive: true, mode: 0o700 });
|
|
141
225
|
fs.mkdirSync(outputRoot(config, project.id), { recursive: true, mode: 0o700 });
|
|
@@ -173,7 +257,7 @@ export function registerProjectCatalogRoutes(app, context) {
|
|
|
173
257
|
const requestedName = typeof body?.name === "string" && body.name.trim() ? body.name : `${source.name.slice(0, 115)} (1)`;
|
|
174
258
|
const project = {
|
|
175
259
|
id: randomUUID(), owner_id: user.id, last_modified_by: user.id, name: text(requestedName, 120),
|
|
176
|
-
main_file: source.main_file, latexmkrc: source.latexmkrc, engine: source.engine, created_at: now(), updated_at: now()
|
|
260
|
+
main_file: source.main_file, latexmkrc: source.latexmkrc, engine: source.engine, icon: source.icon, created_at: now(), updated_at: now()
|
|
177
261
|
};
|
|
178
262
|
try {
|
|
179
263
|
// Duplicate the source tree only after flushing the live Yjs room and
|
|
@@ -187,9 +271,9 @@ export function registerProjectCatalogRoutes(app, context) {
|
|
|
187
271
|
}
|
|
188
272
|
}
|
|
189
273
|
});
|
|
190
|
-
db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, created_at, updated_at)
|
|
191
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
192
|
-
.run(project.id, project.owner_id, project.last_modified_by, project.name, project.main_file, project.latexmkrc, project.engine, project.created_at, project.updated_at);
|
|
274
|
+
db.prepare(`INSERT INTO projects (id, owner_id, last_modified_by, name, main_file, latexmkrc, engine, icon, created_at, updated_at)
|
|
275
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
276
|
+
.run(project.id, project.owner_id, project.last_modified_by, project.name, project.main_file, project.latexmkrc, project.engine, project.icon, project.created_at, project.updated_at);
|
|
193
277
|
}
|
|
194
278
|
catch (error) {
|
|
195
279
|
removeProjectDirectory(config, project.id);
|
|
@@ -213,6 +297,29 @@ export function registerProjectCatalogRoutes(app, context) {
|
|
|
213
297
|
project: projectJson(project, tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
|
|
214
298
|
};
|
|
215
299
|
});
|
|
300
|
+
/** Project icons are shared metadata, so only the project owner can change them. */
|
|
301
|
+
app.patch("/api/projects/:id/icon", async (request, reply) => {
|
|
302
|
+
const user = requireUser(request, reply, db);
|
|
303
|
+
if (!user)
|
|
304
|
+
return;
|
|
305
|
+
const { id } = request.params;
|
|
306
|
+
const project = accessibleProject(db, id, user);
|
|
307
|
+
if (!project || project.permission !== "owner")
|
|
308
|
+
return apiError(reply, 403, "PROJECT_OWNER_ONLY");
|
|
309
|
+
const body = (request.body ?? {});
|
|
310
|
+
const icon = body.icon === null ? null : resolveLucideIconName(body.icon);
|
|
311
|
+
if (body.icon !== null && !icon) {
|
|
312
|
+
return apiError(reply, 400, "PROJECT_ICON_INVALID");
|
|
313
|
+
}
|
|
314
|
+
const changedAt = now();
|
|
315
|
+
const updated = db.prepare("UPDATE projects SET icon = ?, updated_at = ?, last_modified_by = ? WHERE id = ? AND owner_id = ?")
|
|
316
|
+
.run(icon, changedAt, user.id, id, user.id);
|
|
317
|
+
if (!updated.changes)
|
|
318
|
+
return apiError(reply, 403, "PROJECT_OWNER_ONLY");
|
|
319
|
+
return {
|
|
320
|
+
project: projectJson(accessibleProject(db, id, user), tagsForProject(db, id, user.id), commentsSummaryForProject(db, id))
|
|
321
|
+
};
|
|
322
|
+
});
|
|
216
323
|
app.put("/api/projects/:id/archive", async (request, reply) => {
|
|
217
324
|
const user = requireUser(request, reply, db);
|
|
218
325
|
if (!user)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { requireUser } from "../auth.js";
|
|
2
|
+
import { maxCollaborativeFileBytes } from "../collaboration.js";
|
|
3
|
+
import { compileMainFile } from "../compileArtifacts.js";
|
|
4
|
+
import { assertNoSourceSymlinks, safeRelativePath, sourceRoot } from "../files.js";
|
|
5
|
+
import { apiError, httpError } from "../http.js";
|
|
6
|
+
import { accessibleProject } from "../projects.js";
|
|
7
|
+
import { TexcountExecutionError, TexcountUnavailableError } from "../texcount.js";
|
|
8
|
+
/** Register host TeXcount-backed full-document and editor-selection counts. */
|
|
9
|
+
export function registerWordCountRoutes(app, context) {
|
|
10
|
+
const { config, db, projectMutations, texcount } = context;
|
|
11
|
+
app.post("/api/projects/:id/word-count", async (request, reply) => {
|
|
12
|
+
const user = requireUser(request, reply, db);
|
|
13
|
+
if (!user)
|
|
14
|
+
return;
|
|
15
|
+
const { id } = request.params;
|
|
16
|
+
const project = accessibleProject(db, id, user);
|
|
17
|
+
if (!project)
|
|
18
|
+
return apiError(reply, 404, "PROJECT_NOT_FOUND");
|
|
19
|
+
const body = (request.body ?? {});
|
|
20
|
+
const mode = body.mode;
|
|
21
|
+
if (mode !== "full" && mode !== "selection")
|
|
22
|
+
return apiError(reply, 400, "WORD_COUNT_SOURCE_INVALID");
|
|
23
|
+
try {
|
|
24
|
+
if (mode === "selection") {
|
|
25
|
+
return await countSelection(config, id, body, texcount);
|
|
26
|
+
}
|
|
27
|
+
return await countDocument(config, db, projectMutations, texcount, id, user, body.mainFile);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error instanceof TexcountUnavailableError)
|
|
31
|
+
return apiError(reply, 503, "WORD_COUNT_UNAVAILABLE");
|
|
32
|
+
if (error instanceof TexcountExecutionError)
|
|
33
|
+
return apiError(reply, 422, "WORD_COUNT_FAILED");
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function countSelection(config, projectId, body, texcount) {
|
|
39
|
+
if (typeof body.path !== "string" || typeof body.source !== "string") {
|
|
40
|
+
throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
|
|
41
|
+
}
|
|
42
|
+
let filePath;
|
|
43
|
+
try {
|
|
44
|
+
filePath = safeRelativePath(body.path);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
|
|
48
|
+
}
|
|
49
|
+
// TeXcount's stdin parser is intended for TeX-like source. Keep BibTeX and
|
|
50
|
+
// binary resources out of this endpoint while allowing .sty/.cls snippets.
|
|
51
|
+
if (!/\.(?:tex|sty|cls)$/i.test(filePath) || !body.source.trim()) {
|
|
52
|
+
throw httpError(400, "WORD_COUNT_SOURCE_INVALID");
|
|
53
|
+
}
|
|
54
|
+
if (Buffer.byteLength(body.source, "utf8") > maxCollaborativeFileBytes(config)) {
|
|
55
|
+
throw httpError(413, "WORD_COUNT_SOURCE_TOO_LARGE");
|
|
56
|
+
}
|
|
57
|
+
const counts = await texcount.countSource(sourceRoot(config, projectId), body.source);
|
|
58
|
+
return { mode: "selection", path: filePath, ...counts };
|
|
59
|
+
}
|
|
60
|
+
async function countDocument(config, db, projectMutations, texcount, projectId, user, requestedMainFile) {
|
|
61
|
+
const initialProject = accessibleProject(db, projectId, user);
|
|
62
|
+
if (!initialProject)
|
|
63
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
64
|
+
const mainFile = compileMainFile(config, projectId, initialProject.main_file, requestedMainFile);
|
|
65
|
+
if (!mainFile)
|
|
66
|
+
throw httpError(400, "MAIN_DOCUMENT_INVALID");
|
|
67
|
+
return await projectMutations.runConsistentRead(projectId, async () => {
|
|
68
|
+
// TeXcount follows \input/\include paths. Reject links before handing it
|
|
69
|
+
// the source root so a checked-out link cannot expose files outside it.
|
|
70
|
+
assertNoSourceSymlinks(config, projectId);
|
|
71
|
+
const counts = await texcount.countFile(sourceRoot(config, projectId), mainFile);
|
|
72
|
+
return { mode: "full", path: mainFile, ...counts };
|
|
73
|
+
}, {
|
|
74
|
+
preflight: () => {
|
|
75
|
+
const current = accessibleProject(db, projectId, user);
|
|
76
|
+
if (!current)
|
|
77
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
78
|
+
const selected = compileMainFile(config, projectId, current.main_file, requestedMainFile);
|
|
79
|
+
if (!selected || selected !== mainFile)
|
|
80
|
+
throw httpError(400, "MAIN_DOCUMENT_INVALID");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
|
|
3
|
+
/** TeXcount is an optional host command supplied by TeX Live. */
|
|
4
|
+
export const TEXCOUNT_COMMAND = "texcount";
|
|
5
|
+
const TEXLITE_WORD_REPORT_PREFIX = "__TEXLITE_TEXCOUNT_WORDS__";
|
|
6
|
+
const TEXLITE_CHARACTER_REPORT_PREFIX = "__TEXLITE_TEXCOUNT_CHARS__";
|
|
7
|
+
// TeXcount placeholders {1} through {7} are, respectively: words in text,
|
|
8
|
+
// header words, caption words, header count, float count, inline math, and
|
|
9
|
+
// displayed math. Do not parse TeXcount's human-facing prose report.
|
|
10
|
+
const TEXLITE_WORD_REPORT_TEMPLATE = `${TEXLITE_WORD_REPORT_PREFIX}|{1}|{2}|{3}|{4}|{5}|{6}|{7}`;
|
|
11
|
+
const TEXLITE_CHARACTER_REPORT_TEMPLATE = `${TEXLITE_CHARACTER_REPORT_PREFIX}|{1}|{2}|{3}|{4}|{5}|{6}|{7}`;
|
|
12
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
13
|
+
const COUNT_TIMEOUT_MS = 30_000;
|
|
14
|
+
const UNAVAILABLE_RETRY_MS = 15_000;
|
|
15
|
+
const MAX_OUTPUT_BYTES = 512 * 1024;
|
|
16
|
+
export class TexcountUnavailableError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "TexcountUnavailableError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class TexcountExecutionError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "TexcountExecutionError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Run the optional host TeXcount binary and normalize its explicit report
|
|
30
|
+
* template into stable fields for the browser. The command is intentionally
|
|
31
|
+
* fixed (rather than shell-evaluated) so project sources cannot inject flags.
|
|
32
|
+
*/
|
|
33
|
+
export class TexcountService {
|
|
34
|
+
command;
|
|
35
|
+
availability = "unknown";
|
|
36
|
+
lastUnavailableAt = 0;
|
|
37
|
+
probePromise = null;
|
|
38
|
+
activeChildren = new Set();
|
|
39
|
+
disposed = false;
|
|
40
|
+
constructor(command = TEXCOUNT_COMMAND) {
|
|
41
|
+
this.command = command;
|
|
42
|
+
}
|
|
43
|
+
async countFile(cwd, filePath) {
|
|
44
|
+
await this.ensureAvailable();
|
|
45
|
+
return this.count(cwd, ["-inc", "-total"], filePath);
|
|
46
|
+
}
|
|
47
|
+
async countSource(cwd, source) {
|
|
48
|
+
await this.ensureAvailable();
|
|
49
|
+
// A dash tells TeXcount to read one virtual TeX document from stdin. No
|
|
50
|
+
// include traversal is requested for a selection because the selection
|
|
51
|
+
// is not a complete project tree.
|
|
52
|
+
return this.count(cwd, ["-total"], "-", source);
|
|
53
|
+
}
|
|
54
|
+
async dispose() {
|
|
55
|
+
this.disposed = true;
|
|
56
|
+
for (const child of this.activeChildren)
|
|
57
|
+
killProcessGroup(child, "SIGTERM");
|
|
58
|
+
this.activeChildren.clear();
|
|
59
|
+
await this.probePromise?.catch(() => undefined);
|
|
60
|
+
this.probePromise = null;
|
|
61
|
+
}
|
|
62
|
+
async ensureAvailable() {
|
|
63
|
+
if (this.disposed)
|
|
64
|
+
throw new TexcountUnavailableError("TeXcount service stopped.");
|
|
65
|
+
if (this.availability === "available")
|
|
66
|
+
return;
|
|
67
|
+
if (this.availability === "unavailable" && Date.now() - this.lastUnavailableAt < UNAVAILABLE_RETRY_MS) {
|
|
68
|
+
throw new TexcountUnavailableError(`The optional ${this.command} command is not available.`);
|
|
69
|
+
}
|
|
70
|
+
if (this.probePromise)
|
|
71
|
+
return this.probePromise;
|
|
72
|
+
this.probePromise = this.runCommand(["--version"], undefined, undefined, PROBE_TIMEOUT_MS, 64 * 1024)
|
|
73
|
+
.then(({ code, stdout, stderr }) => {
|
|
74
|
+
if (code !== 0)
|
|
75
|
+
throw new Error((stderr || stdout).trim() || `exited with code ${code}`);
|
|
76
|
+
this.availability = "available";
|
|
77
|
+
})
|
|
78
|
+
.catch((error) => {
|
|
79
|
+
this.availability = "unavailable";
|
|
80
|
+
this.lastUnavailableAt = Date.now();
|
|
81
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
82
|
+
throw new TexcountUnavailableError(`The optional ${this.command} command is unavailable: ${detail}`);
|
|
83
|
+
})
|
|
84
|
+
.finally(() => { this.probePromise = null; });
|
|
85
|
+
return this.probePromise;
|
|
86
|
+
}
|
|
87
|
+
async count(cwd, options, target, input) {
|
|
88
|
+
const [wordResult, characterResult] = await Promise.all([
|
|
89
|
+
this.runCommand([...options, `-template=${TEXLITE_WORD_REPORT_TEMPLATE}`, target], cwd, input),
|
|
90
|
+
this.runCommand([...options, "-char", `-template=${TEXLITE_CHARACTER_REPORT_TEMPLATE}`, target], cwd, input)
|
|
91
|
+
]);
|
|
92
|
+
const words = this.finishWordCount(wordResult);
|
|
93
|
+
const characters = this.finishCharacterCount(characterResult);
|
|
94
|
+
return {
|
|
95
|
+
...words,
|
|
96
|
+
totalCharacters: characters.text + characters.headers + characters.captions,
|
|
97
|
+
// TeXcount parses the same source twice. Do not show each syntax issue
|
|
98
|
+
// twice merely because character counting was requested as well.
|
|
99
|
+
parserErrors: Math.max(words.parserErrors, characters.parserErrors)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
finishWordCount(result) {
|
|
103
|
+
if (result.code !== 0) {
|
|
104
|
+
throw new TexcountExecutionError(result.stderr.trim() || `${this.command} exited with code ${result.code}`);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return parseTexcountOutput(result.stdout);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
throw new TexcountExecutionError(error instanceof Error ? error.message : String(error));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
finishCharacterCount(result) {
|
|
114
|
+
if (result.code !== 0) {
|
|
115
|
+
throw new TexcountExecutionError(result.stderr.trim() || `${this.command} exited with code ${result.code}`);
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return parseTexcountReport(result.stdout, TEXLITE_CHARACTER_REPORT_PREFIX);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
throw new TexcountExecutionError(error instanceof Error ? error.message : String(error));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
runCommand(args, cwd, input, timeoutMs = COUNT_TIMEOUT_MS, outputLimit = MAX_OUTPUT_BYTES) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
if (this.disposed) {
|
|
127
|
+
reject(new TexcountUnavailableError("TeXcount service stopped."));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
let child;
|
|
131
|
+
try {
|
|
132
|
+
child = spawn(this.command, args, {
|
|
133
|
+
cwd,
|
|
134
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
135
|
+
shell: false,
|
|
136
|
+
detached: detachedProcessGroup(),
|
|
137
|
+
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
reject(error);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.activeChildren.add(child);
|
|
145
|
+
const stdout = [];
|
|
146
|
+
const stderr = [];
|
|
147
|
+
let outputBytes = 0;
|
|
148
|
+
let settled = false;
|
|
149
|
+
let timedOut = false;
|
|
150
|
+
let outputTooLarge = false;
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
timedOut = true;
|
|
153
|
+
killProcessGroup(child);
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
const finish = (callback) => {
|
|
156
|
+
if (settled)
|
|
157
|
+
return;
|
|
158
|
+
settled = true;
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
this.activeChildren.delete(child);
|
|
161
|
+
callback();
|
|
162
|
+
};
|
|
163
|
+
const terminateWithError = (error) => {
|
|
164
|
+
killProcessGroup(child);
|
|
165
|
+
finish(() => reject(error));
|
|
166
|
+
};
|
|
167
|
+
const collect = (target) => (chunk) => {
|
|
168
|
+
outputBytes += chunk.length;
|
|
169
|
+
if (outputBytes > outputLimit) {
|
|
170
|
+
outputTooLarge = true;
|
|
171
|
+
terminateWithError(new TexcountExecutionError(`${this.command} produced too much output.`));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
target.push(chunk);
|
|
175
|
+
};
|
|
176
|
+
child.stdout?.on("data", collect(stdout));
|
|
177
|
+
child.stderr?.on("data", collect(stderr));
|
|
178
|
+
child.stdin?.on("error", (error) => terminateWithError(error));
|
|
179
|
+
child.once("error", (error) => finish(() => {
|
|
180
|
+
if (timedOut) {
|
|
181
|
+
reject(new TexcountExecutionError(`${this.command} timed out after ${Math.ceil(timeoutMs / 1000)} seconds.`));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (error.code === "ENOENT") {
|
|
185
|
+
reject(new TexcountUnavailableError(`The optional ${this.command} command is not available.`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
reject(error);
|
|
189
|
+
}));
|
|
190
|
+
child.once("close", (code) => finish(() => {
|
|
191
|
+
if (timedOut) {
|
|
192
|
+
reject(new TexcountExecutionError(`${this.command} timed out after ${Math.ceil(timeoutMs / 1000)} seconds.`));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (outputTooLarge) {
|
|
196
|
+
reject(new TexcountExecutionError(`${this.command} produced too much output.`));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
resolve({
|
|
200
|
+
code,
|
|
201
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
202
|
+
stderr: Buffer.concat(stderr).toString("utf8")
|
|
203
|
+
});
|
|
204
|
+
}));
|
|
205
|
+
if (input !== undefined)
|
|
206
|
+
child.stdin?.end(input, "utf8");
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** Parse TexLite's explicit TeXcount template, rather than prose output. */
|
|
211
|
+
export function parseTexcountOutput(output) {
|
|
212
|
+
const report = parseTexcountReport(output, TEXLITE_WORD_REPORT_PREFIX);
|
|
213
|
+
const filesMatch = /Files:\s*(\d+)/i.exec(output);
|
|
214
|
+
return {
|
|
215
|
+
textWords: report.text,
|
|
216
|
+
headerWords: report.headers,
|
|
217
|
+
captionWords: report.captions,
|
|
218
|
+
totalWords: report.text + report.headers + report.captions,
|
|
219
|
+
headers: report.headerCount,
|
|
220
|
+
floats: report.floatCount,
|
|
221
|
+
inlineMath: report.inlineMathCount,
|
|
222
|
+
displayMath: report.displayMathCount,
|
|
223
|
+
files: filesMatch ? Number(filesMatch[1]) : null,
|
|
224
|
+
parserErrors: report.parserErrors
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function parseTexcountReport(output, prefix) {
|
|
228
|
+
const report = new RegExp(`${prefix}\\|(\\d+)\\|(\\d+)\\|(\\d+)\\|(\\d+)\\|(\\d+)\\|(\\d+)\\|(\\d+)`).exec(output);
|
|
229
|
+
if (!report) {
|
|
230
|
+
throw new Error("TeXcount did not return TexLite's expected report; a %TC:newtemplate or %TC:template directive may have overridden it.");
|
|
231
|
+
}
|
|
232
|
+
const [, text, header, caption, headerCount, floatCount, inlineMathCount, displayMathCount] = report;
|
|
233
|
+
const errorsMatch = /\(errors:\s*(\d+)\)/i.exec(output);
|
|
234
|
+
return {
|
|
235
|
+
text: Number(text),
|
|
236
|
+
headers: Number(header),
|
|
237
|
+
captions: Number(caption),
|
|
238
|
+
headerCount: Number(headerCount),
|
|
239
|
+
floatCount: Number(floatCount),
|
|
240
|
+
inlineMathCount: Number(inlineMathCount),
|
|
241
|
+
displayMathCount: Number(displayMathCount),
|
|
242
|
+
parserErrors: errorsMatch ? Number(errorsMatch[1]) : 0
|
|
243
|
+
};
|
|
244
|
+
}
|