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.
Files changed (96) hide show
  1. package/DESIGN.md +7 -4
  2. package/NPM_TESTING.md +8 -2
  3. package/OPERATIONS.md +21 -6
  4. package/README.md +42 -15
  5. package/README.zh-CN.md +39 -15
  6. package/dist/client/assets/{GitDialog-DdLrbz_H.js → GitDialog-ULwrGkKd.js} +1 -1
  7. package/dist/client/assets/{HistoryDialog-AlF9iozX.js → HistoryDialog-BE7jyWWP.js} +3 -3
  8. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  9. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  10. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  12. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  13. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  14. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  15. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  16. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  18. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  19. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  20. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  21. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  22. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  23. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  24. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  25. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  26. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  27. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  28. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  29. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  30. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  31. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  32. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  33. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  34. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  35. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  36. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  37. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  38. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  39. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  40. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  45. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  46. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  47. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  48. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  49. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  50. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  51. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  52. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  53. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  54. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  55. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  56. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  57. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  58. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  59. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  60. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  61. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  62. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  63. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  64. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  65. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  66. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  67. package/dist/client/assets/LatexEditor-DbU8fMFZ.js +65 -0
  68. package/dist/client/assets/{PdfPreview-CanYnyfJ.js → PdfPreview-BputjYeR.js} +1 -1
  69. package/dist/client/assets/{ProjectNavigationDialogs-DiXtkTj1.js → ProjectNavigationDialogs-BVE1JsHd.js} +1 -1
  70. package/dist/client/assets/{SystemMetricsDialog-DimBI7er.js → SystemMetricsDialog-DxQsGcIH.js} +1 -1
  71. package/dist/client/assets/{editor-DMLs-XWi.js → editor-CiK7TK0p.js} +13 -13
  72. package/dist/client/assets/index-Dx9UqMyC.js +64 -0
  73. package/dist/client/assets/index-DzzqNm9b.css +1 -0
  74. package/dist/client/assets/katex-CEK31ho9.css +1 -0
  75. package/dist/client/assets/katex-COYOr4nK.js +257 -0
  76. package/dist/client/assets/{minus-DR-ttrx3.js → minus-B08vgSQJ.js} +1 -1
  77. package/dist/client/assets/{spellCheck-Wn7_eF9j.js → spellCheck-9fdyhJtV.js} +1 -1
  78. package/dist/client/index.html +2 -2
  79. package/dist/server/app.js +5 -0
  80. package/dist/server/cli.js +170 -32
  81. package/dist/server/db.js +4 -0
  82. package/dist/server/environment.js +168 -38
  83. package/dist/server/i18n.js +12 -0
  84. package/dist/server/lucideIcons.js +46 -0
  85. package/dist/server/projectIcons.js +96 -0
  86. package/dist/server/routes/projectShared.js +1 -0
  87. package/dist/server/routes/projects.js +114 -7
  88. package/dist/server/routes/wordCount.js +83 -0
  89. package/dist/server/texcount.js +244 -0
  90. package/package.json +4 -2
  91. package/preview-1.png +0 -0
  92. package/preview-2.png +0 -0
  93. package/dist/client/assets/LatexEditor-DH9v1wgS.js +0 -59
  94. package/dist/client/assets/index-CdiutUgf.css +0 -1
  95. package/dist/client/assets/index-vpWlpUOr.js +0 -64
  96. package/preview.png +0 -0
