texlite 0.8.1 → 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 (85) hide show
  1. package/README.md +36 -14
  2. package/README.zh-CN.md +34 -14
  3. package/dist/client/assets/{GitDialog-CZRQPg3O.js → GitDialog-ULwrGkKd.js} +1 -1
  4. package/dist/client/assets/{HistoryDialog-BQtr58Co.js → HistoryDialog-BE7jyWWP.js} +3 -3
  5. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/dist/client/assets/LatexEditor-DbU8fMFZ.js +65 -0
  65. package/dist/client/assets/{PdfPreview-D5CC-vqV.js → PdfPreview-BputjYeR.js} +1 -1
  66. package/dist/client/assets/{ProjectNavigationDialogs-BdtTldSa.js → ProjectNavigationDialogs-BVE1JsHd.js} +1 -1
  67. package/dist/client/assets/{SystemMetricsDialog-CEbYJdj7.js → SystemMetricsDialog-DxQsGcIH.js} +1 -1
  68. package/dist/client/assets/{editor-DMLs-XWi.js → editor-CiK7TK0p.js} +13 -13
  69. package/dist/client/assets/index-Dx9UqMyC.js +64 -0
  70. package/dist/client/assets/index-DzzqNm9b.css +1 -0
  71. package/dist/client/assets/katex-CEK31ho9.css +1 -0
  72. package/dist/client/assets/katex-COYOr4nK.js +257 -0
  73. package/dist/client/assets/{minus-KzdrD7TK.js → minus-B08vgSQJ.js} +1 -1
  74. package/dist/client/assets/{spellCheck-CDZ9ZXo6.js → spellCheck-9fdyhJtV.js} +1 -1
  75. package/dist/client/index.html +2 -2
  76. package/dist/server/db.js +4 -0
  77. package/dist/server/i18n.js +4 -0
  78. package/dist/server/lucideIcons.js +46 -0
  79. package/dist/server/projectIcons.js +96 -0
  80. package/dist/server/routes/projectShared.js +1 -0
  81. package/dist/server/routes/projects.js +114 -7
  82. package/package.json +2 -1
  83. package/dist/client/assets/LatexEditor-XLwZxO6_.js +0 -59
  84. package/dist/client/assets/index-BAFR6u6_.js +0 -64
  85. package/dist/client/assets/index-g5CaToD6.css +0 -1
@@ -1 +1 @@
1
- import{j as e}from"./index-BAFR6u6_.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-BAFR6u6_.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-BAFR6u6_.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-g5CaToD6.css">
15
+ <link rel="stylesheet" crossorigin href="/assets/index-DzzqNm9b.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
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"],
@@ -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,6 +92,7 @@ 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.",
@@ -193,6 +195,7 @@ const zhMessages = {
193
195
  PROJECT_CREATE_FORBIDDEN: "管理员尚未授予你创建项目的权限。",
194
196
  PROJECT_DELETE_FORBIDDEN: "只有项目所有者可以删除项目。",
195
197
  PROJECT_EDIT_FORBIDDEN: "你没有此项目的编辑权限。",
198
+ PROJECT_ICON_INVALID: "请输入已安装的有效 Lucide 图标短名称。",
196
199
  PROJECT_NOT_FOUND: "项目不存在或你已无法访问。",
197
200
  PROJECT_OWNER_ONLY: "只有项目所有者可以执行此操作。",
198
201
  PROJECT_TRANSFER_FORBIDDEN: "只有当前项目所有者可以转让项目。",
@@ -215,6 +218,7 @@ const zhMessages = {
215
218
  SYMLINK_FORBIDDEN: "项目源码中不允许使用符号链接。",
216
219
  SYNC_PDF_INVALID: "PDF 位置无效。",
217
220
  SYNC_SOURCE_INVALID: "源码位置无效。",
221
+ TAG_NAME_EXISTS: "你已经有同名标签。",
218
222
  TAG_NOT_FOUND: "所选标签不存在。",
219
223
  UPLOAD_EMPTY: "没有选择要上传的文件。",
220
224
  USERNAME_INVALID: "用户名只能包含字母、数字、点、横线或下划线。",
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "texlite",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "A lightweight local-first collaborative LaTeX workspace",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -78,6 +78,7 @@
78
78
  "fastify": "^5.4.0",
79
79
  "i18next": "^25.3.2",
80
80
  "i18next-browser-languagedetector": "^8.2.0",
81
+ "katex": "^0.18.4",
81
82
  "lib0": "^0.2.117",
82
83
  "lucide-react": "^0.468.0",
83
84
  "pdfjs-dist": "^6.2.108",