@@ -1 +1 @@
1
- import{j as e}from"./index-vpWlpUOr.js";const n=e("Maximize2",[["polyline",{points:"15 3 21 3 21 9",key:"mznyad"}],["polyline",{points:"9 21 3 21 3 15",key:"1avn1i"}],["line",{x1:"21",x2:"14",y1:"3",y2:"10",key:"ota7mn"}],["line",{x1:"3",x2:"10",y1:"21",y2:"14",key:"1atl0r"}]]);const a=e("Minus",[["path",{d:"M5 12h14",key:"1ays0h"}]]);export{a as M,n as a};
1
+ import{j as e}from"./index-Dx9UqMyC.js";const n=e("Maximize2",[["polyline",{points:"15 3 21 3 21 9",key:"mznyad"}],["polyline",{points:"9 21 3 21 3 15",key:"1avn1i"}],["line",{x1:"21",x2:"14",y1:"3",y2:"10",key:"ota7mn"}],["line",{x1:"3",x2:"10",y1:"21",y2:"14",key:"1atl0r"}]]);const a=e("Minus",[["path",{d:"M5 12h14",key:"1ays0h"}]]);export{a as M,n as a};
@@ -1 +1 @@
1
- import{a as k}from"./index-vpWlpUOr.js";import"./react-BoEjKzFZ.js";const S=5,u=new Map,x=12;let p=!1,f=null;class h extends Error{constructor(){super("A newer writing check replaced this request."),this.name="HarperLintSupersededError"}}function A(t){return t instanceof h}function v(t){const e=[0];let n=0;for(const r of t)n+=r.length,e.push(n);return e}function L(t){return[...new Set(t.map(e=>e.trim()).filter(Boolean))].sort((e,n)=>e.localeCompare(n)).join("\0")}async function C(t,e,n){return(await k(`/api/projects/${t}/spellcheck`,{method:"POST",body:JSON.stringify({path:e,source:n})})).lints}function R(t,e,n){if(!e.problem)return null;const r={from:e.start,to:e.end},o={from:n[e.start]??-1,to:n[e.end]??-1},i=c=>c.from>=0&&c.to>c.from&&c.to<=t.length,a=c=>i(c)&&t.slice(c.from,c.to)===e.problem;return a(o)?o:a(r)?r:null}function $(t,e,n){const r=R(t,e,n);if(!r)return null;const{from:o,to:i}=r,a=t.slice(o,i);if(!a.trim()||!/[A-Za-z]/.test(a)||e.kind==="Formatting")return null;const c=e.kind==="Spelling"||e.kind==="Typo"?"spelling":"grammar",m=e.suggestions.filter((d,s,l)=>s<S&&l.indexOf(d)===s);return{from:o,to:i,word:a,kind:c,message:e.message,suggestions:m}}function g(t,e){let n=2166136261;for(let r=0;r<e.length;r+=1)n^=e.charCodeAt(r),n=Math.imul(n,16777619);return`${t}\0${e.length}:${n>>>0}`}function y(t,e,n){const r=g(t,e),o=u.get(r);return!o||o.path!==t||o.source!==e||o.dictionary!==n?null:(u.delete(r),u.set(r,o),o.issues)}function j(t,e,n,r){const o=g(t,e);for(u.set(o,{path:t,source:e,dictionary:n,issues:r});u.size>x;){const i=u.keys().next().value;if(i===void 0)break;u.delete(i)}}async function q(t,e,n,r="main.tex"){const o=L(e),i=y(r,t,o);if(i)return i;const a=v(t),c=new Set(e.map(s=>s.trim().toLocaleLowerCase("en-US")).filter(Boolean)),m=new Set,d=n.map(s=>$(t,s,a)).filter(s=>!!s).filter(s=>s.kind!=="spelling"||!c.has(s.word.trim().toLocaleLowerCase("en-US"))).filter(s=>{const l=`${s.from}:${s.to}:${s.kind}`;return m.has(l)?!1:(m.add(l),!0)}).sort((s,l)=>s.from-l.from||s.to-l.to);return j(r,t,o,d),d}async function b(t,e,n,r){const o=L(r),i=y(e,n,o);if(i)return i;const a=await C(t,e,n);return q(n,r,a,e)}async function w(){if(p||!f)return;const t=f;f=null,p=!0;try{t.resolve(await b(t.projectId,t.path,t.source,t.customWords))}catch(e){t.reject(e)}finally{p=!1,w()}}function B(t,e,n,r=[]){return new Promise((o,i)=>{f?.reject(new h),f={projectId:t,path:e,source:n,customWords:[...r],resolve:o,reject:i},w()})}export{h as HarperLintSupersededError,A as isHarperLintSupersededError,B as lintLatex,q as mapLatexLints};
1
+ import{a as k}from"./index-Dx9UqMyC.js";import"./react-BoEjKzFZ.js";const S=5,u=new Map,x=12;let p=!1,f=null;class h extends Error{constructor(){super("A newer writing check replaced this request."),this.name="HarperLintSupersededError"}}function A(t){return t instanceof h}function v(t){const e=[0];let n=0;for(const r of t)n+=r.length,e.push(n);return e}function L(t){return[...new Set(t.map(e=>e.trim()).filter(Boolean))].sort((e,n)=>e.localeCompare(n)).join("\0")}async function C(t,e,n){return(await k(`/api/projects/${t}/spellcheck`,{method:"POST",body:JSON.stringify({path:e,source:n})})).lints}function R(t,e,n){if(!e.problem)return null;const r={from:e.start,to:e.end},o={from:n[e.start]??-1,to:n[e.end]??-1},i=c=>c.from>=0&&c.to>c.from&&c.to<=t.length,a=c=>i(c)&&t.slice(c.from,c.to)===e.problem;return a(o)?o:a(r)?r:null}function $(t,e,n){const r=R(t,e,n);if(!r)return null;const{from:o,to:i}=r,a=t.slice(o,i);if(!a.trim()||!/[A-Za-z]/.test(a)||e.kind==="Formatting")return null;const c=e.kind==="Spelling"||e.kind==="Typo"?"spelling":"grammar",m=e.suggestions.filter((d,s,l)=>s<S&&l.indexOf(d)===s);return{from:o,to:i,word:a,kind:c,message:e.message,suggestions:m}}function g(t,e){let n=2166136261;for(let r=0;r<e.length;r+=1)n^=e.charCodeAt(r),n=Math.imul(n,16777619);return`${t}\0${e.length}:${n>>>0}`}function y(t,e,n){const r=g(t,e),o=u.get(r);return!o||o.path!==t||o.source!==e||o.dictionary!==n?null:(u.delete(r),u.set(r,o),o.issues)}function j(t,e,n,r){const o=g(t,e);for(u.set(o,{path:t,source:e,dictionary:n,issues:r});u.size>x;){const i=u.keys().next().value;if(i===void 0)break;u.delete(i)}}async function q(t,e,n,r="main.tex"){const o=L(e),i=y(r,t,o);if(i)return i;const a=v(t),c=new Set(e.map(s=>s.trim().toLocaleLowerCase("en-US")).filter(Boolean)),m=new Set,d=n.map(s=>$(t,s,a)).filter(s=>!!s).filter(s=>s.kind!=="spelling"||!c.has(s.word.trim().toLocaleLowerCase("en-US"))).filter(s=>{const l=`${s.from}:${s.to}:${s.kind}`;return m.has(l)?!1:(m.add(l),!0)}).sort((s,l)=>s.from-l.from||s.to-l.to);return j(r,t,o,d),d}async function b(t,e,n,r){const o=L(r),i=y(e,n,o);if(i)return i;const a=await C(t,e,n);return q(n,r,a,e)}async function w(){if(p||!f)return;const t=f;f=null,p=!0;try{t.resolve(await b(t.projectId,t.path,t.source,t.customWords))}catch(e){t.reject(e)}finally{p=!1,w()}}function B(t,e,n,r=[]){return new Promise((o,i)=>{f?.reject(new h),f={projectId:t,path:e,source:n,customWords:[...r],resolve:o,reject:i},w()})}export{h as HarperLintSupersededError,A as isHarperLintSupersededError,B as lintLatex,q as mapLatexLints};
@@ -10,9 +10,9 @@
10
10
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/iosevka@5.2.5/400.css" />
11
11
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
12
12
  <title>TexLite</title>
13
- <script type="module" crossorigin src="/assets/index-vpWlpUOr.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-Dx9UqMyC.js"></script>
14
14
  <link rel="modulepreload" crossorigin href="/assets/react-BoEjKzFZ.js">
15
- <link rel="stylesheet" crossorigin href="/assets/index-CdiutUgf.css">
15
+ <link rel="stylesheet" crossorigin href="/assets/index-DzzqNm9b.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -31,7 +31,9 @@ import { registerProjectGitRoutes } from "./routes/projectGit.js";
31
31
  import { registerProjectCatalogRoutes } from "./routes/projects.js";
32
32
  import { registerSystemRoutes } from "./routes/system.js";
33
33
  import { registerUserManagementRoutes } from "./routes/users.js";
34
+ import { registerWordCountRoutes } from "./routes/wordCount.js";
34
35
  import { HarperService } from "./harper.js";
36
+ import { TexcountService } from "./texcount.js";
35
37
  // Retained public helper for callers and tests; implementation lives with the file routes.
36
38
  export { escapeGlobPattern } from "./routes/projectShared.js";
37
39
  const SESSION_CLEANUP_INTERVAL_MS = 15 * 60_000;
@@ -50,6 +52,7 @@ export async function buildApp(config, db, options = {}) {
50
52
  const latexCompletions = new LatexCompletionService(config);
51
53
  const projectOutlines = new ProjectOutlineService(config);
52
54
  const harper = new HarperService();
55
+ const texcount = new TexcountService();
53
56
  // Probe the optional host Harper CLI without delaying startup. Its absence is
54
57
  // supported: the browser spellchecker remains the writing-check fallback.
55
58
  void harper.preload().catch((error) => app.log.info({ err: error }, "Optional Harper CLI is unavailable"));
@@ -106,6 +109,7 @@ export async function buildApp(config, db, options = {}) {
106
109
  app.addHook("onClose", async () => {
107
110
  eventLoopDelay.disable();
108
111
  await harper.dispose();
112
+ await texcount.dispose();
109
113
  });
110
114
  await app.register(cookie, { hook: "onRequest" });
111
115
  await app.register(websocket, { options: { maxPayload: 6 * 1024 * 1024 } });
@@ -195,6 +199,7 @@ export async function buildApp(config, db, options = {}) {
195
199
  harper,
196
200
  recordHistory
197
201
  });
202
+ registerWordCountRoutes(app, { config, db, projectMutations, texcount });
198
203
  registerCompileRoutes(app, { config, db, collaboration, projectMutations, compileCoordinator, metrics, pruneCompileRuns });
199
204
  // The factory owns shared services and lifecycle hooks; route modules own endpoint behavior.
200
205
  app.addHook("onRequest", async (request) => {
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { CONFIG_DEFAULTS, loadConfig } from "./config.js";
9
9
  import { activeAdminCount, openDatabase } from "./db.js";
10
10
  import { hashPassword, MIN_PASSWORD_LENGTH } from "./security.js";
11
- import { assertEnvironment, assertGitAvailable } from "./environment.js";
11
+ import { assertEnvironment, hostRequirementsSatisfied, inspectHostEnvironment, inspectHostRequirements } from "./environment.js";
12
12
  import { serve } from "./index.js";
13
13
  import { processStatus, restartManaged, startManaged, stopManaged, streamLogs, waitForOnline } from "./pm2.js";
14
14
  import { defaultDataDirectory, resolveConfigPath } from "./runtimePaths.js";
@@ -18,42 +18,38 @@ Usage:
18
18
  texlite <command> [options]
19
19
 
20
20
  Commands:
21
- init Create the configuration, initialize storage, and create the first administrator.
22
- serve Run the server in the foreground for debugging, Docker, or systemd.
23
- start Start the server in the background under PM2.
24
- status Show the status of the PM2-managed server.
25
- stop Stop the PM2-managed server.
26
- restart Restart (or start) the PM2-managed server.
27
- logs Stream logs from the PM2-managed server.
28
- doctor Validate configuration, paths, LaTeX, and the administrator.
29
- config Print the effective configuration as JSON without changing it.
30
- help Show this help message.
31
- version Print the installed TexLite version.
21
+ init Create the configuration, initialize storage, and create the first administrator.
22
+ serve Run the server in the foreground for debugging, Docker, or systemd.
23
+ start Start the server in the background under PM2.
24
+ status Show the status of the PM2-managed server.
25
+ stop Stop the PM2-managed server.
26
+ restart Restart (or start) the PM2-managed server.
27
+ logs Stream logs from the PM2-managed server.
28
+ doctor Check configuration, application state, and host dependencies.
29
+ requirements Check host software on PATH without loading TexLite configuration.
30
+ config Print the effective configuration as JSON without changing it.
31
+ help Show this help message.
32
+ version Print the installed TexLite version.
32
33
 
33
34
  Options:
34
35
  --config PATH Use this configuration file instead of TEXLITE_CONFIG or the XDG default.
35
- --git Make doctor check the optional Git integration as well.
36
- --json Print status as JSON for scripts instead of the terminal view.
36
+ Requirements does not accept this option.
37
+ --json Print status, doctor, or requirements results as JSON for scripts.
37
38
  -h, --help Show this help message.
38
39
  -v, --version Print the installed TexLite version.
39
40
  `;
40
41
  export function parseArgs(args) {
41
42
  let command = "help";
42
43
  let configPath;
43
- let checkGit = false;
44
44
  let json = false;
45
45
  for (let index = 0; index < args.length; index += 1) {
46
46
  const argument = args[index];
47
47
  if (!argument)
48
48
  continue;
49
49
  if (argument === "-h" || argument === "--help")
50
- return { command: "help", configPath, checkGit, json };
50
+ return { command: "help", configPath, json };
51
51
  if (argument === "-v" || argument === "--version")
52
- return { command: "version", configPath, checkGit, json };
53
- if (argument === "--git") {
54
- checkGit = true;
55
- continue;
56
- }
52
+ return { command: "version", configPath, json };
57
53
  if (argument === "--json") {
58
54
  json = true;
59
55
  continue;
@@ -78,7 +74,10 @@ export function parseArgs(args) {
78
74
  throw new Error(`Only one command may be specified (already received ${command})`);
79
75
  command = argument;
80
76
  }
81
- return { command, configPath, checkGit, json };
77
+ if (command === "requirements" && configPath) {
78
+ throw new Error("`texlite requirements` does not use --config.");
79
+ }
80
+ return { command, configPath, json };
82
81
  }
83
82
  function output(message) {
84
83
  stdout.write(`${message}\n`);
@@ -209,16 +208,64 @@ async function assertAdmin(config) {
209
208
  }
210
209
  async function doctor(options) {
211
210
  const { config, configPath } = await loadValidatedConfig(options);
212
- const environment = await assertEnvironment(config);
213
- await assertAdmin(config);
214
- output(`Configuration is valid: ${configPath}`);
215
- output(`Data directory: ${config.dataDir}`);
216
- output(`Client assets: ${config.clientDir}`);
217
- output(`Environment: ${environment.map((item) => `${item.name} ${item.version}`).join("; ")}`);
218
- if (options.checkGit)
219
- output(`Git: ${(await assertGitAvailable(config)).version}`);
220
- else
221
- output("Git: not checked (Git integration is optional; use --git to check it)");
211
+ const application = inspectDoctorApplication(config, configPath);
212
+ const hostTools = await inspectHostEnvironment(config);
213
+ const report = {
214
+ configPath,
215
+ dataDir: config.dataDir,
216
+ clientDir: config.clientDir,
217
+ application,
218
+ hostTools,
219
+ ok: application.every((check) => check.status === "passed")
220
+ && hostRequirementsSatisfied(hostTools)
221
+ };
222
+ output(options.json ? JSON.stringify(report, null, 2) : formatDoctorReport(report, terminalColorsEnabled()));
223
+ if (!report.ok)
224
+ process.exitCode = 2;
225
+ }
226
+ async function requirements(options) {
227
+ const hostTools = await inspectHostRequirements();
228
+ const report = { hostTools, ok: hostRequirementsSatisfied(hostTools) };
229
+ output(options.json ? JSON.stringify(report, null, 2) : formatRequirementsReport(report, terminalColorsEnabled()));
230
+ if (!report.ok)
231
+ process.exitCode = 2;
232
+ }
233
+ function inspectDoctorApplication(config, configPath) {
234
+ const checks = [
235
+ { name: "Configuration", status: "passed", detail: `Valid: ${configPath}` },
236
+ inspectClientAssets(config)
237
+ ];
238
+ let db = null;
239
+ try {
240
+ db = openDatabase(config);
241
+ checks.push({ name: "Data and database", status: "passed", detail: `Opened: ${config.databasePath}` });
242
+ const administrators = activeAdminCount(db);
243
+ checks.push(administrators > 0
244
+ ? { name: "Administrator", status: "passed", detail: `${administrators} active administrator${administrators === 1 ? "" : "s"}` }
245
+ : { name: "Administrator", status: "failed", detail: "No active administrator. Run `texlite init`." });
246
+ }
247
+ catch (error) {
248
+ const detail = errorMessage(error);
249
+ checks.push({ name: "Data and database", status: "failed", detail });
250
+ checks.push({ name: "Administrator", status: "failed", detail: "Could not inspect because the database is unavailable." });
251
+ }
252
+ finally {
253
+ db?.close();
254
+ }
255
+ return checks;
256
+ }
257
+ function inspectClientAssets(config) {
258
+ const entry = path.join(config.clientDir, "index.html");
259
+ try {
260
+ const stat = fs.statSync(entry);
261
+ if (stat.isFile())
262
+ return { name: "Client assets", status: "passed", detail: `Found: ${entry}` };
263
+ }
264
+ catch { /* Report a concise diagnostic below. */ }
265
+ return { name: "Client assets", status: "failed", detail: `Missing ${entry}; run \`npm run build\` or reinstall TexLite.` };
266
+ }
267
+ function errorMessage(error) {
268
+ return error instanceof Error ? error.message.replace(/\s+/g, " ").slice(0, 240) : String(error).slice(0, 240);
222
269
  }
223
270
  async function printConfig(options) {
224
271
  const { config, configPath } = await loadValidatedConfig(options);
@@ -282,6 +329,94 @@ function terminalColorsEnabled() {
282
329
  function colorize(value, color, enabled) {
283
330
  return enabled ? `${ANSI[color]}${value}${ANSI.reset}` : value;
284
331
  }
332
+ export function formatDoctorReport(report, colors = false) {
333
+ const titleColor = report.ok ? "green" : "red";
334
+ const title = report.ok ? "all required checks passed" : "required checks need attention";
335
+ const applicationRows = report.application.map((check) => [
336
+ { text: check.name },
337
+ { text: "Required" },
338
+ { text: check.status === "passed" ? "Passed" : "Failed", color: check.status === "passed" ? "green" : "red" },
339
+ { text: shortenDoctorCell(check.detail, 68) }
340
+ ]);
341
+ return [
342
+ `${colorize("●", titleColor, colors)} ${colors ? `${ANSI.bold}TexLite doctor${ANSI.reset}` : "TexLite doctor"} — ${colorize(title, titleColor, colors)}`,
343
+ ` Config: ${report.configPath}`,
344
+ ` Data: ${report.dataDir}`,
345
+ ` Client: ${report.clientDir}`,
346
+ "",
347
+ "Application checks",
348
+ formatDoctorTable(["Check", "Need", "Status", "Details"], applicationRows, colors),
349
+ "",
350
+ "Host software",
351
+ formatHostSoftwareTable(report.hostTools, colors),
352
+ "",
353
+ hostRequirementNote(report.hostTools, "start TexLite"),
354
+ "Harper CLI is used by TexLite; harper-ls is reported for host diagnostics and external editor integrations."
355
+ ].join("\n");
356
+ }
357
+ export function formatRequirementsReport(report, colors = false) {
358
+ const titleColor = report.ok ? "green" : "red";
359
+ const title = report.ok ? "all host requirements passed" : "host requirements need attention";
360
+ return [
361
+ `${colorize("●", titleColor, colors)} ${colors ? `${ANSI.bold}TexLite requirements${ANSI.reset}` : "TexLite requirements"} — ${colorize(title, titleColor, colors)}`,
362
+ "",
363
+ "Host software (default commands on PATH)",
364
+ formatHostSoftwareTable(report.hostTools, colors),
365
+ "",
366
+ hostRequirementNote(report.hostTools, "compile with TexLite"),
367
+ "This command does not read a TexLite configuration, data directory, database, or administrator state.",
368
+ "Harper CLI is used by TexLite; harper-ls is reported for host diagnostics and external editor integrations."
369
+ ].join("\n");
370
+ }
371
+ function formatHostSoftwareTable(hostTools, colors) {
372
+ const hostRows = hostTools.map((tool) => [
373
+ { text: shortenDoctorCell(tool.command === tool.name ? tool.name : `${tool.name} (${tool.command})`, 32) },
374
+ { text: requirementLabel(tool.requirement) },
375
+ hostToolStatusCell(tool),
376
+ { text: shortenDoctorCell(tool.version ?? tool.detail ?? "—", 56) }
377
+ ]);
378
+ return formatDoctorTable(["Tool / command", "Need", "Status", "Version / diagnostic"], hostRows, colors);
379
+ }
380
+ function requirementLabel(requirement) {
381
+ if (requirement === "required")
382
+ return "Required";
383
+ if (requirement === "one-of")
384
+ return "One of";
385
+ return "Optional";
386
+ }
387
+ function hostRequirementNote(hostTools, action) {
388
+ const oneOf = hostTools.some((tool) => tool.requirement === "one-of");
389
+ return oneOf
390
+ ? `Required tools and at least one tool in every “One of” group must be installed to ${action}. Optional tools enable their named feature without blocking the editor.`
391
+ : `Required tools must be installed to ${action}. Optional tools enable their named feature without blocking the editor.`;
392
+ }
393
+ function hostToolStatusCell(tool) {
394
+ if (tool.status === "installed")
395
+ return { text: "Installed", color: "green" };
396
+ if (tool.status === "missing") {
397
+ return { text: tool.requirement === "required" ? "Missing" : "Not installed", color: tool.requirement === "required" ? "red" : "yellow" };
398
+ }
399
+ return { text: "Failed", color: "red" };
400
+ }
401
+ function shortenDoctorCell(value, maxLength) {
402
+ const normalized = value.replace(/\s+/g, " ").trim();
403
+ return normalized.length > maxLength ? `${normalized.slice(0, Math.max(1, maxLength - 1))}…` : normalized;
404
+ }
405
+ function formatDoctorTable(headers, rows, colors) {
406
+ const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.text.length ?? 0)));
407
+ const line = (left, middle, right, fill) => `${left}${widths.map((width) => fill.repeat(width + 2)).join(middle)}${right}`;
408
+ const renderRow = (cells) => `│${cells.map((cell, index) => {
409
+ const padded = (cell?.text ?? "").padEnd(widths[index] ?? 0);
410
+ return ` ${cell?.color ? colorize(padded, cell.color, colors) : padded} `;
411
+ }).join("│")}│`;
412
+ return [
413
+ line("┌", "┬", "┐", "─"),
414
+ renderRow(headers.map((text) => ({ text }))),
415
+ line("├", "┼", "┤", "─"),
416
+ ...rows.map(renderRow),
417
+ line("└", "┴", "┘", "─")
418
+ ].join("\n");
419
+ }
285
420
  function duration(value) {
286
421
  if (value === null)
287
422
  return "n/a";
@@ -371,6 +506,9 @@ async function run(options) {
371
506
  case "doctor":
372
507
  await doctor(options);
373
508
  return;
509
+ case "requirements":
510
+ await requirements(options);
511
+ return;
374
512
  case "config":
375
513
  await printConfig(options);
376
514
  return;
package/dist/server/db.js CHANGED
@@ -39,6 +39,7 @@ function migrate(db) {
39
39
  main_file TEXT NOT NULL DEFAULT 'main.tex',
40
40
  latexmkrc TEXT,
41
41
  engine TEXT NOT NULL DEFAULT 'xelatex' CHECK (engine IN ('pdflatex', 'xelatex', 'lualatex')),
42
+ icon TEXT,
42
43
  created_at TEXT NOT NULL,
43
44
  updated_at TEXT NOT NULL
44
45
  );
@@ -231,6 +232,9 @@ function migrate(db) {
231
232
  if (!projectColumns.some((column) => column.name === "last_modified_by")) {
232
233
  db.exec("ALTER TABLE projects ADD COLUMN last_modified_by TEXT REFERENCES users(id) ON DELETE SET NULL");
233
234
  }
235
+ if (!projectColumns.some((column) => column.name === "icon")) {
236
+ db.exec("ALTER TABLE projects ADD COLUMN icon TEXT");
237
+ }
234
238
  const commentColumns = db.prepare("PRAGMA table_info(comments)").all();
235
239
  const additions = [
236
240
  ["start_offset", "INTEGER NOT NULL DEFAULT 0"],
@@ -1,50 +1,167 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { LATEX_ENGINES } from "./config.js";
2
3
  import { httpError } from "./http.js";
3
4
  import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
4
- export async function assertEnvironment(config) {
5
- const commands = [
6
- { name: "latexmk", command: config.latexmk },
7
- ...config.allowedEngines.map((command) => ({ name: command, command }))
8
- ];
9
- const checked = [];
10
- const seen = new Set();
11
- for (const item of commands) {
12
- if (seen.has(item.command))
5
+ export const MIN_NODE_MAJOR = 24;
6
+ const commandProbeTimeoutMs = 10_000;
7
+ const maxCommandOutputBytes = 64 * 1024;
8
+ /**
9
+ * Probe all tools that are relevant to a TexLite host. Optional tools are
10
+ * included deliberately: their absence must be visible in `doctor`, but does
11
+ * not prevent TexLite from starting.
12
+ */
13
+ export async function inspectHostEnvironment(config) {
14
+ return Promise.all(hostToolDefinitions(config).map((tool) => inspectTool(tool)));
15
+ }
16
+ /**
17
+ * Probe the default commands on PATH without loading a TexLite configuration.
18
+ * This supports pre-installation checks through `texlite requirements`.
19
+ */
20
+ export async function inspectHostRequirements() {
21
+ return Promise.all(hostRequirementToolDefinitions().map((tool) => inspectTool(tool)));
22
+ }
23
+ export function hostRequirementsSatisfied(tools) {
24
+ if (tools.some((tool) => tool.requirement === "required" && tool.status !== "installed"))
25
+ return false;
26
+ const oneOfGroups = new Map();
27
+ for (const tool of tools) {
28
+ if (tool.requirement !== "one-of")
13
29
  continue;
14
- seen.add(item.command);
15
- const result = await commandVersion(item.command, 10_000);
16
- if (result.error || result.status !== 0) {
17
- const detail = result.error || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
18
- throw new Error(`Environment check failed: could not run the ${item.name} command "${item.command}" (${detail}). Initialization/startup has been stopped.`);
19
- }
20
- const version = `${result.stdout || result.stderr}`.trim().split("\n")[0]?.slice(0, 160) || "available";
21
- checked.push({ ...item, version });
30
+ const group = tool.requirementGroup ?? tool.id;
31
+ const members = oneOfGroups.get(group) ?? [];
32
+ members.push(tool);
33
+ oneOfGroups.set(group, members);
34
+ }
35
+ return [...oneOfGroups.values()].every((members) => members.some((tool) => tool.status === "installed"));
36
+ }
37
+ export async function assertEnvironment(config) {
38
+ const tools = await Promise.all(requiredToolDefinitions(config).map((tool) => inspectTool(tool)));
39
+ const unavailable = tools.find((tool) => tool.status !== "installed");
40
+ if (unavailable) {
41
+ const detail = unavailable.detail ?? "unknown error";
42
+ throw new Error(`Environment check failed: could not run the ${unavailable.name} command "${unavailable.command}" (${detail}). Initialization/startup has been stopped.`);
22
43
  }
23
- return checked;
44
+ return tools.map(({ name, command, version }) => ({ name, command, version: version ?? "available" }));
24
45
  }
25
46
  export async function assertGitAvailable(config) {
26
- const item = { name: "Git", command: config.git };
27
- const result = await commandVersion(item.command, Math.min(config.gitOperationTimeoutMs, 10_000));
28
- if (result.error || result.status !== 0) {
29
- const detail = result.error || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
30
- // Command output can reveal host paths; it remains a startup diagnostic,
31
- // while API clients receive the localized, stable error code.
32
- void detail;
47
+ const tool = await inspectTool(gitToolDefinition(config.git, Math.min(config.gitOperationTimeoutMs, commandProbeTimeoutMs)));
48
+ if (tool.status !== "installed")
33
49
  throw httpError(503, "GIT_UNAVAILABLE");
50
+ return { name: tool.name, command: tool.command, version: tool.version ?? "available" };
51
+ }
52
+ function requiredToolDefinitions(config) {
53
+ return [
54
+ { id: "node", name: `Node.js ${MIN_NODE_MAJOR}+`, command: "node", requirement: "required", purpose: "TexLite runtime" },
55
+ { id: "latexmk", name: "latexmk", command: config.latexmk, requirement: "required", purpose: "LaTeX compilation" },
56
+ ...config.allowedEngines.map((command) => ({
57
+ id: `engine:${command}`, name: command, command, requirement: "required",
58
+ purpose: "Configured LaTeX engine"
59
+ }))
60
+ ];
61
+ }
62
+ function hostToolDefinitions(config) {
63
+ return [
64
+ ...requiredToolDefinitions(config),
65
+ ...optionalToolDefinitions(config.git, Math.min(config.gitOperationTimeoutMs, commandProbeTimeoutMs))
66
+ ];
67
+ }
68
+ function hostRequirementToolDefinitions() {
69
+ return [
70
+ { id: "node", name: `Node.js ${MIN_NODE_MAJOR}+`, command: "node", requirement: "required", purpose: "TexLite runtime" },
71
+ { id: "latexmk", name: "latexmk", command: "latexmk", requirement: "required", purpose: "LaTeX compilation" },
72
+ ...LATEX_ENGINES.map((command) => ({
73
+ id: `engine:${command}`, name: command, command, requirement: "one-of",
74
+ requirementGroup: "latex-engine", purpose: "Supported LaTeX engine"
75
+ })),
76
+ ...optionalToolDefinitions("git", commandProbeTimeoutMs)
77
+ ];
78
+ }
79
+ function optionalToolDefinitions(gitCommand, gitTimeoutMs) {
80
+ return [
81
+ gitToolDefinition(gitCommand, gitTimeoutMs),
82
+ { id: "texcount", name: "TeXcount", command: "texcount", requirement: "optional", purpose: "Word and character statistics", versionArgs: ["-version"] },
83
+ { id: "bibtex", name: "BibTeX", command: "bibtex", requirement: "optional", purpose: "BibTeX bibliography builds" },
84
+ { id: "biber", name: "Biber", command: "biber", requirement: "optional", purpose: "Biber bibliography builds" },
85
+ { id: "makeindex", name: "MakeIndex", command: "makeindex", requirement: "optional", purpose: "Index generation", versionArgs: [] },
86
+ { id: "harper-cli", name: "Harper CLI", command: "harper-cli", requirement: "optional", purpose: "TexLite spelling and grammar checks" },
87
+ { id: "harper-ls", name: "Harper language server", command: "harper-ls", requirement: "optional", purpose: "External editor integration (not used by TexLite)" }
88
+ ];
89
+ }
90
+ function gitToolDefinition(command, timeoutMs) {
91
+ return { id: "git", name: "Git", command, requirement: "optional", purpose: "Git and GitHub backup", timeoutMs };
92
+ }
93
+ async function inspectTool(tool) {
94
+ if (tool.id === "node")
95
+ return inspectNode(tool);
96
+ const identity = toolIdentity(tool);
97
+ const result = await commandVersion(tool.command, tool.timeoutMs ?? commandProbeTimeoutMs, tool.versionArgs ?? ["--version"]);
98
+ if (!result.error && result.status === 0) {
99
+ return { ...identity, status: "installed", version: versionFromResult(result), detail: null };
34
100
  }
35
- const version = `${result.stdout || result.stderr}`.trim().split("\n")[0]?.slice(0, 160) || "available";
36
- return { ...item, version };
101
+ const missing = result.errorCode === "ENOENT";
102
+ return {
103
+ ...identity,
104
+ status: missing ? "missing" : "failed",
105
+ version: null,
106
+ detail: commandFailureDetail(result)
107
+ };
108
+ }
109
+ function inspectNode(tool) {
110
+ const identity = toolIdentity(tool);
111
+ const match = /^v(\d+)/.exec(process.version);
112
+ const major = match ? Number(match[1]) : Number.NaN;
113
+ if (Number.isInteger(major) && major >= MIN_NODE_MAJOR) {
114
+ return { ...identity, status: "installed", version: process.version, detail: null };
115
+ }
116
+ return {
117
+ ...identity,
118
+ status: "failed",
119
+ version: process.version || null,
120
+ detail: `TexLite requires Node.js ${MIN_NODE_MAJOR} or newer.`
121
+ };
122
+ }
123
+ function toolIdentity(tool) {
124
+ return {
125
+ id: tool.id,
126
+ name: tool.name,
127
+ command: tool.command,
128
+ requirement: tool.requirement,
129
+ requirementGroup: tool.requirementGroup,
130
+ purpose: tool.purpose
131
+ };
132
+ }
133
+ function versionFromResult(result) {
134
+ const lines = `${result.stdout}\n${result.stderr}`.split(/\r?\n/)
135
+ .map((line) => line.trim())
136
+ .filter((line) => line && !/^warning\b/i.test(line) && !/^possible precedence problem\b/i.test(line));
137
+ const versionLine = lines.find((line) => /\bversion\b|\bv?\d+\.\d+/i.test(line)) ?? lines[0];
138
+ return (versionLine ?? "available").slice(0, 160);
37
139
  }
38
- function commandVersion(command, timeoutMs) {
140
+ function commandFailureDetail(result) {
141
+ const detail = result.error || result.stderr.trim() || result.stdout.trim() || `exit ${result.status ?? "unknown"}`;
142
+ return detail.replace(/\s+/g, " ").slice(0, 240);
143
+ }
144
+ function commandVersion(command, timeoutMs, args) {
39
145
  return new Promise((resolve) => {
40
- const child = spawn(command, ["--version"], {
41
- env: { ...process.env, LC_ALL: "C" }, shell: false, detached: detachedProcessGroup(),
42
- stdio: ["ignore", "pipe", "pipe"]
43
- });
146
+ let child;
147
+ try {
148
+ child = spawn(command, args, {
149
+ env: { ...process.env, LC_ALL: "C" }, shell: false, detached: detachedProcessGroup(),
150
+ stdio: ["ignore", "pipe", "pipe"]
151
+ });
152
+ }
153
+ catch (error) {
154
+ const message = error instanceof Error ? error.message : String(error);
155
+ const errorCode = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : null;
156
+ resolve({ status: null, stdout: "", stderr: "", error: message, errorCode });
157
+ return;
158
+ }
44
159
  const stdout = [];
45
160
  const stderr = [];
161
+ let outputBytes = 0;
46
162
  let settled = false;
47
163
  let timedOut = false;
164
+ let outputExceeded = false;
48
165
  const finish = (result) => {
49
166
  if (settled)
50
167
  return;
@@ -53,11 +170,24 @@ function commandVersion(command, timeoutMs) {
53
170
  resolve({ ...result, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
54
171
  };
55
172
  const timer = setTimeout(() => { timedOut = true; killProcessGroup(child); }, timeoutMs);
56
- child.stdout.on("data", (chunk) => { if (Buffer.concat(stdout).length < 64 * 1024)
57
- stdout.push(chunk); });
58
- child.stderr.on("data", (chunk) => { if (Buffer.concat(stderr).length < 64 * 1024)
59
- stderr.push(chunk); });
60
- child.once("error", (error) => finish({ status: null, error: error.message }));
61
- child.once("close", (status) => finish({ status, error: timedOut ? "command timed out" : null }));
173
+ const collect = (target) => (chunk) => {
174
+ if (settled)
175
+ return;
176
+ outputBytes += chunk.length;
177
+ if (outputBytes > maxCommandOutputBytes) {
178
+ outputExceeded = true;
179
+ killProcessGroup(child);
180
+ return;
181
+ }
182
+ target.push(chunk);
183
+ };
184
+ child.stdout.on("data", collect(stdout));
185
+ child.stderr.on("data", collect(stderr));
186
+ child.once("error", (error) => finish({ status: null, error: error.message, errorCode: error.code ?? null }));
187
+ child.once("close", (status) => finish({
188
+ status,
189
+ error: timedOut ? "command timed out" : outputExceeded ? "command produced too much output" : null,
190
+ errorCode: null
191
+ }));
62
192
  });
63
193
  }
@@ -69,6 +69,7 @@ const enMessages = {
69
69
  PROJECT_CREATE_FORBIDDEN: "You do not have permission to create projects.",
70
70
  PROJECT_DELETE_FORBIDDEN: "Only the project owner can delete this project.",
71
71
  PROJECT_EDIT_FORBIDDEN: "You do not have edit permission for this project.",
72
+ PROJECT_ICON_INVALID: "Enter a valid installed Lucide icon short name.",
72
73
  PROJECT_NOT_FOUND: "The project does not exist or is no longer accessible.",
73
74
  PROJECT_OWNER_ONLY: "Only the project owner can perform this action.",
74
75
  PROJECT_TRANSFER_FORBIDDEN: "Only the current project owner can transfer ownership.",
@@ -91,10 +92,15 @@ const enMessages = {
91
92
  SYMLINK_FORBIDDEN: "Symbolic links are not allowed in project sources.",
92
93
  SYNC_PDF_INVALID: "The PDF location is invalid.",
93
94
  SYNC_SOURCE_INVALID: "The source location is invalid.",
95
+ TAG_NAME_EXISTS: "You already have a tag with this name.",
94
96
  TAG_NOT_FOUND: "The selected tag does not exist.",
95
97
  UPLOAD_EMPTY: "No file was selected for upload.",
96
98
  USERNAME_INVALID: "The username may contain only letters, numbers, dots, hyphens, or underscores.",
97
99
  USER_NOT_FOUND: "The user does not exist or is disabled.",
100
+ WORD_COUNT_FAILED: "TeXcount could not count this source.",
101
+ WORD_COUNT_SOURCE_INVALID: "Select LaTeX source text to count.",
102
+ WORD_COUNT_SOURCE_TOO_LARGE: "The selected source is too large to count.",
103
+ WORD_COUNT_UNAVAILABLE: "TeXcount is not available on this server. Install it from TeX Live and try again.",
98
104
  ZIP_DUPLICATE_ENTRY: "The ZIP archive contains a duplicate filename: {{path}}.",
99
105
  ZIP_ENTRY_READ_FAILED: "A ZIP archive entry could not be read.",
100
106
  ZIP_ENTRY_TOO_LARGE: "The ZIP archive contains a file that exceeds the permitted size.",
@@ -189,6 +195,7 @@ const zhMessages = {
189
195
  PROJECT_CREATE_FORBIDDEN: "管理员尚未授予你创建项目的权限。",
190
196
  PROJECT_DELETE_FORBIDDEN: "只有项目所有者可以删除项目。",
191
197
  PROJECT_EDIT_FORBIDDEN: "你没有此项目的编辑权限。",
198
+ PROJECT_ICON_INVALID: "请输入已安装的有效 Lucide 图标短名称。",
192
199
  PROJECT_NOT_FOUND: "项目不存在或你已无法访问。",
193
200
  PROJECT_OWNER_ONLY: "只有项目所有者可以执行此操作。",
194
201
  PROJECT_TRANSFER_FORBIDDEN: "只有当前项目所有者可以转让项目。",
@@ -211,10 +218,15 @@ const zhMessages = {
211
218
  SYMLINK_FORBIDDEN: "项目源码中不允许使用符号链接。",
212
219
  SYNC_PDF_INVALID: "PDF 位置无效。",
213
220
  SYNC_SOURCE_INVALID: "源码位置无效。",
221
+ TAG_NAME_EXISTS: "你已经有同名标签。",
214
222
  TAG_NOT_FOUND: "所选标签不存在。",
215
223
  UPLOAD_EMPTY: "没有选择要上传的文件。",
216
224
  USERNAME_INVALID: "用户名只能包含字母、数字、点、横线或下划线。",
217
225
  USER_NOT_FOUND: "用户不存在或已被禁用。",
226
+ WORD_COUNT_FAILED: "TeXcount 无法统计这段源码。",
227
+ WORD_COUNT_SOURCE_INVALID: "请选择 LaTeX 源码后再统计。",
228
+ WORD_COUNT_SOURCE_TOO_LARGE: "所选源码过大,无法统计。",
229
+ WORD_COUNT_UNAVAILABLE: "服务器未安装 TeXcount,请安装 TeX Live 后重试。",
218
230
  ZIP_DUPLICATE_ENTRY: "ZIP 压缩包包含重复文件名:{{path}}。",
219
231
  ZIP_ENTRY_READ_FAILED: "无法读取 ZIP 压缩包中的文件。",
220
232
  ZIP_ENTRY_TOO_LARGE: "ZIP 压缩包中存在超过限制的文件。",