From b2e1dbdaeef3ee9e52ed0e94490ee4feaa69854d Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 18 Jul 2026 21:28:15 +0200 Subject: [PATCH 1/5] chore(mcp): add the @zplab/mcp-server workspace package and tooling --- .gitattributes | 4 + eslint.config.js | 2 + package.json | 2 +- pnpm-lock.yaml | 1024 ++++++++++++++++++++++++++++++++- scripts/gen-gs1-catalog.mjs | 2 +- scripts/lib/pnpm-licenses.mjs | 9 +- vite.config.ts | 2 +- 7 files changed, 1019 insertions(+), 26 deletions(-) diff --git a/.gitattributes b/.gitattributes index 7e0fe886..6a62a8d5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,5 +6,9 @@ THIRD-PARTY-LICENSES-RUST.md text eol=lf scripts/*.mjs text eol=lf scripts/**/*.mjs text eol=lf +# Rust manifests: pin LF so a Windows checkout stops flagging Cargo.toml as +# perpetually modified (autocrlf phantom, empty content diff). +src-tauri/Cargo.toml text eol=lf + # Raw printer-capture fixtures: byte-exact, never eol-converted. *.prn binary diff --git a/eslint.config.js b/eslint.config.js index daa9ce26..bb858fe9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,8 @@ export default defineConfig([ '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-non-null-assertion': 'warn', '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], + // Omit-via-rest (`const { drop, ...keep } = obj`) is the intended pattern. + '@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }], }, }, { diff --git a/package.json b/package.json index ab905aed..bca4676b 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "dev": "vite", - "build": "tsc -b && tsc -p tsconfig.test.json --noEmit && vite build", + "build": "tsc -b && tsc -p tsconfig.test.json --noEmit && tsc -p packages/mcp-server/tsconfig.json --noEmit && vite build", "typecheck:test": "tsc -p tsconfig.test.json --noEmit", "test": "vitest run", "test:watch": "vitest", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64f6cd31..ee9ab821 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,10 +101,10 @@ importers: version: 1.0.2 '@rolldown/plugin-babel': specifier: ^0.2.3 - version: 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)) + version: 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) '@tailwindcss/vite': specifier: ^4.3.2 - version: 4.3.2(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)) + version: 4.3.2(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) '@tauri-apps/cli': specifier: ^2.11.4 version: 2.11.4 @@ -140,7 +140,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.3 - version: 6.0.3(@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)) + version: 6.0.3(@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) '@vitest/coverage-v8': specifier: ^4.1.10 version: 4.1.10(vitest@4.1.10) @@ -182,10 +182,10 @@ importers: version: 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) vite: specifier: ^8.1.4 - version: 8.1.4(@types/node@26.1.1)(jiti@2.7.0) + version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)) + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) packages/core: dependencies: @@ -199,6 +199,22 @@ importers: specifier: 'catalog:' version: 4.4.3 + packages/mcp-server: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) + '@zplab/core': + specifier: workspace:* + version: link:../core + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + tsx: + specifier: ^4.19.2 + version: 4.23.1 + packages: '@asamuzakjp/css-color@5.1.11': @@ -441,6 +457,162 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -528,6 +700,12 @@ packages: peerDependencies: react: '>= 16 || ^19.0.0-rc' + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -573,6 +751,16 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/canvas-android-arm64@1.0.2': resolution: {integrity: sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==} engines: {node: '>= 10'} @@ -1196,6 +1384,10 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1206,9 +1398,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1246,6 +1449,10 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + brace-expansion@5.0.7: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} @@ -1259,6 +1466,18 @@ packages: resolution: {integrity: sha512-gAjZvO0a8n6NmzbtqRY5gLrYO4vNWtbmUXfyWG/NMG8/qJE66ahbzA8J0MvBkWnYY69zx3qfJQq4q9qu3Bm3FQ==} hasBin: true + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} @@ -1270,9 +1489,33 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1303,6 +1546,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1314,6 +1561,13 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.375: resolution: {integrity: sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==} @@ -1321,6 +1575,10 @@ packages: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + enhanced-resolve@5.21.6: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} @@ -1329,13 +1587,33 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1396,10 +1674,32 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.6.0: + resolution: {integrity: sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1409,6 +1709,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.3: + resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1425,6 +1728,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1436,15 +1743,34 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1453,6 +1779,10 @@ packages: resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} engines: {node: '>=18'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1460,12 +1790,24 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hono@4.12.30: + resolution: {integrity: sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==} + engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1473,6 +1815,14 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1488,6 +1838,17 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1499,6 +1860,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1523,6 +1887,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1549,6 +1916,12 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1666,9 +2039,29 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1684,14 +2077,33 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + node-releases@2.0.47: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1710,6 +2122,10 @@ packages: parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1718,6 +2134,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1736,6 +2155,10 @@ packages: resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} hasBin: true + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -1752,10 +2175,26 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-aria@3.50.0: resolution: {integrity: sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==} peerDependencies: @@ -1801,6 +2240,13 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -1822,6 +2268,17 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1830,6 +2287,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1849,6 +2322,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -1891,6 +2368,10 @@ packages: resolution: {integrity: sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==} hasBin: true + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -1908,10 +2389,19 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript-eslint@8.64.0: resolution: {integrity: sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1931,6 +2421,10 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -1945,6 +2439,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite@8.1.4: resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2059,6 +2557,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -2073,6 +2574,11 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -2398,6 +2904,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.7.0(jiti@2.7.0))': dependencies: eslint: 10.7.0(jiti@2.7.0) @@ -2477,6 +3061,10 @@ snapshots: dependencies: react: 19.2.7 + '@hono/node-server@1.19.14(hono@4.12.30)': + dependencies: + hono: 4.12.30 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2524,6 +3112,28 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.30) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.6.0(express@5.2.1) + hono: 4.12.30 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@napi-rs/canvas-android-arm64@1.0.2': optional: true @@ -2648,14 +3258,14 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))': + '@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': dependencies: '@babel/core': 8.0.1 picomatch: 4.0.4 rolldown: 1.1.5 optionalDependencies: '@babel/runtime': 7.29.2 - vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) '@rolldown/pluginutils@1.0.1': {} @@ -2726,12 +3336,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 - '@tailwindcss/vite@4.3.2(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.2(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': dependencies: '@tailwindcss/node': 4.3.2 '@tailwindcss/oxide': 4.3.2 tailwindcss: 4.3.2 - vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) '@tanstack/react-virtual@3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: @@ -3003,12 +3613,12 @@ snapshots: '@typescript-eslint/types': 8.64.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.0.3(@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))': + '@vitejs/plugin-react@6.0.3(@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.2)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) babel-plugin-react-compiler: 1.0.0 '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': @@ -3023,7 +3633,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)) + vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) '@vitest/expect@4.1.10': dependencies: @@ -3034,13 +3644,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0))': + '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) '@vitest/pretty-format@4.1.10': dependencies: @@ -3066,12 +3676,21 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 acorn@8.17.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -3079,6 +3698,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-styles@5.2.0: {} @@ -3111,6 +3737,20 @@ snapshots: dependencies: require-from-string: 2.0.2 + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -3125,14 +3765,41 @@ snapshots: bwip-js@4.11.2: {} + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + caniuse-lite@1.0.30001799: {} chai@6.2.2: {} clsx@2.1.1: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3161,16 +3828,28 @@ snapshots: deep-is@0.1.4: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} dom-accessibility-api@0.5.16: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + electron-to-chromium@1.5.375: {} empathic@2.0.1: {} + encodeurl@2.0.0: {} + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 @@ -3178,10 +3857,49 @@ snapshots: entities@8.0.0: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.3.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-plugin-react-hooks@7.1.1(eslint@10.7.0(jiti@2.7.0)): @@ -3269,14 +3987,65 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + expect-type@1.4.0: {} + express-rate-limit@8.6.0(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.2.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fast-uri@3.1.3: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -3287,6 +4056,17 @@ snapshots: dependencies: flat-cache: 4.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -3299,27 +4079,61 @@ snapshots: flatted@3.4.2: {} + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 globals@17.7.0: {} + gopd@1.2.0: {} + graceful-fs@4.2.11: {} has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hermes-estree@0.25.1: {} hermes-parser@0.25.1: dependencies: hermes-estree: 0.25.1 + hono@4.12.30: {} + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.1 @@ -3328,6 +4142,18 @@ snapshots: html-escaper@2.0.2: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.6: {} @@ -3336,6 +4162,12 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -3344,6 +4176,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -3368,6 +4202,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -3404,6 +4240,10 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -3494,8 +4334,20 @@ snapshots: dependencies: semver: 7.8.5 + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 @@ -3506,10 +4358,24 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + node-releases@2.0.47: {} + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + obug@2.1.3: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3533,10 +4399,14 @@ snapshots: dependencies: entities: 8.0.0 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-key@3.1.1: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -3549,6 +4419,8 @@ snapshots: dependencies: pngjs: 7.0.0 + pkce-challenge@5.0.1: {} + pngjs@7.0.0: {} postcss@8.5.19: @@ -3565,8 +4437,27 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + punycode@2.3.1: {} + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + react-aria@3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@internationalized/date': 3.12.2 @@ -3640,6 +4531,18 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.5 '@rolldown/binding-win32-x64-msvc': 1.1.5 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -3652,12 +4555,67 @@ snapshots: semver@7.8.5: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -3673,6 +4631,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} supports-color@7.2.0: @@ -3704,6 +4664,8 @@ snapshots: dependencies: tldts-core: 7.4.3 + toidentifier@1.0.1: {} + tough-cookie@6.0.1: dependencies: tldts: 7.4.3 @@ -3718,10 +4680,22 @@ snapshots: tslib@2.8.1: {} + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript-eslint@8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/parser@8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) @@ -3739,6 +4713,8 @@ snapshots: undici@7.28.0: {} + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -3753,7 +4729,9 @@ snapshots: dependencies: react: 19.2.7 - vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0): + vary@1.1.2: {} + + vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -3762,13 +4740,15 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.1.1 + esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 + tsx: 4.23.1 - vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)): + vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@26.1.1)(jiti@2.7.0)) + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -3785,7 +4765,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.4(@types/node@26.1.1)(jiti@2.7.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.1 @@ -3821,6 +4801,8 @@ snapshots: word-wrap@1.2.5: {} + wrappy@1.0.2: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} @@ -3829,6 +4811,10 @@ snapshots: yocto-queue@0.1.0: {} + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod-validation-error@4.0.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/scripts/gen-gs1-catalog.mjs b/scripts/gen-gs1-catalog.mjs index 29c8df3d..04140f3b 100644 --- a/scripts/gen-gs1-catalog.mjs +++ b/scripts/gen-gs1-catalog.mjs @@ -106,7 +106,7 @@ const toLine = (e) => { const header = `// GENERATED from the GS1 Barcode Syntax Dictionary (GS1 AISBL, Apache-2.0, // derived from BWIPP + Zint) via scripts/gen-gs1-catalog.mjs. Do not hand-edit. // Data only; the entry types live in gs1AiCatalog.types.ts. Consumed by -// src/lib/gs1.ts (AI_BY_CODE), which expands ranges and skips multiComponent +// gs1.ts (AI_BY_CODE), which expands ranges and skips multiComponent // AIs (their extra fields are not modeled yet). import type { Gs1AiCatalogEntry } from './gs1AiCatalog.types'; diff --git a/scripts/lib/pnpm-licenses.mjs b/scripts/lib/pnpm-licenses.mjs index af8f2a8c..384a767a 100644 --- a/scripts/lib/pnpm-licenses.mjs +++ b/scripts/lib/pnpm-licenses.mjs @@ -1,7 +1,8 @@ // Single source for reading the production dependency license tree. Both the -// license gate and the attribution generator depend on the exact same scope -// (--prod = what ships in dist/), so the invocation lives here: changing the -// flags in one place keeps the gate and the attribution file in agreement. +// license gate and the attribution generator depend on the exact same scope: +// the shipped app only (--filter zebra-print-lab). Since the workspace split, +// an unscoped read would also pull packages/mcp-server's tree, which runs as a +// separate process and never lands in dist/. import { execSync } from 'node:child_process'; // Returns pnpm's grouped output: { [spdxId]: Array<{name, versions, paths, @@ -12,7 +13,7 @@ export function readProdLicenses() { try { // maxBuffer well above the current ~20 KB so a growing tree can't hit the // 1 MB default and turn a license read into an ENOBUFS crash. - raw = execSync('pnpm licenses list --prod --json', { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + raw = execSync('pnpm licenses list --prod --json --filter zebra-print-lab', { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); } catch (e) { console.error('Could not read `pnpm licenses list --prod --json`:', e.message); process.exit(2); diff --git a/vite.config.ts b/vite.config.ts index cb1febbc..f08c7b1c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -59,7 +59,7 @@ export default defineConfig({ ], test: { environment: 'node', - include: ['src/**/*.test.{ts,tsx}'], + include: ['src/**/*.test.{ts,tsx}', 'packages/*/src/**/*.test.{ts,tsx}'], setupFiles: ['src/test/setup.ts'], }, }) From b0626508f18bb76a614f9c521681dc20716414ff Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 18 Jul 2026 21:28:15 +0200 Subject: [PATCH 2/5] feat(core): headless import-service and geometry primitives for MCP --- packages/core/package.json | 4 + packages/core/src/lib/importReport.ts | 51 +++++++++++ packages/core/src/lib/localStorageBucket.ts | 6 +- packages/core/src/lib/objectBounds.ts | 11 +++ packages/core/src/lib/objectOverlap.ts | 76 ++++++++++++++++ {src => packages/core/src}/lib/pageOverlay.ts | 2 +- packages/core/src/lib/preflight.ts | 6 ++ .../core/src}/lib/pruneUndefined.ts | 0 .../core/src}/lib/zplImportService.ts | 38 ++++++-- packages/core/src/lib/zplParser.ts | 5 +- packages/core/src/lib/zplParser/context.ts | 9 +- .../src/lib/zplParser/handlers/graphics.ts | 4 +- packages/core/src/lib/zplParser/types.ts | 3 +- packages/core/src/types/preflight.ts | 6 +- src/lib/coreHeadless.test.ts | 17 ++++ src/lib/objectOverlap.test.ts | 87 +++++++++++++++++++ src/lib/preflight.test.ts | 20 ++++- src/lib/zplImportService.test.ts | 18 +++- src/lib/zplParser.test.ts | 29 ++++++- src/registry/registry-isolation.test.ts | 13 ++- 20 files changed, 378 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/lib/importReport.ts create mode 100644 packages/core/src/lib/objectOverlap.ts rename {src => packages/core/src}/lib/pageOverlay.ts (91%) rename {src => packages/core/src}/lib/pruneUndefined.ts (100%) rename {src => packages/core/src}/lib/zplImportService.ts (92%) create mode 100644 src/lib/objectOverlap.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 8a88358e..85de9a8c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,6 +4,10 @@ "version": "0.0.0", "type": "module", "exports": { + "./registry": { + "types": "./src/registry/index.ts", + "default": "./src/registry/index.ts" + }, "./*": { "types": "./src/*.ts", "default": "./src/*.ts" diff --git a/packages/core/src/lib/importReport.ts b/packages/core/src/lib/importReport.ts new file mode 100644 index 00000000..8be46afa --- /dev/null +++ b/packages/core/src/lib/importReport.ts @@ -0,0 +1,51 @@ +import type { ImportFinding, ImportFindingKind, ImportReport } from './zplParser'; + +// Re-export the parser-side domain types so UI code talks to the report +// surface through this module exclusively instead of reaching into the +// parser for type imports. +export type { ImportFinding, ImportFindingKind, ImportReport }; + +/** Distinct command codes of one finding kind; backs the report's bucket views. */ +export function dedupCommandsByKind( + findings: readonly ImportFinding[], + kind: ImportFindingKind, +): string[] { + return [...new Set(findings.filter((f) => f.kind === kind).map((f) => f.command))]; +} + +/** Routable setup-script findings; drives the import routing prompt. */ +export function replayRiskFindings(report: ImportReport): ImportFinding[] { + return report.findings.filter((f) => f.kind === 'replayRisk'); +} + +/** Routable setup commands plus non-routable device actions, for the prompt list. */ +export function printerCommandFindings(report: ImportReport): ImportFinding[] { + return report.findings.filter((f) => f.kind === 'replayRisk' || f.kind === 'deviceAction'); +} + +/** Report after routing setup commands out of the label: replayRisk resolved, + * overlay-dependent findings on routed pages moot, findings on dropped pages + * removed and survivors remapped to `keptPageIndexes` order. */ +export function resolveRoutedReport( + report: ImportReport, + keptPageIndexes: readonly number[], +): ImportReport { + const riskPages = new Set(replayRiskFindings(report).map((f) => f.pageIndex)); + const newIndexOf = new Map(keptPageIndexes.map((orig, i) => [orig, i])); + const findings = report.findings.flatMap((f) => { + if (f.kind === 'replayRisk') return []; + if ((f.kind === 'lossyEdit' || f.kind === 'deviceAction') && riskPages.has(f.pageIndex)) { + return []; + } + const newIndex = newIndexOf.get(f.pageIndex); + return newIndex === undefined ? [] : [{ ...f, pageIndex: newIndex }]; + }); + return { + findings, + partial: dedupCommandsByKind(findings, 'partial'), + browserLimit: dedupCommandsByKind(findings, 'browserLimit'), + unknown: dedupCommandsByKind(findings, 'unknown'), + replayRisk: dedupCommandsByKind(findings, 'replayRisk'), + deviceAction: dedupCommandsByKind(findings, 'deviceAction'), + }; +} diff --git a/packages/core/src/lib/localStorageBucket.ts b/packages/core/src/lib/localStorageBucket.ts index 364c35e6..06657010 100644 --- a/packages/core/src/lib/localStorageBucket.ts +++ b/packages/core/src/lib/localStorageBucket.ts @@ -47,5 +47,9 @@ export function safeLocalStorageSet(key: string, value: string): void { export function safeLocalStorageRemove(key: string): void { if (!hasLocalStorage()) return; - localStorage.removeItem(key); + try { + localStorage.removeItem(key); + } catch { + // extension/policy interception can make removeItem throw; module must not + } } diff --git a/packages/core/src/lib/objectBounds.ts b/packages/core/src/lib/objectBounds.ts index 397f1697..7894e351 100644 --- a/packages/core/src/lib/objectBounds.ts +++ b/packages/core/src/lib/objectBounds.ts @@ -175,6 +175,17 @@ export function isBarcode(obj: { type: string }): boolean { return BARCODE_TYPES.has(obj.type); } +/** True when objectBoundsDots estimates this leaf headlessly (no measured + * footprint): barcode registry footprint, single-line-text font estimate, + * image-without-heightDots square guess. Everything else is exact. */ +export function boundsAreApprox(obj: LabelObject): boolean { + if (isBarcode(obj)) return true; + if (obj.type === "image") return obj.props.heightDots === undefined; + if (obj.type !== "text") return false; + const p = obj.props; + return !(resolveTextMode(p) !== "normal" && !!p.blockWidth && p.blockWidth > 0); +} + /** Axis-aligned model-space bbox (dots) for one object. Always the VISUAL * top-left regardless of FO/FT, so align/distribute can use min/max edges. */ export function objectBoundsDots(obj: LabelObject, ctx: ObjectBoundsCtx): BoundingBoxDots { diff --git a/packages/core/src/lib/objectOverlap.ts b/packages/core/src/lib/objectOverlap.ts new file mode 100644 index 00000000..5a436a31 --- /dev/null +++ b/packages/core/src/lib/objectOverlap.ts @@ -0,0 +1,76 @@ +// Axis-aligned bbox intersections between leaf objects, as neutral geometric +// facts: intent is not in the model (text on a reverse box overlaps on +// purpose), so callers judge. Headless barcode bboxes are estimates (`approx`). + +import type { LeafObject } from "../registry"; +import { + boundsAreApprox, + objectBoundsDots, + type BoundingBoxDots, + type ObjectBoundsCtx, +} from "./objectBounds"; + +/** One leaf's bbox plus the approx flag; the shared input for bounds + * reporting and overlap detection so both derive from the same boxes. */ +export interface LeafBoxDots { + id: string; + box: BoundingBoxDots; + /** Bbox is a headless estimate (barcode footprint or single-line text), not + * render-exact. See boundsAreApprox. */ + approx: boolean; +} + +export function leafBoxesDots( + leaves: readonly LeafObject[], + ctx: ObjectBoundsCtx, +): LeafBoxDots[] { + return leaves.map((l) => ({ + id: l.id, + box: objectBoundsDots(l, ctx), + approx: boundsAreApprox(l), + })); +} + +export interface OverlapDots { + a: string; + b: string; + /** Axis-aligned intersection rect in dots. */ + x: number; + y: number; + width: number; + height: number; + /** Either bbox is approximate, so the rect isn't render-exact. */ + approx: boolean; +} + +function intersect(a: BoundingBoxDots, b: BoundingBoxDots): BoundingBoxDots | null { + const x = Math.max(a.x, b.x); + const y = Math.max(a.y, b.y); + const width = Math.min(a.x + a.width, b.x + b.width) - x; + const height = Math.min(a.y + a.height, b.y + b.height) - y; + return width > 0 && height > 0 ? { x, y, width, height } : null; +} + +/** Overlap-count cap. The pairwise scan is O(n²); an unbounded result also + * bloats the agent payload. Past this the layout is degenerate anyway, so + * stop and let the caller flag truncation. */ +export const MAX_OVERLAPS = 500; + +/** Index loop (no per-row slice allocation) with an early exit at `cap`. */ +export function computeOverlaps( + boxes: readonly LeafBoxDots[], + cap: number = MAX_OVERLAPS, +): OverlapDots[] { + const out: OverlapDots[] = []; + for (let i = 0; i < boxes.length && out.length < cap; i++) { + const bi = boxes[i]; + if (!bi) continue; + for (let j = i + 1; j < boxes.length && out.length < cap; j++) { + const bj = boxes[j]; + if (!bj) continue; + const rect = intersect(bi.box, bj.box); + if (rect) out.push({ a: bi.id, b: bj.id, ...rect, approx: bi.approx || bj.approx }); + } + } + return out; +} diff --git a/src/lib/pageOverlay.ts b/packages/core/src/lib/pageOverlay.ts similarity index 91% rename from src/lib/pageOverlay.ts rename to packages/core/src/lib/pageOverlay.ts index 382308d8..2b19ccbf 100644 --- a/src/lib/pageOverlay.ts +++ b/packages/core/src/lib/pageOverlay.ts @@ -1,4 +1,4 @@ -import type { Page } from "@zplab/core/types/Group"; +import type { Page } from "../types/Group"; /** Strip overlays (all pages, or those matching `shouldDrop`) so they * regenerate from the model instead of replaying stale bytes. diff --git a/packages/core/src/lib/preflight.ts b/packages/core/src/lib/preflight.ts index b590efcf..c8b0d5cb 100644 --- a/packages/core/src/lib/preflight.ts +++ b/packages/core/src/lib/preflight.ts @@ -225,6 +225,12 @@ export function computePreflight( ): PreflightFinding[] { const findings: PreflightFinding[] = []; for (const leaf of leaves) { + // An unregistered type has no emitter/bounds producer, so it prints + // nothing; flag it and skip the checks that dereference the entry. + if (getEntry(leaf.type) === undefined) { + findings.push({ objectId: leaf.id, kind: "unknownType", severity: PREFLIGHT_SEVERITY.unknownType }); + continue; + } const content = getObjectStringContent(leaf); const box = objectBoundsDots(leaf, ctx); // A blank text field draws a placeholder (its bounds) but emits an empty diff --git a/src/lib/pruneUndefined.ts b/packages/core/src/lib/pruneUndefined.ts similarity index 100% rename from src/lib/pruneUndefined.ts rename to packages/core/src/lib/pruneUndefined.ts diff --git a/src/lib/zplImportService.ts b/packages/core/src/lib/zplImportService.ts similarity index 92% rename from src/lib/zplImportService.ts rename to packages/core/src/lib/zplImportService.ts index e1c4f21b..9ac3b57f 100644 --- a/src/lib/zplImportService.ts +++ b/packages/core/src/lib/zplImportService.ts @@ -1,13 +1,13 @@ -import { parseZPL, type ImportFinding, type ImportReport } from "@zplab/core/lib/zplParser"; +import { parseZPL, type ImportFinding, type ImportReport } from "./zplParser"; import { replayRiskFindings, dedupCommandsByKind } from "./importReport"; import { dropPageOverlays } from "./pageOverlay"; import { pruneUndefined } from "./pruneUndefined"; -import { stripDrivePrefix } from "@zplab/core/lib/customFonts"; -import { renameTemplateMarkers } from "@zplab/core/lib/fnTemplate"; -import type { CustomFontMapping, LabelConfig } from "@zplab/core/types/LabelConfig"; -import type { PrinterProfile } from "@zplab/core/types/PrinterProfile"; -import type { LabelObject, Page } from "@zplab/core/types/Group"; -import { nextFreeFnNumber, uniqueVariableName, type Variable } from "@zplab/core/types/Variable"; +import { stripDrivePrefix } from "./customFonts"; +import { renameTemplateMarkers } from "./fnTemplate"; +import type { CustomFontMapping, LabelConfig } from "../types/LabelConfig"; +import type { PrinterProfile } from "../types/PrinterProfile"; +import type { LabelObject, Page } from "../types/Group"; +import { nextFreeFnNumber, uniqueVariableName, type Variable } from "../types/Variable"; export interface ZplImportResult { labelConfig: Partial; @@ -20,6 +20,10 @@ export interface ZplImportResult { pages: Page[]; variables: Variable[]; report: ImportReport; + /** True when ^XA blocks set different ^PW/^LL: the single-label design keeps + * only block 0's size, so later pages would render/preflight at the wrong + * size. Interactive import ignores it; the MCP tools reject on it. */ + mixedPageGeometry: boolean; } interface SplitBlocks { @@ -60,6 +64,7 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { pages: [], variables: [], report: { findings: [], partial: [], browserLimit: [], unknown: [], replayRisk: [], deviceAction: [] }, + mixedPageGeometry: false, }; } @@ -230,7 +235,24 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { deviceAction: dedupCommandsByKind(findings, 'deviceAction'), }; - return { labelConfig, printerProfile, pages, variables, report }; + // ^PW/^LL persist across ^XA on a printer, so only an explicit re-statement + // to a different size is a real divergence. + const explicitSizes = new Set( + parsedBlocks + .map((r) => r.labelConfig) + .filter((lc) => lc.widthMm !== undefined || lc.heightMm !== undefined) + .map((lc) => `${lc.widthMm ?? ''}x${lc.heightMm ?? ''}`), + ); + const mixedPageGeometry = explicitSizes.size > 1; + if (mixedPageGeometry) { + report.findings.push({ + kind: 'mixedPageGeometry', + command: [...explicitSizes].join(', '), + pageIndex: 0, + }); + } + + return { labelConfig, printerProfile, pages, variables, report, mixedPageGeometry }; } /** Additive setup-font merge (dedupe by normalized path): a stream lists only diff --git a/packages/core/src/lib/zplParser.ts b/packages/core/src/lib/zplParser.ts index e0c7d849..b2be0e06 100644 --- a/packages/core/src/lib/zplParser.ts +++ b/packages/core/src/lib/zplParser.ts @@ -254,7 +254,10 @@ export function parseZPL( } if (rest.trim() || cmd.trim()) { - const token = `^${cmd}${rest}`; + // Source prefix from `start`: tilde commands must not surface as `^`. + // trimEnd: `rest` runs to the next command, dragging the line break in + // multi-line ZPL into the surfaced token. + const token = `${zpl[start] ?? "^"}${cmd}${rest}`.trimEnd(); skipped.push(token); unknown.push(token); } diff --git a/packages/core/src/lib/zplParser/context.ts b/packages/core/src/lib/zplParser/context.ts index 67c08852..a543bfb8 100644 --- a/packages/core/src/lib/zplParser/context.ts +++ b/packages/core/src/lib/zplParser/context.ts @@ -219,10 +219,13 @@ export interface ParserState { serialStrippedFns: Set; } -/** Append to `skipped` and `browserLimit` (invariant: browserLimit ⊆ skipped). */ +/** Append to `skipped` and `browserLimit` (invariant: browserLimit ⊆ skipped). + * trimEnd: `token` carries `rest` up to the next command, which in multi-line + * ZPL includes the trailing newline (noise in this diagnostic surface). */ export function pushBrowserLimit(result: ParserResult, token: string): void { - result.skipped.push(token); - result.browserLimit.push(token); + const clean = token.trimEnd(); + result.skipped.push(clean); + result.browserLimit.push(clean); } /** Default text height: ^CF override, else ZPL baseline 30. */ diff --git a/packages/core/src/lib/zplParser/handlers/graphics.ts b/packages/core/src/lib/zplParser/handlers/graphics.ts index 97e6989a..13fac9c1 100644 --- a/packages/core/src/lib/zplParser/handlers/graphics.ts +++ b/packages/core/src/lib/zplParser/handlers/graphics.ts @@ -435,7 +435,9 @@ export function createGraphicsHandlers( const size = parseInt(rest.slice(c2 + 1, c3), 10); const dyBytesPerRow = parseInt(rest.slice(c3 + 1, c4), 10); const data = rest.slice(c4 + 1); - const dySummary = `~DY${rest.slice(0, IMPORT_FINDING_PAYLOAD_LIMIT)}…`; + // trimEnd before slicing: a short rest ends with the line break, which + // would land mid-token where pushBrowserLimit's trim cannot reach. + const dySummary = `~DY${rest.trimEnd().slice(0, IMPORT_FINDING_PAYLOAD_LIMIT)}…`; // Graphic uploads (~DY ...,A/B/C,G,...): decode via the same payload // pipeline as ^GF, register the resulting image under the full diff --git a/packages/core/src/lib/zplParser/types.ts b/packages/core/src/lib/zplParser/types.ts index 254322d0..13058f9a 100644 --- a/packages/core/src/lib/zplParser/types.ts +++ b/packages/core/src/lib/zplParser/types.ts @@ -12,7 +12,8 @@ export type ImportFindingKind = | "deviceAction" | "lossyEdit" | "fnRenumbered" - | "fnDefaultDropped"; + | "fnDefaultDropped" + | "mixedPageGeometry"; /** * One import finding. Created per-occurrence so each entry can be navigated diff --git a/packages/core/src/types/preflight.ts b/packages/core/src/types/preflight.ts index 4887c5e0..9db297a7 100644 --- a/packages/core/src/types/preflight.ts +++ b/packages/core/src/types/preflight.ts @@ -20,7 +20,8 @@ export type PreflightKind = | 'emptyContent' | 'printerSupportLimited' | 'qrRotatedStatic' - | 'qrRotatedModel2'; + | 'qrRotatedModel2' + | 'unknownType'; export interface PreflightFinding { objectId: string; @@ -65,6 +66,9 @@ export const PREFLIGHT_SEVERITY: Record = { // The graphic encoder only produces Model 2 symbols, so a rotated Model 1 // QR prints as Model 2 (legacy scanners may reject it). qrRotatedModel2: 'warning', + // A leaf whose type isn't in the registry: it has no emitter, so it prints + // nothing. Only reachable via an imported/foreign design file. + unknownType: 'error', }; /** What a per-type `preflight` producer receives. Minimal on purpose (the diff --git a/src/lib/coreHeadless.test.ts b/src/lib/coreHeadless.test.ts index 1a899227..0a069637 100644 --- a/src/lib/coreHeadless.test.ts +++ b/src/lib/coreHeadless.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { generateZPL } from "@zplab/core/lib/zplGenerator"; import { parseZPL } from "@zplab/core/lib/zplParser"; +import { importZplText } from "@zplab/core/lib/zplImportService"; import { rasterizeMono } from "@zplab/core/lib/imageToZpl"; import { decodeGraphicToImage } from "@zplab/core/lib/zplParser/decoders/graphic"; import { measureInkWidthPx } from "@zplab/core/lib/labelGeometry/measureTextDots"; @@ -30,10 +31,17 @@ describe("core closures without browser globals", () => { { id: "b", type: "code128", x: 10, y: 60, rotation: 0, props: { content: "12345", height: 80, moduleWidth: 2, printInterpretation: true, checkDigit: false, rotation: "N" } }, + // Exercises the image guard (gfaSync typeof Image): no decode, emits an + // anchor-only field instead of throwing. + { id: "i", type: "image", x: 10, y: 150, rotation: 0, + props: { imageId: "x", widthDots: 100, threshold: 128, rotation: "N" } }, ] as unknown as LabelObject[]; const zpl = generateZPL(LABEL, objs); expect(zpl).toContain("^FDHi"); expect(zpl).toContain("^BCN,80"); + // The image degraded (no bytes) but the whole label still generated. + expect(zpl.startsWith("^XA")).toBe(true); + expect(zpl.endsWith("^XZ")).toBe(true); }); it("parseZPL degrades an inline ^GFA to the browserLimit path", () => { @@ -44,6 +52,15 @@ describe("core closures without browser globals", () => { expect(result).toBeDefined(); }); + it("importZplText splits multi-^XA blocks (the MCP raw-ZPL entry)", () => { + headless(); + const { pages } = importZplText( + "^XA^FO50,50^A0N,30,30^FDA^FS^XZ^XA^FO50,50^A0N,30,30^FDB^FS^XZ", + 8, + ); + expect(pages).toHaveLength(2); + }); + it("raster/measure/storage helpers degrade instead of throwing", () => { headless(); expect(rasterizeMono({ naturalWidth: 10, naturalHeight: 10 } as HTMLImageElement, 80, 128)).toBeNull(); diff --git a/src/lib/objectOverlap.test.ts b/src/lib/objectOverlap.test.ts new file mode 100644 index 00000000..c1ec36e4 --- /dev/null +++ b/src/lib/objectOverlap.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { computeOverlaps, leafBoxesDots } from "@zplab/core/lib/objectOverlap"; +import type { ObjectBoundsCtx } from "@zplab/core/lib/objectBounds"; +import type { LabelConfig } from "@zplab/core/types/LabelConfig"; +import type { LeafObject } from "@zplab/core/registry"; + +const label: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8 }; +const ctx: ObjectBoundsCtx = { label }; + +const box = (id: string, x: number, y: number, width: number, height: number) => + ({ + id, type: "box", x, y, rotation: 0, + props: { width, height, thickness: 3, filled: false, color: "B", rounding: 0 }, + }) as LeafObject; + +const boxesOf = (...leaves: LeafObject[]) => leafBoxesDots(leaves, ctx); + +describe("computeOverlaps", () => { + it("reports the intersection rect for two overlapping boxes", () => { + const boxes = boxesOf(box("a", 0, 0, 100, 100), box("b", 60, 60, 100, 100)); + expect(computeOverlaps(boxes)).toEqual([ + { a: "a", b: "b", x: 60, y: 60, width: 40, height: 40, approx: false }, + ]); + }); + + it("returns nothing for disjoint or edge-touching boxes", () => { + const boxes = boxesOf( + box("a", 0, 0, 50, 50), + box("b", 200, 200, 50, 50), + // Shares the x=50 edge with a: zero-area, not an overlap. + box("c", 50, 0, 50, 50), + ); + expect(computeOverlaps(boxes)).toEqual([]); + }); + + it("flags approx when a barcode footprint is involved", () => { + const bc = { + id: "bc", type: "code128", x: 10, y: 10, rotation: 0, + props: { + content: "12345", height: 80, moduleWidth: 2, + printInterpretation: false, checkDigit: false, rotation: "N", + }, + } as LeafObject; + const boxes = boxesOf(box("frame", 0, 0, 400, 200), bc); + const [overlap] = computeOverlaps(boxes); + expect(overlap?.approx).toBe(true); + }); + + it("flags single-line text as approx (headless font estimate), block text exact", () => { + const single = { + id: "t", type: "text", x: 0, y: 0, rotation: 0, + props: { content: "Hi", fontHeight: 30, fontWidth: 0, rotation: "N" }, + } as LeafObject; + const block = { + id: "b", type: "text", x: 0, y: 0, rotation: 0, + props: { content: "Hi", fontHeight: 30, fontWidth: 0, rotation: "N", mode: "fb", blockWidth: 200 }, + } as LeafObject; + expect(leafBoxesDots([single], ctx)[0]?.approx).toBe(true); + expect(leafBoxesDots([block], ctx)[0]?.approx).toBe(false); + }); + + it("flags an image without heightDots as approx (square guess), with it exact", () => { + const guess = { + id: "i1", type: "image", x: 0, y: 0, rotation: 0, + props: { imageId: "a", widthDots: 90, threshold: 128 }, + } as LeafObject; + const exact = { + id: "i2", type: "image", x: 0, y: 0, rotation: 0, + props: { imageId: "a", widthDots: 90, heightDots: 40, threshold: 128 }, + } as LeafObject; + expect(leafBoxesDots([guess], ctx)[0]?.approx).toBe(true); + expect(leafBoxesDots([exact], ctx)[0]?.approx).toBe(false); + }); + + it("emits one entry per intersecting pair (n objects → pairs)", () => { + const boxes = boxesOf( + box("a", 0, 0, 100, 100), + box("b", 10, 10, 100, 100), + box("c", 20, 20, 100, 100), + ); + expect(computeOverlaps(boxes).map((o) => [o.a, o.b])).toEqual([ + ["a", "b"], + ["a", "c"], + ["b", "c"], + ]); + }); +}); diff --git a/src/lib/preflight.test.ts b/src/lib/preflight.test.ts index ff334e9d..a783de05 100644 --- a/src/lib/preflight.test.ts +++ b/src/lib/preflight.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import { computePreflight, markerValueFindings, suppressPristineEmpty, type PreflightFinding } from "@zplab/core/lib/preflight"; -import { getEntry } from "@zplab/core/registry/index"; +import { getEntry } from "@zplab/core/registry"; import type { ObjectBoundsCtx } from "@zplab/core/lib/objectBounds"; import type { LabelObject } from "@zplab/core/types/Group"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; import type { LabelConfig } from "@zplab/core/types/LabelConfig"; const label: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8 }; // printable 800 x 400 @@ -196,6 +196,22 @@ describe("emptyContent producer", () => { }); }); +describe("unknownType producer", () => { + it("flags a leaf whose type is not in the registry", () => { + const leaf = { + id: "u", + type: "not-a-real-type", + x: 10, + y: 10, + rotation: 0, + props: {}, + } as unknown as LeafObject; + expect(computePreflight([leaf], ctx, "mm")).toEqual([ + { objectId: "u", kind: "unknownType", severity: "error" }, + ]); + }); +}); + describe("suppressPristineEmpty", () => { const empty = (id: string): PreflightFinding => ({ objectId: id, kind: "emptyContent", severity: "warning" }); const outside = (id: string): PreflightFinding => ({ objectId: id, kind: "offLabelOutside", severity: "error" }); diff --git a/src/lib/zplImportService.test.ts b/src/lib/zplImportService.test.ts index 7d2f24c2..e0ac4528 100644 --- a/src/lib/zplImportService.test.ts +++ b/src/lib/zplImportService.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { importZplText, routeSetupCommands, mergeSetupFonts } from './zplImportService'; +import { importZplText, routeSetupCommands, mergeSetupFonts } from '@zplab/core/lib/zplImportService'; import { generateSetupScript } from './zplSetupScript'; import { generateMultiPageZPL } from '@zplab/core/lib/zplGenerator'; -import { describeFinding, replayRiskFindings, printerCommandFindings, resolveRoutedReport } from './importReport'; +import { describeFinding } from './importReport'; +import { replayRiskFindings, printerCommandFindings, resolveRoutedReport } from '@zplab/core/lib/importReport'; import type { PrinterProfile } from '@zplab/core/types/PrinterProfile'; import type { LabelConfig } from '@zplab/core/types/LabelConfig'; @@ -595,4 +596,17 @@ describe('routeSetupCommands - setup-only pages', () => { expect(pages).toHaveLength(1); expect(keptPageIndexes).toEqual([0]); }); + + it('flags divergent ^PW/^LL between blocks as mixedPageGeometry', () => { + const imported = importZplText('^XA^PW400^LL200^FO10,10^FDA^FS^XZ^XA^PW800^LL400^FO10,10^FDB^FS^XZ', 8); + expect(imported.mixedPageGeometry).toBe(true); + const finding = imported.report.findings.find((f) => f.kind === 'mixedPageGeometry'); + expect(finding).toBeDefined(); + expect(describeFinding(finding!).title).toMatch(/Multiple label sizes/); + }); + + it('does not flag a shared explicit size (^PW/^LL persist across ^XA)', () => { + const imported = importZplText('^XA^PW800^LL400^FO10,10^FDA^FS^XZ^XA^FO10,10^FDB^FS^XZ', 8); + expect(imported.mixedPageGeometry).toBe(false); + }); }); diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index 4bc60945..534eb108 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { zlibSync } from 'fflate'; import { parseZPL, BY_CONSUMING_BARCODE_TYPES } from '@zplab/core/lib/zplParser'; import { formatLabelMetaComment } from '@zplab/core/lib/zplLabelMeta'; -import { ObjectRegistry } from '@zplab/core/registry/index'; +import { ObjectRegistry } from '@zplab/core/registry'; import { props, serialOf } from '../test/helpers'; // Drift guard for the bare-^BY hazard set. Every 1D and postal barcode emits a @@ -2168,14 +2168,14 @@ describe('parseZPL — ^BT TLC39', () => { }); it('drops non-canonical r1 on round-trip (re-emits as 2)', async () => { - const { ObjectRegistry } = await import('@zplab/core/registry/index'); + const { ObjectRegistry } = await import('@zplab/core/registry'); const { objects } = parseZPL('^XA^FO0,0^BTN,2,3,40,4,4^FD123,X^FS^XZ', 8); const emitted = ObjectRegistry.tlc39.toZPL(objects[0] as never); expect(emitted).toContain('^BTN,2,2,40,4,4'); }); it('round-trips ^BT without serial (no MicroPDF block, no trailing comma)', async () => { - const { ObjectRegistry } = await import('@zplab/core/registry/index'); + const { ObjectRegistry } = await import('@zplab/core/registry'); const original = '^XA^FO10,20^BY2^BTN,2,2,40,4,4^FD123456^FS^XZ'; const { objects } = parseZPL(original, 8); expect(objects[0]?.type).toBe('tlc39'); @@ -2187,7 +2187,7 @@ describe('parseZPL — ^BT TLC39', () => { }); it('round-trips ^BT via parse → toZPL → parse', async () => { - const { ObjectRegistry } = await import('@zplab/core/registry/index'); + const { ObjectRegistry } = await import('@zplab/core/registry'); const original = '^XA^FO50,60^BY3^BTN,3,2,80,5,8^FD654321,ABCDEF^FS^XZ'; const { objects } = parseZPL(original, 8); expect(objects).toHaveLength(1); @@ -2345,6 +2345,27 @@ describe('parseZPL — importReport.unknown', () => { expect(importReport.unknown.some((s) => s.startsWith('^XX'))).toBe(true); }); + it('surfaces unknown/browserLimit tokens without a trailing newline', () => { + const { skipped, importReport } = parseZPL('^XA\n^XX99\n^IMR:LOGO.GRF\n^XZ', 8); + expect(importReport.unknown).toContain('^XX99'); + expect(importReport.browserLimit).toContain('^IMR:LOGO.GRF'); + for (const s of [...importReport.unknown, ...importReport.browserLimit, ...skipped]) { + expect(s).toBe(s.trimEnd()); + } + }); + + it('keeps the source prefix on unknown tilde commands', () => { + const { importReport } = parseZPL('^XA\n~QQ1,2\n^XZ', 8); + expect(importReport.unknown).toContain('~QQ1,2'); + }); + + it('does not bake a line break into a truncated ~DY summary token', () => { + // Short malformed ~DY: rest ended with \n before the appended ellipsis, + // where the push-site trim cannot reach. + const { importReport } = parseZPL('^XA\n~DYR:X,Q,G,10,2,ZZ\n^XZ', 8); + expect(importReport.browserLimit).toContain('~DYR:X,Q,G,10,2,ZZ…'); + }); + it('preserves an undecodable ^GFB format as an opaque verbatim image', () => { const { objects, importReport } = parseZPL('^XA^FO0,0^GFB,32,32,4,AABBCCDD^FS^XZ', 8); expect(objects).toHaveLength(1); diff --git a/src/registry/registry-isolation.test.ts b/src/registry/registry-isolation.test.ts index a042aaf5..e7ec3e42 100644 --- a/src/registry/registry-isolation.test.ts +++ b/src/registry/registry-isolation.test.ts @@ -9,7 +9,7 @@ vi.mock("@headlessui/react", () => { const Stub = () => null; return { Listbox: Stub, ListboxButton: Stub, ListboxOptions: Stub, ListboxOption: Stub }; }); -import { ObjectRegistry, BARCODE_1D_TYPES, STACKED_2D_TYPES } from "@zplab/core/registry/index"; +import { ObjectRegistry, BARCODE_1D_TYPES, STACKED_2D_TYPES } from "@zplab/core/registry"; import { ObjectPanels } from "./panels"; describe("registry isolation baseline", () => { @@ -91,7 +91,16 @@ describe("registry isolation baseline", () => { // React, store or UI) holds across everything they pull from lib/ too. it("emit/parse import closure stays free of react, store and UI imports", () => { const srcDir = fileURLToPath(new URL("../../packages/core/src/", import.meta.url)); - const entries = ["lib/zplGenerator.ts", "lib/zplParser.ts", "registry/index.ts"]; + // zplImportService + objectOverlap are the MCP server's extra entry points; + // they sit ABOVE the emit/parse closure (nothing in it imports them back), + // so they must be listed explicitly or the scan never reaches them. + const entries = [ + "lib/zplGenerator.ts", + "lib/zplParser.ts", + "lib/zplImportService.ts", + "lib/objectOverlap.ts", + "registry/index.ts", + ]; // A spec that names its extension resolves as written; anything else tries // ts/tsx and directory-index forms. Returning null is NOT a skip: the // caller flags it, so the walk can never silently shrink the closure. From 6c86f13080c5900631bea6221b72b2435db06bbd Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 18 Jul 2026 21:28:16 +0200 Subject: [PATCH 3/5] chore(imports): normalize @zplab/core import paths --- src/components/Barcode/ContentBuilderModal.tsx | 2 +- src/components/Canvas/BarcodeObject.tsx | 2 +- src/components/Canvas/KonvaObject.tsx | 2 +- src/components/Canvas/LabelCanvas.tsx | 6 +++--- src/components/Canvas/PreflightOverlay.tsx | 2 +- src/components/Canvas/barcodePreflight.test.ts | 2 +- src/components/Canvas/barcodePreflight.ts | 2 +- src/components/Canvas/bwipHelpers.test.ts | 2 +- src/components/Canvas/bwipHelpers.ts | 2 +- src/components/Canvas/hooks/useKonvaTransformer.ts | 4 ++-- src/components/Canvas/konvaObjectProps.ts | 2 +- src/components/Canvas/paletteGhostMonitor.test.ts | 2 +- src/components/Canvas/paletteGhostMonitor.ts | 4 ++-- src/components/Canvas/transformPosition.test.ts | 2 +- src/components/Canvas/transformPosition.ts | 2 +- src/components/Output/ImportSetupChoice.tsx | 4 ++-- src/components/Output/ZplImportModal.tsx | 5 +++-- src/components/Palette/ObjectPalette.tsx | 2 +- src/components/Palette/paletteGroups.ts | 2 +- src/components/Properties/ContentEditorButton.tsx | 2 +- src/components/Properties/LayerRow.tsx | 2 +- src/components/Properties/LayersPanel.tsx | 2 +- src/components/Properties/PropertiesPanel.tsx | 2 +- src/components/Properties/SerialModeSection.tsx | 2 +- src/components/Properties/SymbologySelect.tsx | 2 +- src/components/Properties/VariableBuilderModal.tsx | 2 +- src/lib/barcodeHri.ts | 2 +- src/lib/densityRescale.test.ts | 2 +- src/lib/densityRescale.ts | 2 +- src/lib/emittedAnchor.test.ts | 2 +- src/lib/historyFormat.ts | 2 +- src/lib/historyStep.test.ts | 2 +- src/lib/multiResize.test.ts | 2 +- src/lib/multiResize.ts | 4 ++-- src/lib/objectBounds.test.ts | 2 +- src/lib/positionConvert.test.ts | 2 +- src/lib/positionConvert.ts | 2 +- src/lib/printPreview.ts | 2 +- src/lib/spawn.test.ts | 2 +- src/lib/spawn.ts | 2 +- src/lib/symbologySwitch.ts | 2 +- src/lib/tidyClassify.ts | 2 +- src/lib/variableBinding.test.ts | 2 +- src/lib/zplOverlay/emitOverlayPage.test.ts | 2 +- src/lib/zplRoundtrip.integration.test.ts | 2 +- src/registry/barcode1d.panel.tsx | 2 +- src/registry/datamatrix.panel.test.ts | 2 +- src/registry/gs1databar.panel.test.ts | 2 +- src/registry/maxicode.test.ts | 2 +- src/registry/palettePresets.ts | 2 +- src/registry/placeholderContent.ts | 2 +- src/registry/registry.test.ts | 2 +- src/registry/serialField.test.ts | 2 +- src/registry/typedContentSection.tsx | 2 +- src/store/labelStore.internals.ts | 2 +- src/store/slices/labelConfigSlice.ts | 2 +- src/store/slices/objectSlice.ts | 2 +- src/store/slices/printerProfileSlice.ts | 2 +- src/store/slices/variablesSlice.ts | 2 +- src/test/labelarySync.test.ts | 2 +- 60 files changed, 68 insertions(+), 67 deletions(-) diff --git a/src/components/Barcode/ContentBuilderModal.tsx b/src/components/Barcode/ContentBuilderModal.tsx index 973c0df0..68e18ed2 100644 --- a/src/components/Barcode/ContentBuilderModal.tsx +++ b/src/components/Barcode/ContentBuilderModal.tsx @@ -8,7 +8,7 @@ import { usePreviewBinding } from "../../store/usePreviewBinding"; import { getObjectStringContent } from "@zplab/core/lib/variableBinding"; import { MarkerTextField } from "../Properties/MarkerTextField"; import { findObjectById } from "@zplab/core/types/Group"; -import { objectResolvesCtrl } from "@zplab/core/registry/index"; +import { objectResolvesCtrl } from "@zplab/core/registry"; import { encodeContent, parseContent, recommendedEc, isContentComplete, typedContentMarkerFindings, CONTENT_TYPES, type ContentType, type ContentFields } from "@zplab/core/lib/typedContent"; type FieldKind = "text" | "password" | "textarea" | "checkbox" | "auth"; diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index 85fdfa24..4ec5912a 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect } from "react"; import { Image as KImage, Group, Rect, Shape, Text } from "react-konva"; import type Konva from "konva"; -import { BARCODE_1D_TYPES, ObjectRegistry, objectResolvesCtrl } from "@zplab/core/registry/index"; +import { BARCODE_1D_TYPES, ObjectRegistry, objectResolvesCtrl } from "@zplab/core/registry"; import { dotsToPx, pxToDots } from "@zplab/core/lib/coordinates"; import { barcodeFtAnchorOffset, qrPrintsAsGraphic } from "@zplab/core/lib/objectBounds"; import { useColorScheme, CANVAS_WARNING } from "../../hooks/useColorScheme"; diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index fbaa7310..624b146c 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -23,7 +23,7 @@ import { GS_SYMBOL_PATHS, GS_VECTOR_CODES, type GsVectorCode } from "../../regis import { blockBoundsDots, blockJustifyWordPositions, blockLineStartDots, blockLineStepDots, EMPTY_TEXT_PLACEHOLDER_GLYPHS, isBlankText, tbBoundsDots, tbLineStepDots, wrapBlockLines, zebraAlignOffsetDots, zebraHangingIndentOffsetDots, zebraJustifyGapDots, zebraLineWidthDots, type ZplRotation } from "@zplab/core/lib/zebraTextLayout"; import { resolveTextMode } from "@zplab/core/registry/text"; import { isAxisSwapped } from "@zplab/core/registry/rotation"; -import { objectResolvesCtrl, type LeafObject } from "@zplab/core/registry/index"; +import { objectResolvesCtrl, type LeafObject } from "@zplab/core/registry"; import type { TextProps } from "@zplab/core/registry/text"; type Props = KonvaObjectProps; diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index ffeab9f5..1f779444 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -40,13 +40,13 @@ import { CAPTURE_CHROME } from "./konvaObjectProps"; import { Grid } from "./Grid"; import { GuideLines } from "./GuideLines"; import { Ruler, RULER_SIZE } from "./Ruler"; -import { SHAPE_PRIMITIVE_TYPES } from "@zplab/core/registry/index"; -import type { LeafObject } from "@zplab/core/registry/index"; +import { SHAPE_PRIMITIVE_TYPES } from "@zplab/core/registry"; +import type { LeafObject } from "@zplab/core/registry"; import { isImageRotatable, type ImageProps } from "@zplab/core/registry/image"; import { convertPositionType } from "../../lib/positionConvert"; import { addableGroupsFor, symbologyGroupsFor } from "../Palette/paletteGroups"; import { symbologyTargets, convertSymbologyMapper } from "../../lib/symbologySwitch"; -import type { LeafType } from "@zplab/core/registry/index"; +import type { LeafType } from "@zplab/core/registry"; import { resolveAddable, type AddableEntry } from "../../registry/palettePresets"; import { useColorScheme } from "../../hooks/useColorScheme"; import { useT } from "../../hooks/useT"; diff --git a/src/components/Canvas/PreflightOverlay.tsx b/src/components/Canvas/PreflightOverlay.tsx index 15d133c7..17929380 100644 --- a/src/components/Canvas/PreflightOverlay.tsx +++ b/src/components/Canvas/PreflightOverlay.tsx @@ -1,7 +1,7 @@ import { useRef, useState } from "react"; import { ExclamationTriangleIcon, ExclamationCircleIcon } from "@heroicons/react/16/solid"; import { typeLabelFor } from "../../registry/palettePresets"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; import { useT } from "../../hooks/useT"; import { useDismiss } from "../../hooks/useDismiss"; import { Tooltip } from "../ui/Tooltip"; diff --git a/src/components/Canvas/barcodePreflight.test.ts b/src/components/Canvas/barcodePreflight.test.ts index 5f466e82..40bcf033 100644 --- a/src/components/Canvas/barcodePreflight.test.ts +++ b/src/components/Canvas/barcodePreflight.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { barcodeEncodeFindings, resolveForEncode, type EncodeEnv } from "./barcodePreflight"; import type { LabelObject } from "@zplab/core/types/Group"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; const bar = (id: string, content = "X"): LeafObject => ({ id, type: "code128", x: 0, y: 0, rotation: 0, diff --git a/src/components/Canvas/barcodePreflight.ts b/src/components/Canvas/barcodePreflight.ts index ea67dd54..08b56a88 100644 --- a/src/components/Canvas/barcodePreflight.ts +++ b/src/components/Canvas/barcodePreflight.ts @@ -1,4 +1,4 @@ -import { objectResolvesCtrl, type LeafObject } from "@zplab/core/registry/index"; +import { objectResolvesCtrl, type LeafObject } from "@zplab/core/registry"; import { isBarcode } from "@zplab/core/lib/objectBounds"; import { PREFLIGHT_SEVERITY, type PreflightFinding } from "@zplab/core/lib/preflight"; import type { Variable } from "@zplab/core/types/Variable"; diff --git a/src/components/Canvas/bwipHelpers.test.ts b/src/components/Canvas/bwipHelpers.test.ts index f2cf1a43..8cd27235 100644 --- a/src/components/Canvas/bwipHelpers.test.ts +++ b/src/components/Canvas/bwipHelpers.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { buildBwipOptions, dataMatrixMinFitIndex, getDisplaySize, getEanUpcHriFragments, parseZplCode128Escapes } from "./bwipHelpers"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; import { dmSizePairs, type DataMatrixProps } from "@zplab/core/registry/datamatrix"; import { placeholderContentFor, samplePropsFor } from "../../registry/placeholderContent"; import { ZD230_QA_123 } from "../../test/qrFixtures"; diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index 86321ef6..d2e3fa70 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -3,7 +3,7 @@ import bwipjs from "bwip-js/browser"; import { errorMessage } from "../../lib/errorMessage"; -import { getEntry, type LeafObject } from "@zplab/core/registry/index"; +import { getEntry, type LeafObject } from "@zplab/core/registry"; import { clampCodablockColumns, CODABLOCK_PREVIEW_COLUMNS_MIN } from "@zplab/core/registry/codablock"; import { barcodeTextZoneDots, barcodeZoneAbove } from "../../lib/barcodeHri"; import { upceData6FromFd } from "@zplab/core/registry/hriFormatters"; diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index eedfb837..7d1243e7 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -9,8 +9,8 @@ import { BARCODE_1D_TYPES, STACKED_2D_TYPES, getEntry, -} from "@zplab/core/registry/index"; -import type { LeafObject } from "@zplab/core/registry/index"; +} from "@zplab/core/registry"; +import type { LeafObject } from "@zplab/core/registry"; import { resolveBlockResizeMode } from "@zplab/core/registry/transformHelpers"; import { resolveTextMode, type TextProps } from "@zplab/core/registry/text"; import type { ObjectChanges } from "../../../store/labelStore"; diff --git a/src/components/Canvas/konvaObjectProps.ts b/src/components/Canvas/konvaObjectProps.ts index b3ee29a2..ad978c52 100644 --- a/src/components/Canvas/konvaObjectProps.ts +++ b/src/components/Canvas/konvaObjectProps.ts @@ -1,6 +1,6 @@ import type Konva from "konva"; import type { RefObject } from "react"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; import { useLabelStore, type ObjectChanges } from "../../store/labelStore"; import type { SnapGuide, SnapRect } from "../../lib/snapGuides"; import { PALETTE_GHOST_ID } from "./paletteGhostMonitor"; diff --git a/src/components/Canvas/paletteGhostMonitor.test.ts b/src/components/Canvas/paletteGhostMonitor.test.ts index 4366e365..3bf84baa 100644 --- a/src/components/Canvas/paletteGhostMonitor.test.ts +++ b/src/components/Canvas/paletteGhostMonitor.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import type { DragEndEvent, DragMoveEvent } from "@dnd-kit/core"; import { paletteGhostHandlers, PALETTE_GHOST_ID, type PaletteGhostDeps } from "./paletteGhostMonitor"; import { CANVAS_DROPPABLE_ID } from "../../dnd/types"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; import { centeredSpawnAnchor } from "../../lib/spawn"; const CANVAS = CANVAS_DROPPABLE_ID; diff --git a/src/components/Canvas/paletteGhostMonitor.ts b/src/components/Canvas/paletteGhostMonitor.ts index b4b6cb93..f3aa020a 100644 --- a/src/components/Canvas/paletteGhostMonitor.ts +++ b/src/components/Canvas/paletteGhostMonitor.ts @@ -1,7 +1,7 @@ import type { DragEndEvent, DragMoveEvent } from "@dnd-kit/core"; import { CANVAS_DROPPABLE_ID, type PaletteDragData } from "../../dnd/types"; -import { getEntry } from "@zplab/core/registry/index"; -import type { LeafObject } from "@zplab/core/registry/index"; +import { getEntry } from "@zplab/core/registry"; +import type { LeafObject } from "@zplab/core/registry"; import { centeredSpawnAnchor, spawnRotationOverride } from "../../lib/spawn"; import type { ObjectBoundsCtx } from "@zplab/core/lib/objectBounds"; import type { LabelConfig } from "@zplab/core/types/LabelConfig"; diff --git a/src/components/Canvas/transformPosition.test.ts b/src/components/Canvas/transformPosition.test.ts index fb190517..315b43e5 100644 --- a/src/components/Canvas/transformPosition.test.ts +++ b/src/components/Canvas/transformPosition.test.ts @@ -3,7 +3,7 @@ import { committedUprightBarDots, modelPositionFromRenderedTopLeft, renderedTopL import { setMeasuredBounds, clearMeasuredBounds } from "./measuredBoundsCache"; import { QR_FO_Y_OFFSET_DOTS, QR_FT_MODULE_OFFSET } from "@zplab/core/lib/bwipConstants"; import type { LabelObject } from "@zplab/core/types/Group"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; import type { ZplRotation } from "@zplab/core/registry/rotation"; const qrFo: LabelObject = { diff --git a/src/components/Canvas/transformPosition.ts b/src/components/Canvas/transformPosition.ts index 3981fdd1..d91c0c2f 100644 --- a/src/components/Canvas/transformPosition.ts +++ b/src/components/Canvas/transformPosition.ts @@ -1,4 +1,4 @@ -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; import { QR_FO_Y_OFFSET_DOTS, QR_FT_MODULE_OFFSET } from "@zplab/core/lib/bwipConstants"; import { barcodeFtAnchorOffset, isBarcode, qrPrintsAsGraphic } from "@zplab/core/lib/objectBounds"; import { isAxisSwapped, objectRotation, type ZplRotation } from "@zplab/core/registry/rotation"; diff --git a/src/components/Output/ImportSetupChoice.tsx b/src/components/Output/ImportSetupChoice.tsx index 778ec1d9..df6acd9c 100644 --- a/src/components/Output/ImportSetupChoice.tsx +++ b/src/components/Output/ImportSetupChoice.tsx @@ -1,5 +1,5 @@ -import type { ImportFinding } from '../../lib/importReport'; -import type { SetupCommandChoice } from '../../lib/zplImportService'; +import type { ImportFinding } from '@zplab/core/lib/importReport'; +import type { SetupCommandChoice } from '@zplab/core/lib/zplImportService'; import { useT } from '../../hooks/useT'; import { FindingRow } from './ImportSummary'; diff --git a/src/components/Output/ZplImportModal.tsx b/src/components/Output/ZplImportModal.tsx index 2eaf910d..788ae4a8 100644 --- a/src/components/Output/ZplImportModal.tsx +++ b/src/components/Output/ZplImportModal.tsx @@ -1,13 +1,14 @@ import { useRef, useState } from 'react'; import { XMarkIcon, ClipboardDocumentIcon, CheckIcon, FolderOpenIcon } from '@heroicons/react/16/solid'; -import { importZplText, routeSetupCommands, mergeSetupFonts, type ZplImportResult, type SetupCommandChoice } from '../../lib/zplImportService'; +import { importZplText, routeSetupCommands, mergeSetupFonts, type ZplImportResult, type SetupCommandChoice } from '@zplab/core/lib/zplImportService'; import { readFileAsText } from '../../lib/readFile'; import { useLabelStore } from '../../store/labelStore'; import type { Page } from '@zplab/core/types/Group'; import type { LabelConfig } from '@zplab/core/types/LabelConfig'; import type { PrinterProfile } from '@zplab/core/types/PrinterProfile'; import type { Variable } from '@zplab/core/types/Variable'; -import { formatReportAsText, replayRiskFindings, printerCommandFindings, resolveRoutedReport, type ImportReport, type ImportResult } from '../../lib/importReport'; +import { formatReportAsText, type ImportResult } from '../../lib/importReport'; +import { replayRiskFindings, printerCommandFindings, resolveRoutedReport, type ImportReport } from '@zplab/core/lib/importReport'; import { ImportSummaryBody } from './ImportSummary'; import { ImportSetupChoice } from './ImportSetupChoice'; import { useT } from '../../hooks/useT'; diff --git a/src/components/Palette/ObjectPalette.tsx b/src/components/Palette/ObjectPalette.tsx index 79cf4903..ce85b79c 100644 --- a/src/components/Palette/ObjectPalette.tsx +++ b/src/components/Palette/ObjectPalette.tsx @@ -8,7 +8,7 @@ import { buildFavoritesAddMenu, buildPaletteRowMenu } from './paletteActions'; import { ContextMenu, type MenuSection } from '../ui/ContextMenu'; import { useContextMenu } from '../../hooks/useContextMenu'; import { resolveAddable, typeLabelFor, type AddableEntry } from '../../registry/palettePresets'; -import { getEntry } from '@zplab/core/registry/index'; +import { getEntry } from '@zplab/core/registry'; import { useT } from '../../hooks/useT'; import { useLabelStore } from '../../store/labelStore'; import { printableRectDots } from '@zplab/core/lib/objectBounds'; diff --git a/src/components/Palette/paletteGroups.ts b/src/components/Palette/paletteGroups.ts index a4cb79d0..b1bdb21f 100644 --- a/src/components/Palette/paletteGroups.ts +++ b/src/components/Palette/paletteGroups.ts @@ -1,6 +1,6 @@ import type { ObjectGroup } from '@zplab/core/types/LabelObject'; import { addablesInGroup, typeLabelFor, type AddableEntry } from '../../registry/palettePresets'; -import type { LeafType } from '@zplab/core/registry/index'; +import type { LeafType } from '@zplab/core/registry'; import type { SymbologyTarget } from '../../lib/symbologySwitch'; import type { Translations } from '../../locales'; diff --git a/src/components/Properties/ContentEditorButton.tsx b/src/components/Properties/ContentEditorButton.tsx index 16ce9015..b34701ea 100644 --- a/src/components/Properties/ContentEditorButton.tsx +++ b/src/components/Properties/ContentEditorButton.tsx @@ -2,7 +2,7 @@ import { useLabelStore } from "../../store/labelStore"; import { useT } from "../../hooks/useT"; import type { BindableLeaf } from "@zplab/core/lib/variableField"; import { fieldIsMultiline } from "@zplab/core/registry/text"; -import { objectResolvesCtrl, specForObject } from "@zplab/core/registry/index"; +import { objectResolvesCtrl, specForObject } from "@zplab/core/registry"; import { contentSanitiser } from "@zplab/core/registry/contentSpec"; import { TemplateContentInput } from "./TemplateContentInput"; diff --git a/src/components/Properties/LayerRow.tsx b/src/components/Properties/LayerRow.tsx index a697e027..4eb7581e 100644 --- a/src/components/Properties/LayerRow.tsx +++ b/src/components/Properties/LayerRow.tsx @@ -10,7 +10,7 @@ import { LinkSlashIcon, VariableIcon, } from '@heroicons/react/16/solid'; -import { getEntry } from '@zplab/core/registry/index'; +import { getEntry } from '@zplab/core/registry'; import { isGroup, type LabelObject } from '@zplab/core/types/Group'; import { useT } from '../../hooks/useT'; import { useLabelStore } from '../../store/labelStore'; diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index 79b79c1a..3d7ac11a 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -4,7 +4,7 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import { FolderPlusIcon } from '@heroicons/react/16/solid'; import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; import { canGroupSelection, dragBlockIds, effectiveSelection, findObjectById, isGroup, walkObjects } from '@zplab/core/types/Group'; -import { getEntry } from '@zplab/core/registry/index'; +import { getEntry } from '@zplab/core/registry'; import { useT } from '../../hooks/useT'; import { buildBulkToggleUpdates, type ToggleField } from '../../lib/bulkToggle'; import { buildFlatRows, useLayerDnd, type FlatRow } from './useLayerDnd'; diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index c67bc357..ccd4fcba 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -4,7 +4,7 @@ import { useLabelStore, useCurrentObjects, selectPreviewLocksEditor } from "../. import type { LabelCanvasHandle } from "../Canvas/LabelCanvas"; import type { AlignOp, DistributeAxis, AlignRef } from "../../lib/align"; import type { AlignSelectionRef } from "../../store/slices/uiSlice"; -import { getEntry } from "@zplab/core/registry/index"; +import { getEntry } from "@zplab/core/registry"; import { getPanel } from "../../registry/panels"; import { canGroupSelection, findObjectById, hasLockedAncestor, isGroup } from "@zplab/core/types/Group"; import { symbologyTargets } from "../../lib/symbologySwitch"; diff --git a/src/components/Properties/SerialModeSection.tsx b/src/components/Properties/SerialModeSection.tsx index 0c3e6a32..ccf4a30c 100644 --- a/src/components/Properties/SerialModeSection.tsx +++ b/src/components/Properties/SerialModeSection.tsx @@ -6,7 +6,7 @@ import { CheckboxRow } from "./CheckboxRow"; import { NumberInput } from "./NumberInput"; import { SegmentedControl } from "../ui/SegmentedControl"; import { inputCls, labelCls } from "../ui/formStyles"; -import { getEntry, objectResolvesCtrl, specForObject } from "@zplab/core/registry/index"; +import { getEntry, objectResolvesCtrl, specForObject } from "@zplab/core/registry"; import { serialDisablePatch, serialEnablePatch, diff --git a/src/components/Properties/SymbologySelect.tsx b/src/components/Properties/SymbologySelect.tsx index 358a214c..5bc3d1df 100644 --- a/src/components/Properties/SymbologySelect.tsx +++ b/src/components/Properties/SymbologySelect.tsx @@ -3,7 +3,7 @@ import { useT } from '../../hooks/useT'; import { Select, type SelectGroup } from '../ui/Select'; import { symbologyGroupsFor } from '../Palette/paletteGroups'; import { convertSymbologyMapper, type SymbologyTarget } from '../../lib/symbologySwitch'; -import type { LeafType } from '@zplab/core/registry/index'; +import type { LeafType } from '@zplab/core/registry'; import type { LabelObjectBase } from '@zplab/core/types/LabelObject'; /** Panel-header symbology switcher: shows the current barcode type and converts diff --git a/src/components/Properties/VariableBuilderModal.tsx b/src/components/Properties/VariableBuilderModal.tsx index 9f8ffbf0..b5a61df0 100644 --- a/src/components/Properties/VariableBuilderModal.tsx +++ b/src/components/Properties/VariableBuilderModal.tsx @@ -14,7 +14,7 @@ import { useLabelStore, getCurrentObjects } from "../../store/labelStore"; import { findObjectById } from "@zplab/core/types/Group"; import { markerOf } from "@zplab/core/types/Variable"; import { fieldIsMultiline } from "@zplab/core/registry/text"; -import { objectResolvesCtrl, specForObject } from "@zplab/core/registry/index"; +import { objectResolvesCtrl, specForObject } from "@zplab/core/registry"; import { contentSanitiser } from "@zplab/core/registry/contentSpec"; import { removeMarkerAt } from "../../lib/markerTokens"; import { extractTemplateRefs, renameTemplateMarker } from "@zplab/core/lib/fnTemplate"; diff --git a/src/lib/barcodeHri.ts b/src/lib/barcodeHri.ts index da536eec..15c92e9f 100644 --- a/src/lib/barcodeHri.ts +++ b/src/lib/barcodeHri.ts @@ -1,7 +1,7 @@ // Pure HRI text-zone resolution shared by the barcode renderer (getDisplaySize) // and the group-rotation bbox probe, so zone height and side never drift apart. -import { ObjectRegistry, type LeafObject } from "@zplab/core/registry/index"; +import { ObjectRegistry, type LeafObject } from "@zplab/core/registry"; import { EAN_TEXT_ZONE_DOTS, LOGMARS_TEXT_ZONE_DOTS, diff --git a/src/lib/densityRescale.test.ts b/src/lib/densityRescale.test.ts index d861b648..58c9ecc4 100644 --- a/src/lib/densityRescale.test.ts +++ b/src/lib/densityRescale.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, beforeEach } from "vitest"; import { rescaleDesign } from "./densityRescale"; import { useLabelStore } from "../store/labelStore"; import type { LabelObject, Page } from "@zplab/core/types/Group"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; import type { LabelConfig } from "@zplab/core/types/LabelConfig"; const label: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8 }; diff --git a/src/lib/densityRescale.ts b/src/lib/densityRescale.ts index 26a5b06a..b7f22205 100644 --- a/src/lib/densityRescale.ts +++ b/src/lib/densityRescale.ts @@ -1,5 +1,5 @@ import { isGroup, type LabelObject, type LeafObject, type Page } from "@zplab/core/types/Group"; -import { getEntry } from "@zplab/core/registry/index"; +import { getEntry } from "@zplab/core/registry"; import type { LabelConfig } from "@zplab/core/types/LabelConfig"; /** A field whose scaled value had to be clamped or snapped, so the rescale is diff --git a/src/lib/emittedAnchor.test.ts b/src/lib/emittedAnchor.test.ts index f7852312..a09490c1 100644 --- a/src/lib/emittedAnchor.test.ts +++ b/src/lib/emittedAnchor.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { emittedAnchorDots } from "@zplab/core/lib/emittedAnchor"; import type { ObjectBoundsCtx } from "@zplab/core/lib/objectBounds"; import type { LabelObject } from "@zplab/core/types/Group"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; const label = { widthMm: 100, heightMm: 50, dpmm: 8 }; const ctx: ObjectBoundsCtx = { label }; diff --git a/src/lib/historyFormat.ts b/src/lib/historyFormat.ts index f850c1ab..63a77ee7 100644 --- a/src/lib/historyFormat.ts +++ b/src/lib/historyFormat.ts @@ -1,4 +1,4 @@ -import { getEntry } from '@zplab/core/registry/index'; +import { getEntry } from '@zplab/core/registry'; import { formatTemplate } from './formatTemplate'; import type { Translations } from '../locales'; import type { HistoryStepDescriptor } from './historyStep'; diff --git a/src/lib/historyStep.test.ts b/src/lib/historyStep.test.ts index c5ed88ce..330336d9 100644 --- a/src/lib/historyStep.test.ts +++ b/src/lib/historyStep.test.ts @@ -6,7 +6,7 @@ import { type HistorySnapshot, } from "./historyStep"; import type { LabelObject, Page } from "@zplab/core/types/Group"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; import type { Variable } from "@zplab/core/types/Variable"; import type { LabelConfig } from "@zplab/core/types/LabelConfig"; diff --git a/src/lib/multiResize.test.ts b/src/lib/multiResize.test.ts index 0dc9dce5..9699f912 100644 --- a/src/lib/multiResize.test.ts +++ b/src/lib/multiResize.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { projectMultiResize } from "./multiResize"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; const ident = (v: number) => v; const bbox = { x: 100, y: 100, width: 200, height: 100 }; diff --git a/src/lib/multiResize.ts b/src/lib/multiResize.ts index 30780936..407a984c 100644 --- a/src/lib/multiResize.ts +++ b/src/lib/multiResize.ts @@ -1,5 +1,5 @@ -import type { LeafObject } from "@zplab/core/registry/index"; -import { getEntry, SHAPE_PRIMITIVE_TYPES } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; +import { getEntry, SHAPE_PRIMITIVE_TYPES } from "@zplab/core/registry"; import type { BoundingBoxDots } from "@zplab/core/lib/objectBounds"; import { makeFree } from "./lineConstrain"; diff --git a/src/lib/objectBounds.test.ts b/src/lib/objectBounds.test.ts index 6a466eab..4b801d81 100644 --- a/src/lib/objectBounds.test.ts +++ b/src/lib/objectBounds.test.ts @@ -4,7 +4,7 @@ import type { ZplRotation } from "@zplab/core/registry/rotation"; import { QR_FT_MODULE_OFFSET } from "@zplab/core/lib/bwipConstants"; import type { LabelConfig } from "@zplab/core/types/LabelConfig"; import type { LabelObject } from "@zplab/core/types/Group"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; const label: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8 }; const ctx = (measured?: ObjectBoundsCtx["measured"]): ObjectBoundsCtx => ({ label, measured }); diff --git a/src/lib/positionConvert.test.ts b/src/lib/positionConvert.test.ts index 64163267..22435fef 100644 --- a/src/lib/positionConvert.test.ts +++ b/src/lib/positionConvert.test.ts @@ -4,7 +4,7 @@ import { objectBoundsDots, type ObjectBoundsCtx } from "@zplab/core/lib/objectBo import { QR_FO_Y_OFFSET_DOTS, QR_FT_MODULE_OFFSET } from "@zplab/core/lib/bwipConstants"; import type { LabelConfig } from "@zplab/core/types/LabelConfig"; import type { LabelObject } from "@zplab/core/types/Group"; -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; import type { ZplRotation } from "@zplab/core/registry/rotation"; const label: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8 }; diff --git a/src/lib/positionConvert.ts b/src/lib/positionConvert.ts index 39cbdaa2..e8e2d5f8 100644 --- a/src/lib/positionConvert.ts +++ b/src/lib/positionConvert.ts @@ -1,4 +1,4 @@ -import type { LeafObject } from "@zplab/core/registry/index"; +import type { LeafObject } from "@zplab/core/registry"; import { objectBoundsDots, type ObjectBoundsCtx } from "@zplab/core/lib/objectBounds"; export const positionTypeOf = (obj: { positionType?: "FO" | "FT" }): "FO" | "FT" => diff --git a/src/lib/printPreview.ts b/src/lib/printPreview.ts index 5074a1b3..9193c20c 100644 --- a/src/lib/printPreview.ts +++ b/src/lib/printPreview.ts @@ -4,7 +4,7 @@ import type { LabelConfig } from "@zplab/core/types/LabelConfig"; import { isGroup, type LabelObject } from "@zplab/core/types/Group"; import type { Variable } from "@zplab/core/types/Variable"; import { applyBindingToTree, clockCtxFromLabel, getObjectStringContent, type ActiveCsvRow } from "@zplab/core/lib/variableBinding"; -import { objectResolvesCtrl } from "@zplab/core/registry/index"; +import { objectResolvesCtrl } from "@zplab/core/registry"; import { placeholderContentFor, samplePropsFor } from "../registry/placeholderContent"; /** Blank fields rendered with their symbology sample, so the preview overlay diff --git a/src/lib/spawn.test.ts b/src/lib/spawn.test.ts index a22c12d5..3f21375e 100644 --- a/src/lib/spawn.test.ts +++ b/src/lib/spawn.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { centeredSpawnAnchor, spawnRotationOverride } from './spawn'; import { objectBoundsDots } from '@zplab/core/lib/objectBounds'; -import { getEntry } from '@zplab/core/registry/index'; +import { getEntry } from '@zplab/core/registry'; import type { LabelObject } from '@zplab/core/types/Group'; const LABEL = { widthMm: 100, heightMm: 60, dpmm: 8 }; diff --git a/src/lib/spawn.ts b/src/lib/spawn.ts index 751bc3c9..af2b2a29 100644 --- a/src/lib/spawn.ts +++ b/src/lib/spawn.ts @@ -1,4 +1,4 @@ -import { getEntry } from '@zplab/core/registry/index'; +import { getEntry } from '@zplab/core/registry'; import { getStepRotation, zplRotationForView, type ViewRotation, type ZplRotation } from '@zplab/core/registry/rotation'; import { objectBoundsDots, type ObjectBoundsCtx } from '@zplab/core/lib/objectBounds'; import type { LabelConfig } from '@zplab/core/types/LabelConfig'; diff --git a/src/lib/symbologySwitch.ts b/src/lib/symbologySwitch.ts index 17ba4ae4..3e1d6e08 100644 --- a/src/lib/symbologySwitch.ts +++ b/src/lib/symbologySwitch.ts @@ -1,4 +1,4 @@ -import { getEntry, ObjectRegistry } from "@zplab/core/registry/index"; +import { getEntry, ObjectRegistry } from "@zplab/core/registry"; import { resolveContentSpec, hasValidLength, violatesCharset } from "@zplab/core/registry/contentSpec"; import type { ContentSpec } from "@zplab/core/types/contentSpec"; import type { LeafType } from "@zplab/core/registry/leafObject"; diff --git a/src/lib/tidyClassify.ts b/src/lib/tidyClassify.ts index 83c4156b..1487c989 100644 --- a/src/lib/tidyClassify.ts +++ b/src/lib/tidyClassify.ts @@ -5,7 +5,7 @@ // dot bboxes (see objectBounds.ts). import type { BoundingBoxDots } from "@zplab/core/lib/objectBounds"; -import { SHAPE_PRIMITIVE_TYPES } from "@zplab/core/registry/index"; +import { SHAPE_PRIMITIVE_TYPES } from "@zplab/core/registry"; export type TidyClass = "frame" | "divider" | "content"; diff --git a/src/lib/variableBinding.test.ts b/src/lib/variableBinding.test.ts index c64d47d7..ee842db7 100644 --- a/src/lib/variableBinding.test.ts +++ b/src/lib/variableBinding.test.ts @@ -9,7 +9,7 @@ import { resolveContentPreview, type ActiveCsvRow, } from '@zplab/core/lib/variableBinding'; -import { objectResolvesCtrl } from '@zplab/core/registry/index'; +import { objectResolvesCtrl } from '@zplab/core/registry'; import type { CsvMapping, Variable } from '@zplab/core/types/Variable'; import type { LabelObject } from '@zplab/core/types/Group'; diff --git a/src/lib/zplOverlay/emitOverlayPage.test.ts b/src/lib/zplOverlay/emitOverlayPage.test.ts index 8cc5a32f..50cbfdac 100644 --- a/src/lib/zplOverlay/emitOverlayPage.test.ts +++ b/src/lib/zplOverlay/emitOverlayPage.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { importZplText } from "../zplImportService"; +import { importZplText } from "@zplab/core/lib/zplImportService"; import { emitOverlayPage, generateMultiPageZPL } from "@zplab/core/lib/zplGenerator"; import type { LabelConfig } from "@zplab/core/types/LabelConfig"; import type { LabelObject, Page } from "@zplab/core/types/Group"; diff --git a/src/lib/zplRoundtrip.integration.test.ts b/src/lib/zplRoundtrip.integration.test.ts index 63e57992..df1e088d 100644 --- a/src/lib/zplRoundtrip.integration.test.ts +++ b/src/lib/zplRoundtrip.integration.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import { useLabelStore } from "../store/labelStore"; -import { importZplText } from "./zplImportService"; +import { importZplText } from "@zplab/core/lib/zplImportService"; import { generateMultiPageZPL } from "@zplab/core/lib/zplGenerator"; import { serializeDesign, parseDesignFile } from "@zplab/core/lib/designFile"; import { getObjectStringContent } from "@zplab/core/lib/variableBinding"; diff --git a/src/registry/barcode1d.panel.tsx b/src/registry/barcode1d.panel.tsx index edab68d7..b10800a5 100644 --- a/src/registry/barcode1d.panel.tsx +++ b/src/registry/barcode1d.panel.tsx @@ -3,7 +3,7 @@ import type { Translations } from '../locales'; import { useT } from '../hooks/useT'; import { useLabelStore } from '../store/labelStore'; import { hasValidLength } from '@zplab/core/registry/contentSpec'; -import { specForObject } from '@zplab/core/registry/index'; +import { specForObject } from '@zplab/core/registry'; import { RotationSelect } from '../components/Properties/RotationSelect'; import { NumberInput } from '../components/Properties/NumberInput'; import { UnitNumberInput } from '../components/Properties/UnitNumberInput'; diff --git a/src/registry/datamatrix.panel.test.ts b/src/registry/datamatrix.panel.test.ts index c5737352..6fd61a4c 100644 --- a/src/registry/datamatrix.panel.test.ts +++ b/src/registry/datamatrix.panel.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect } from 'vitest'; -import { ObjectRegistry } from '@zplab/core/registry/index'; +import { ObjectRegistry } from '@zplab/core/registry'; import { resolveContentSpec, contentSanitiser } from '@zplab/core/registry/contentSpec'; import { GS1_EXPANDED_CHARSET, GS1_GS } from '@zplab/core/lib/gs1'; diff --git a/src/registry/gs1databar.panel.test.ts b/src/registry/gs1databar.panel.test.ts index 7c4bfa32..16199dad 100644 --- a/src/registry/gs1databar.panel.test.ts +++ b/src/registry/gs1databar.panel.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect } from 'vitest'; -import { ObjectRegistry } from '@zplab/core/registry/index'; +import { ObjectRegistry } from '@zplab/core/registry'; import { resolveContentSpec, contentSanitiser } from '@zplab/core/registry/contentSpec'; import { GS1_DATABAR_EXPANDED_SYMBOLOGIES, GS1_EXPANDED_CHARSET, GS1_GS } from '@zplab/core/lib/gs1'; diff --git a/src/registry/maxicode.test.ts b/src/registry/maxicode.test.ts index 606fe3ae..2f6665e8 100644 --- a/src/registry/maxicode.test.ts +++ b/src/registry/maxicode.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { ObjectRegistry } from "@zplab/core/registry/index"; +import { ObjectRegistry } from "@zplab/core/registry"; import { parseZPL } from "@zplab/core/lib/zplParser"; import { validateMaxicodeBwip } from "../components/Canvas/bwipHelpers"; import type { LabelObjectBase } from "@zplab/core/types/LabelObject"; diff --git a/src/registry/palettePresets.ts b/src/registry/palettePresets.ts index fd27b90f..54ca007c 100644 --- a/src/registry/palettePresets.ts +++ b/src/registry/palettePresets.ts @@ -1,4 +1,4 @@ -import { ObjectRegistry, getEntry } from '@zplab/core/registry/index'; +import { ObjectRegistry, getEntry } from '@zplab/core/registry'; import type { ObjectGroup } from '@zplab/core/types/LabelObject'; import type { ObjectTypeCore } from '@zplab/core/types/ObjectType'; import type { Translations } from '../locales'; diff --git a/src/registry/placeholderContent.ts b/src/registry/placeholderContent.ts index 6c474eec..5429a159 100644 --- a/src/registry/placeholderContent.ts +++ b/src/registry/placeholderContent.ts @@ -1,4 +1,4 @@ -import { getEntry } from '@zplab/core/registry/index'; +import { getEntry } from '@zplab/core/registry'; import { GS1_SAMPLE_CONTENT } from '@zplab/core/lib/gs1'; /** The sample a blank field renders on canvas and in the Labelary preview diff --git a/src/registry/registry.test.ts b/src/registry/registry.test.ts index da7239a3..678d0dbe 100644 --- a/src/registry/registry.test.ts +++ b/src/registry/registry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { ObjectRegistry, getEntry } from '@zplab/core/registry/index'; +import { ObjectRegistry, getEntry } from '@zplab/core/registry'; import type { LabelObjectBase } from '@zplab/core/types/LabelObject'; import { defined } from '../test/helpers'; import { qualityPatch, type DataMatrixProps } from '@zplab/core/registry/datamatrix'; diff --git a/src/registry/serialField.test.ts b/src/registry/serialField.test.ts index 31e1c0bb..c93c2c3a 100644 --- a/src/registry/serialField.test.ts +++ b/src/registry/serialField.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { getEntry } from "@zplab/core/registry/index"; +import { getEntry } from "@zplab/core/registry"; import { serialDisablePatch, serialEnablePatch, SERIAL_DEFAULT } from "@zplab/core/registry/serialField"; describe("serialisable capability (matches what the emitter actually emits)", () => { diff --git a/src/registry/typedContentSection.tsx b/src/registry/typedContentSection.tsx index 1b38f911..e5c6ff26 100644 --- a/src/registry/typedContentSection.tsx +++ b/src/registry/typedContentSection.tsx @@ -1,6 +1,6 @@ import { useT } from '../hooks/useT'; import { useLabelStore } from '../store/labelStore'; -import { getEntry } from '@zplab/core/registry/index'; +import { getEntry } from '@zplab/core/registry'; import { StaticSectionCard } from '../components/Properties/SectionCard'; import { ContentEditorButton } from '../components/Properties/ContentEditorButton'; import { builderButtonCls } from '../components/ui/formStyles'; diff --git a/src/store/labelStore.internals.ts b/src/store/labelStore.internals.ts index 765b7190..b7b6bfa7 100644 --- a/src/store/labelStore.internals.ts +++ b/src/store/labelStore.internals.ts @@ -3,7 +3,7 @@ import type { ObjectChanges } from '@zplab/core/types/LabelObject'; import { isLocaleCode, type LocaleCode } from '../locales'; import { renameTemplateMarkers, substituteTemplateMarker } from '@zplab/core/lib/fnTemplate'; import { getObjectStringContent } from '@zplab/core/lib/variableBinding'; -import { getEntry } from '@zplab/core/registry/index'; +import { getEntry } from '@zplab/core/registry'; /** Meta fields that remain editable on a locked object so the user can * release the lock or annotate without unlocking first. Everything else diff --git a/src/store/slices/labelConfigSlice.ts b/src/store/slices/labelConfigSlice.ts index 14227b26..6e30849a 100644 --- a/src/store/slices/labelConfigSlice.ts +++ b/src/store/slices/labelConfigSlice.ts @@ -6,7 +6,7 @@ import { forgetImport } from '../../lib/csvImport'; import { dropLegacyFontBindings } from '@zplab/core/lib/customFonts'; import { selectPreviewLocksEditor } from '../labelStore.selectors'; import { configPatchAffectsEmit } from '../labelStore.internals'; -import { dropPageOverlays } from '../../lib/pageOverlay'; +import { dropPageOverlays } from '@zplab/core/lib/pageOverlay'; import { rescaleDesign } from '../../lib/densityRescale'; import type { LabelState } from '../labelStore'; diff --git a/src/store/slices/objectSlice.ts b/src/store/slices/objectSlice.ts index ef3b605f..c2545c0c 100644 --- a/src/store/slices/objectSlice.ts +++ b/src/store/slices/objectSlice.ts @@ -12,7 +12,7 @@ import { type LabelObject, type Page, } from '@zplab/core/types/Group'; -import { getEntry } from '@zplab/core/registry/index'; +import { getEntry } from '@zplab/core/registry'; import { reorderForZ, type ZOrderDir } from '../../lib/zorder'; import { makeReverseBackingBox, precedingBackingExists, isOwnReverseBacking } from '@zplab/core/lib/reverseBacking'; import { spawnRotationOverride } from '../../lib/spawn'; diff --git a/src/store/slices/printerProfileSlice.ts b/src/store/slices/printerProfileSlice.ts index 804c783d..4ccaaeda 100644 --- a/src/store/slices/printerProfileSlice.ts +++ b/src/store/slices/printerProfileSlice.ts @@ -5,7 +5,7 @@ import { printerProfileSchema, type PrinterProfile, } from '@zplab/core/types/PrinterProfile'; -import { pruneUndefined } from '../../lib/pruneUndefined'; +import { pruneUndefined } from '@zplab/core/lib/pruneUndefined'; import { selectPreviewLocksEditor } from '../labelStore.selectors'; import type { LabelState } from '../labelStore'; diff --git a/src/store/slices/variablesSlice.ts b/src/store/slices/variablesSlice.ts index cf808706..45baecf8 100644 --- a/src/store/slices/variablesSlice.ts +++ b/src/store/slices/variablesSlice.ts @@ -13,7 +13,7 @@ import { rewriteTemplateMarkers, substituteTemplateMarkers, } from '../labelStore.internals'; -import { dropPageOverlays } from '../../lib/pageOverlay'; +import { dropPageOverlays } from '@zplab/core/lib/pageOverlay'; import { selectPreviewLocksEditor } from '../labelStore.selectors'; import type { LabelState } from '../labelStore'; diff --git a/src/test/labelarySync.test.ts b/src/test/labelarySync.test.ts index 475324e1..b32ce93a 100644 --- a/src/test/labelarySync.test.ts +++ b/src/test/labelarySync.test.ts @@ -19,7 +19,7 @@ import { dotsToPx } from "@zplab/core/lib/coordinates"; import { getObjectStringContent } from "@zplab/core/lib/variableBinding"; import { upcSuppTextZoneDots, QR_FT_MODULE_OFFSET } from "@zplab/core/lib/bwipConstants"; import { barcodeFtAnchorOffset } from "@zplab/core/lib/objectBounds"; -import { ObjectRegistry } from "@zplab/core/registry/index"; +import { ObjectRegistry } from "@zplab/core/registry"; import { objectRotation } from "@zplab/core/registry/rotation"; import { defined } from "./helpers"; import { testModels } from "./testModels"; From 0d213830d098abd5cf944a556b6bf044f725dd29 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 18 Jul 2026 21:28:16 +0200 Subject: [PATCH 4/5] feat(mcp): stdio/HTTP draft server with raw-ZPL and geometry feedback --- packages/mcp-server/README.md | 51 ++ packages/mcp-server/package.json | 17 + packages/mcp-server/src/http.test.ts | 104 ++++ packages/mcp-server/src/http.ts | 100 ++++ packages/mcp-server/src/index.ts | 28 ++ packages/mcp-server/src/openInApp.test.ts | 65 +++ packages/mcp-server/src/server.test.ts | 30 ++ packages/mcp-server/src/server.ts | 145 ++++++ packages/mcp-server/src/testFixtures.ts | 19 + packages/mcp-server/src/tools.test.ts | 377 ++++++++++++++ packages/mcp-server/src/tools.ts | 584 ++++++++++++++++++++++ packages/mcp-server/tsconfig.json | 10 + 12 files changed, 1530 insertions(+) create mode 100644 packages/mcp-server/README.md create mode 100644 packages/mcp-server/package.json create mode 100644 packages/mcp-server/src/http.test.ts create mode 100644 packages/mcp-server/src/http.ts create mode 100644 packages/mcp-server/src/index.ts create mode 100644 packages/mcp-server/src/openInApp.test.ts create mode 100644 packages/mcp-server/src/server.test.ts create mode 100644 packages/mcp-server/src/server.ts create mode 100644 packages/mcp-server/src/testFixtures.ts create mode 100644 packages/mcp-server/src/tools.test.ts create mode 100644 packages/mcp-server/src/tools.ts create mode 100644 packages/mcp-server/tsconfig.json diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 00000000..da5b4ba2 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,51 @@ +# @zplab/mcp-server + +An MCP server that lets an assistant build ZPLab label drafts and turn them into ZPL. + +## Tools + +- `get_schema`: list the supported object types and their props. +- `create_draft`: build a design file from a size and a list of objects, with preflight warnings, per-object bounds, and bbox overlaps. +- `validate_draft`: parse a design file and return schema errors, preflight warnings, per-object bounds, and bbox overlaps. +- `export_zpl`: parse a design file and return its generated ZPL. +- `validate_zpl`: parse raw ZPL (one page per `^XA` block) and report object/page count, detected label, parser findings, preflight warnings, per-object bounds, and bbox overlaps. +- `import_zpl`: parse raw ZPL into an editable design file (one page per `^XA` block, overlays preserved for verbatim re-export) plus parser findings, per-object bounds, and bbox overlaps. +- `open_in_app`: push a design file into the running ZPLab desktop app. Only registered when the app spawned the server (HTTP mode), so it is absent over stdio. + +Bounds are dots (visual top-left); `approx` marks headless estimates (barcode +footprints and single-line text), not render-measured. Overlaps are raw bbox +intersections, not errors: a frame/reverse box overlaps its contents by design. +Geometry is bounded on hostile input: a page past 2000 objects skips its +geometry and overlaps are capped, both flagged by `geometryTruncated`. Raw ZPL +over 256 KB is rejected (`ok: false`). + +## Run + +``` +pnpm --filter @zplab/mcp-server exec tsx src/index.ts +``` + +Default transport is stdio. Passing `--http` serves the Streamable HTTP transport +instead, bound to `127.0.0.1` only: + +``` +pnpm --filter @zplab/mcp-server exec tsx src/index.ts --http --port 4923 --token +``` + +`--port` and `--token` are both required with `--http`. Every request must carry +`Authorization: Bearer `; loopback-only binding plus Origin/Host checks +guard against local drive-by and DNS-rebinding. + +## Claude Desktop config + +```json +{ + "mcpServers": { + "zplab": { + "command": "pnpm", + "args": ["--filter", "@zplab/mcp-server", "exec", "tsx", "src/index.ts"], + "cwd": "/path/to/zpl_label_designer" + } + } +} +``` diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 00000000..6c6d7771 --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,17 @@ +{ + "name": "@zplab/mcp-server", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "tsx src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@zplab/core": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "tsx": "^4.19.2" + } +} diff --git a/packages/mcp-server/src/http.test.ts b/packages/mcp-server/src/http.test.ts new file mode 100644 index 00000000..b7b7e3cb --- /dev/null +++ b/packages/mcp-server/src/http.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { startHttpServer, type RunningHttpServer } from "./http"; +import { designFile } from "./testFixtures"; + +const TOKEN = "s3cret-token"; + +const MCP_ACCEPT = "application/json, text/event-stream"; + +const initBody = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test", version: "0.0.0" }, + }, +}; + +let server: RunningHttpServer; + +function post( + body: unknown, + opts: { token?: string; origin?: string; auth?: string } = {}, +): Promise { + const headers: Record = { + "content-type": "application/json", + accept: MCP_ACCEPT, + }; + if (opts.auth !== undefined) headers.authorization = opts.auth; + else if (opts.token !== undefined) headers.authorization = `Bearer ${opts.token}`; + if (opts.origin !== undefined) headers.origin = opts.origin; + return fetch(`http://127.0.0.1:${server.port}/`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + server = await startHttpServer({ port: 0, token: TOKEN }); +}); + +afterEach(async () => { + await server.close(); +}); + +describe("mcp-server http transport", () => { + it("completes the initialize handshake with the correct token", async () => { + const res = await post(initBody, { token: TOKEN }); + expect(res.status).toBe(200); + const json = (await res.json()) as { result?: { serverInfo?: unknown } }; + expect(json.result).toBeDefined(); + expect(json.result?.serverInfo).toBeDefined(); + }); + + it("rejects a wrong token with 401 and no MCP processing", async () => { + const res = await post(initBody, { token: "wrong" }); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate")).toBe("Bearer"); + const json = (await res.json()) as { result?: unknown; jsonrpc?: unknown }; + expect(json.result).toBeUndefined(); + expect(json.jsonrpc).toBeUndefined(); + }); + + it("rejects a missing token with 401", async () => { + const res = await post(initBody); + expect(res.status).toBe(401); + }); + + it("accepts a lowercase 'bearer' scheme", async () => { + const res = await post(initBody, { auth: `bearer ${TOKEN}` }); + expect(res.status).toBe(200); + }); + + it("accepts multiple spaces after the scheme", async () => { + const res = await post(initBody, { auth: `Bearer ${TOKEN}` }); + expect(res.status).toBe(200); + }); + + it("rejects a non-localhost Origin with 403 even with a valid token", async () => { + const res = await post(initBody, { token: TOKEN, origin: "http://evil.example" }); + expect(res.status).toBe(403); + }); + + it("round-trips tools/call export_zpl over HTTP and returns ZPL", async () => { + const res = await post( + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "export_zpl", arguments: { designFile } }, + }, + { token: TOKEN }, + ); + expect(res.status).toBe(200); + const json = (await res.json()) as { + result?: { content?: { type: string; text: string }[] }; + }; + const text = json.result?.content?.[0]?.text ?? ""; + expect(text).toContain("^XA"); + expect(text).toContain("HELLO"); + }); +}); diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts new file mode 100644 index 00000000..eba0d9e7 --- /dev/null +++ b/packages/mcp-server/src/http.ts @@ -0,0 +1,100 @@ +import { createServer, type IncomingMessage } from "node:http"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { buildServer } from "./server.js"; + +const HOST = "127.0.0.1"; + +export interface HttpServerOptions { + port: number; + token: string; +} + +export interface RunningHttpServer { + port: number; + close: () => Promise; +} + +/** Constant-time Bearer check. Hashing both sides to a fixed 32 bytes avoids + * the length leak and the length-mismatch throw of a raw timingSafeEqual. + * Scheme is parsed per RFC 7235: case-insensitive, one-or-more spaces. */ +function hasValidToken(req: IncomingMessage, expected: string): boolean { + const header = req.headers.authorization; + if (typeof header !== "string" || !/^Bearer\s+/i.test(header)) return false; + const token = header.replace(/^Bearer\s+/i, "").trim(); + const provided = createHash("sha256").update(token).digest(); + const wanted = createHash("sha256").update(expected).digest(); + return timingSafeEqual(provided, wanted); +} + +/** Loopback-only Streamable HTTP server with mandatory bearer auth. A fresh + * McpServer + transport is built per request (stateless: our tools are pure + * request/response); Origin/Host are checked by the SDK's DNS-rebinding protection. */ +export async function startHttpServer(options: HttpServerOptions): Promise { + const { port, token } = options; + + const httpServer = createServer((req, res) => { + if (!hasValidToken(req, token)) { + res.writeHead(401, { + "content-type": "application/json", + "www-authenticate": "Bearer", + }); + res.end(JSON.stringify({ error: "unauthorized" })); + return; + } + + const boundPort = (httpServer.address() as { port: number }).port; + const authority = `${HOST}:${boundPort}`; + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + enableDnsRebindingProtection: true, + allowedHosts: [authority, `localhost:${boundPort}`], + allowedOrigins: [`http://${authority}`, `http://localhost:${boundPort}`], + }); + const server = buildServer({ openInApp: true }); + res.on("close", () => { + void transport.close(); + void server.close(); + }); + server + .connect(transport) + .then(() => transport.handleRequest(req, res)) + .catch(() => { + // Guard against a handler that already responded or closed the socket. + if (!res.headersSent && res.writable) res.writeHead(500); + res.end(); + }); + }); + + const boundPort = await new Promise((resolve, reject) => { + const onListenError = (err: Error) => reject(err); + httpServer.once("error", onListenError); + httpServer.listen(port, HOST, () => { + httpServer.removeListener("error", onListenError); + const addr = httpServer.address(); + if (addr && typeof addr === "object") { + // Readiness signal for the Tauri parent, which waits on this line before + // reporting the server up. stdout is free in HTTP mode (not JSON-RPC). + process.stdout.write(JSON.stringify({ zplabEvent: "listening", port: addr.port }) + "\n"); + resolve(addr.port); + } else { + reject(new Error("failed to determine bound port")); + } + }); + }); + + // Post-bind errors (client resets, EPIPE) would otherwise go unhandled and + // crash the process; surface them to stderr instead. + httpServer.on("error", (err) => { + console.error("mcp http server error:", err); + }); + + return { + port: boundPort, + close: () => + new Promise((resolve, reject) => { + httpServer.close((err) => (err ? reject(err) : resolve())); + }), + }; +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 00000000..8e4b284d --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,28 @@ +import { parseArgs } from "node:util"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { buildServer } from "./server.js"; +import { startHttpServer } from "./http.js"; + +const { values } = parseArgs({ + options: { + http: { type: "boolean", default: false }, + port: { type: "string" }, + token: { type: "string" }, + }, +}); + +if (values.http) { + const port = Number(values.port); + if (!values.port || !Number.isInteger(port) || port < 0 || port > 65535) { + process.stderr.write("--http requires a valid --port\n"); + process.exit(1); + } + if (!values.token) { + process.stderr.write("--http requires --token\n"); + process.exit(1); + } + await startHttpServer({ port, token: values.token }); +} else { + const server = buildServer(); + await server.connect(new StdioServerTransport()); +} diff --git a/packages/mcp-server/src/openInApp.test.ts b/packages/mcp-server/src/openInApp.test.ts new file mode 100644 index 00000000..cc45583b --- /dev/null +++ b/packages/mcp-server/src/openInApp.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { buildServer } from "./server"; +import { openInApp } from "./tools"; +import { designFile } from "./testFixtures"; + +async function connect(server: ReturnType): Promise { + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test", version: "0.0.0" }); + await Promise.all([server.connect(serverT), client.connect(clientT)]); + return client; +} + +describe("open_in_app gating", () => { + it("is absent from the stdio build's tool list", async () => { + const client = await connect(buildServer()); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).not.toContain("open_in_app"); + }); + + it("is listed and writes the openDraft line when app-spawned", async () => { + const writes: string[] = []; + const spy = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }); + try { + const client = await connect(buildServer({ openInApp: true })); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain("open_in_app"); + + const res = (await client.callTool({ + name: "open_in_app", + arguments: { designFile }, + })) as { content: { type: string; text: string }[] }; + expect(JSON.parse(res.content[0]?.text ?? "{}")).toEqual({ ok: true }); + + expect(writes).toHaveLength(1); + const parsed = JSON.parse(writes[0]?.trim() ?? "{}"); + expect(parsed.zplabEvent).toBe("openDraft"); + expect(parsed.designFile).toEqual(designFile); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("openInApp validation", () => { + it("builds an openDraft line for a valid design file", () => { + const result = openInApp(designFile); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(JSON.parse(result.line)).toEqual({ zplabEvent: "openDraft", designFile }); + }); + + it("returns errors for a malformed design file", () => { + const result = openInApp({ schemaVersion: 3, label: { widthMm: 10 } }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/mcp-server/src/server.test.ts b/packages/mcp-server/src/server.test.ts new file mode 100644 index 00000000..e275996f --- /dev/null +++ b/packages/mcp-server/src/server.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { buildServer, SERVER_INSTRUCTIONS } from "./server"; + +async function connect(): Promise { + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test", version: "0.0.0" }); + await Promise.all([buildServer().connect(serverT), client.connect(clientT)]); + return client; +} + +describe("server handshake", () => { + it("delivers the workflow instructions at initialize", async () => { + const client = await connect(); + expect(client.getInstructions()).toBe(SERVER_INSTRUCTIONS); + expect(SERVER_INSTRUCTIONS).toContain("get_schema"); + }); + + it("returns tool results as compact JSON (no pretty-print whitespace)", async () => { + const client = await connect(); + const res = (await client.callTool({ + name: "get_schema", + arguments: {}, + })) as { content: { type: string; text: string }[] }; + const text = res.content[0]?.text ?? ""; + expect(text.length).toBeGreaterThan(0); + expect(text).not.toMatch(/\n\s+/); + }); +}); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 00000000..ce9c5505 --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,145 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + createDraft, + createDraftShape, + designFileEnvelopeSchema, + exportZpl, + getSchema, + importZpl, + openInApp, + validateDraft, + validateZpl, + zplInputShape, +} from "./tools.js"; + +export interface BuildServerOptions { + /** Registers open_in_app, whose handler prints an event line to stdout. Set + * only in HTTP mode, where stdout is free (in stdio mode it is JSON-RPC). */ + openInApp?: boolean; +} + +// Compact on purpose: pretty-printing inflates every tool result by ~45% +// whitespace tokens the model does not need. +const json = (value: unknown) => ({ + content: [{ type: "text" as const, text: JSON.stringify(value) }], +}); + +/** Workflow recipe the host injects into the agent's context at initialize, + * so every session starts pre-trained instead of discovering it by trial. */ +export const SERVER_INSTRUCTIONS = + "ZPLab builds Zebra ZPL label designs. Call get_schema first to learn the " + + "object types and their props. Build a label with create_draft (x/y in dots " + + "from the top-left origin; props merge over defaults), then read the returned " + + "warnings, bounds, and overlaps and iterate until nothing unintended remains. " + + "bounds/overlaps marked approx are headless estimates (barcodes and single-line " + + "text): keep extra " + + "clearance around them. Overlaps are neutral facts, not errors: a frame or " + + "reverse box overlaps its contents by design. Bring existing ZPL through " + + "import_zpl (editable design file) or validate_zpl (lint only); both split " + + "multi-label streams into one page per ^XA block. export_zpl returns the " + + "final ZPL; open_in_app (when present) replaces the design in the running " + + "ZPLab editor, so confirm with the user before calling it."; + +/** Single tool definition shared by the stdio and HTTP entry points. */ +export function buildServer(options: BuildServerOptions = {}): McpServer { + // Display identity the MCP client shows; matches the config-snippet key. + const server = new McpServer( + { name: "zplab", version: "0.0.0" }, + { instructions: SERVER_INSTRUCTIONS }, + ); + + server.registerTool( + "create_draft", + { + title: "Create ZPLab draft", + description: + "Build a ZPLab label draft from a size and a list of objects. Returns the " + + "parseable design file, preflight warnings, per-object bounds (dots), and " + + "bbox overlaps. Call get_schema for object types.", + inputSchema: createDraftShape, + }, + async (args) => json(createDraft(args)), + ); + + server.registerTool( + "validate_draft", + { + title: "Validate ZPLab draft", + description: + "Parse a design file and return schema errors, preflight warnings, per-object " + + "bounds (dots), and bbox overlaps.", + inputSchema: designFileEnvelopeSchema.shape, + }, + async ({ designFile }) => json(validateDraft(designFile)), + ); + + server.registerTool( + "export_zpl", + { + title: "Export ZPL", + description: "Parse a design file and return its generated ZPL.", + inputSchema: designFileEnvelopeSchema.shape, + }, + async ({ designFile }) => json(exportZpl(designFile)), + ); + + server.registerTool( + "validate_zpl", + { + title: "Validate raw ZPL", + description: + "Parse a raw ZPL stream (one page per ^XA block) and report it: object/page " + + "count, detected label, parser findings (unknown/partial/hardware-bound " + + "commands), preflight warnings, per-object bounds (dots), and bbox overlaps. " + + "widthMm/heightMm are fallbacks for streams without ^PW/^LL.", + inputSchema: zplInputShape, + }, + async ({ zpl, dpmm, widthMm, heightMm }) => json(validateZpl(zpl, dpmm, widthMm, heightMm)), + ); + + server.registerTool( + "import_zpl", + { + title: "Import raw ZPL", + description: + "Parse a raw ZPL stream into an editable design file (one page per ^XA block; " + + "feed it to export_zpl or open_in_app) plus parser findings, per-object bounds " + + "(dots), and bbox overlaps. Size falls back to the caller's hints then 100x50mm.", + inputSchema: zplInputShape, + }, + async ({ zpl, dpmm, widthMm, heightMm }) => json(importZpl(zpl, dpmm, widthMm, heightMm)), + ); + + server.registerTool( + "get_schema", + { + title: "Get object schema", + description: "List supported object types and their props for building a draft.", + inputSchema: {}, + }, + async () => json(getSchema()), + ); + + // Only when ZPLab spawned the server (HTTP mode): the handler prints the + // draft to stdout, which the app pipes and forwards to its editor. + if (options.openInApp) { + server.registerTool( + "open_in_app", + { + title: "Open draft in ZPLab", + description: + "Push a design file into the running ZPLab desktop app, replacing the current " + + "design. Only available when ZPLab launched this server.", + inputSchema: designFileEnvelopeSchema.shape, + }, + async ({ designFile }) => { + const result = openInApp(designFile); + if (!result.ok) return json(result); + process.stdout.write(result.line + "\n"); + return json({ ok: true }); + }, + ); + } + + return server; +} diff --git a/packages/mcp-server/src/testFixtures.ts b/packages/mcp-server/src/testFixtures.ts new file mode 100644 index 00000000..23601423 --- /dev/null +++ b/packages/mcp-server/src/testFixtures.ts @@ -0,0 +1,19 @@ +/** Shared design-file fixtures for the tool and HTTP transport tests. */ + +export function textObject(id: string, content: string) { + return { + id, + type: "text", + x: 10, + y: 10, + rotation: 0, + props: { content, fontHeight: 30, fontWidth: 0, rotation: "N" }, + }; +} + +/** Minimal valid single-page design file. */ +export const designFile = { + schemaVersion: 3, + label: { widthMm: 100, heightMm: 50, dpmm: 8 }, + pages: [{ objects: [textObject("t1", "HELLO")] }], +}; diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts new file mode 100644 index 00000000..19a8d843 --- /dev/null +++ b/packages/mcp-server/src/tools.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { createDraft, createDraftShape, validateDraft, exportZpl, getSchema, importZpl, validateZpl } from "./tools"; +import { ObjectRegistry } from "@zplab/core/registry"; +import { textObject } from "./testFixtures"; + +/** Assert a tool succeeded and narrow away the ToolError branch. */ +function ok(r: T): Extract { + expect(r.ok).toBe(true); + return r as Extract; +} + +describe("mcp-server tools", () => { + it("round-trips create → validate → export for a text + code128 label", () => { + const created = createDraft({ + widthMm: 100, + heightMm: 50, + dpmm: 8, + objects: [ + { type: "text", x: 20, y: 20, props: { content: "Hello", fontHeight: 30 } }, + { type: "code128", x: 20, y: 80, props: { content: "12345", height: 80 } }, + ], + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + + const validated = validateDraft(created.designFile); + expect(validated.ok).toBe(true); + + const exported = exportZpl(created.designFile); + expect(exported.ok).toBe(true); + if (!exported.ok) return; + expect(exported.zpl).toContain("^FDHello"); + expect(exported.zpl).toContain("^BC"); + }); + + it("returns errors (not a throw) for an unknown object type", () => { + const created = createDraft({ + widthMm: 50, + heightMm: 30, + dpmm: 8, + objects: [{ type: "not-a-real-type", x: 0, y: 0 }], + }); + expect(created.ok).toBe(false); + if (created.ok) return; + expect(created.errors.length).toBeGreaterThan(0); + }); + + it("validate_draft rejects a malformed design file", () => { + const result = validateDraft({ schemaVersion: 3, label: { widthMm: 10 } }); + expect(result.ok).toBe(false); + }); + + // The envelope tools must reject out-of-range label config, not just + // create_draft's input schema, or validate_draft/export_zpl emit broken ZPL. + it("envelope tools reject an out-of-range label config", () => { + for (const label of [ + { widthMm: 100, heightMm: 50, dpmm: 0 }, + { widthMm: 100, heightMm: 50, dpmm: 7 }, + { widthMm: -5, heightMm: 50, dpmm: 8 }, + { widthMm: 100, heightMm: 0, dpmm: 8 }, + ]) { + const bad = { schemaVersion: 3, label, pages: [{ objects: [] }] }; + expect(validateDraft(bad).ok).toBe(false); + expect(exportZpl(bad).ok).toBe(false); + } + }); + + it("get_schema lists the common types with prop summaries", () => { + const schema = getSchema(); + const text = schema.types.find((t) => t.type === "text"); + expect(text?.props).toBeDefined(); + expect(schema.types.some((t) => t.type === "code128")).toBe(true); + }); + + it("every prop summary key is a real prop of its type (drift guard)", () => { + for (const t of getSchema().types) { + if (!t.props) continue; + const allowed = new Set([...Object.keys(t.defaultProps), "content", "rotation"]); + // `gs1` is an optional mode flag on gs1Capable types, not a defaultProp. + if ((ObjectRegistry as Record)[t.type]?.gs1Capable) { + allowed.add("gs1"); + } + for (const key of Object.keys(t.props)) { + expect(allowed.has(key), `${t.type}.${key} is not a known prop`).toBe(true); + } + } + }); + + it("rejects duplicate explicit ids with a structured error", () => { + const created = createDraft({ + widthMm: 50, + heightMm: 30, + dpmm: 8, + objects: [ + { type: "text", x: 0, y: 0, id: "dup", props: { content: "a" } }, + { type: "text", x: 0, y: 20, id: "dup", props: { content: "b" } }, + ], + }); + expect(created.ok).toBe(false); + if (created.ok) return; + expect(created.errors[0]).toContain("dup"); + }); + + it("auto-generated ids skip an explicit id already taken", () => { + const created = createDraft({ + widthMm: 50, + heightMm: 30, + dpmm: 8, + objects: [ + { type: "text", x: 0, y: 0, id: "text-1", props: { content: "a" } }, + { type: "text", x: 0, y: 20, props: { content: "b" } }, + ], + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const ids = (created.designFile.pages[0]?.objects ?? []).map((o) => o.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("accepts dpmm 6 in the input schema", () => { + const result = z + .object(createDraftShape) + .safeParse({ widthMm: 50, heightMm: 30, dpmm: 6, objects: [] }); + expect(result.success).toBe(true); + }); + + it("export_zpl emits objects from every page of a multi-page design", () => { + const designFile = { + schemaVersion: 3, + label: { widthMm: 100, heightMm: 50, dpmm: 8 }, + pages: [ + { objects: [textObject("p1", "PAGE1")] }, + { objects: [textObject("p2", "PAGE2")] }, + ], + }; + const exported = exportZpl(designFile); + expect(exported.ok).toBe(true); + if (!exported.ok) return; + expect(exported.zpl).toContain("PAGE1"); + expect(exported.zpl).toContain("PAGE2"); + }); + + const RAW = "^XA^FO50,50^A0N,30,30^FDHELLO^FS^BY3^FO50,120^BCN,80,Y,N,N^FD12345^FS^XZ"; + + it("import_zpl parses raw ZPL into an editable, re-exportable design file", () => { + const imported = ok(importZpl(RAW)); + const objs = imported.designFile.pages[0]?.objects ?? []; + expect(objs.map((o) => o.type)).toEqual(["text", "code128"]); + // The model round-trips back to ZPL through the normal export path. + const back = exportZpl(imported.designFile); + expect(back.ok).toBe(true); + if (!back.ok) return; + expect(back.zpl).toContain("HELLO"); + expect(back.zpl).toContain("^BC"); + }); + + it("validate_zpl reports a clean stream with no findings", () => { + const v = ok(validateZpl(RAW)); + expect(v.objectCount).toBe(2); + expect(v.findings.unknown).toEqual([]); + expect(v.findings.browserLimit).toEqual([]); + }); + + it("validate_zpl surfaces commands it could not fully model", () => { + // ^IM needs printer storage (browserLimit); ^JU is a setup command (replayRisk). + const v = ok(validateZpl("^XA^FO10,10^FDX^FS^IMR:LOGO.GRF^JUS^XZ")); + expect(v.findings.browserLimit.some((c) => c.startsWith("^IM"))).toBe(true); + expect(v.findings.replayRisk).toContain("^JU"); + }); + + it("create_draft reports per-object bounds, approx only for the barcode", () => { + const created = createDraft({ + widthMm: 100, + heightMm: 50, + dpmm: 8, + objects: [ + { type: "box", x: 10, y: 20, id: "b", props: { width: 200, height: 100 } }, + { type: "code128", x: 30, y: 140, id: "c", props: { content: "12345", height: 80 } }, + ], + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const box = created.bounds.find((b) => b.objectId === "b"); + expect(box).toMatchObject({ x: 10, y: 20, width: 200, height: 100, approx: false }); + expect(created.bounds.find((b) => b.objectId === "c")?.approx).toBe(true); + }); + + it("validate_zpl reports the intersection rect of two overlapping boxes", () => { + const v = ok(validateZpl("^XA^FO0,0^GB100,100,3^FS^FO60,60^GB100,100,3^FS^XZ")); + expect(v.overlaps).toHaveLength(1); + expect(v.overlaps[0]).toMatchObject({ width: 40, height: 40, approx: false }); + }); + + // Multi-^XA streams must keep one page per block: flattening them merges + // labels on export and reports phantom overlaps between different labels. + it("keeps one page per ^XA block and finds no cross-page overlaps", () => { + const two = "^XA^FO50,50^GB100,100,3^FS^XZ^XA^FO50,50^GB100,100,3^FS^XZ"; + const v = ok(validateZpl(two)); + expect(v.pageCount).toBe(2); + expect(v.objectCount).toBe(2); + expect(v.overlaps).toEqual([]); + const imported = ok(importZpl(two)); + expect(imported.designFile.pages).toHaveLength(2); + const back = exportZpl(imported.designFile); + expect(back.ok).toBe(true); + if (!back.ok) return; + expect(back.zpl.match(/\^XA/g)).toHaveLength(2); + }); + + // Roundtrip guarantee: unmodeled commands survive import → export via the + // captured overlay instead of being silently dropped. + it("re-exports unmodeled commands verbatim after import_zpl", () => { + const imported = ok(importZpl("^XA^FO10,10^A0N,30,30^FDX^FS^IMR:LOGO.GRF^JUS^XZ")); + expect(imported.findings.browserLimit.some((c) => c.startsWith("^IM"))).toBe(true); + const back = exportZpl(imported.designFile); + expect(back.ok).toBe(true); + if (!back.ok) return; + expect(back.zpl).toContain("^IMR:LOGO.GRF"); + expect(back.zpl).toContain("^JUS"); + }); + + it("dedupes repeated findings to one entry per command", () => { + const v = ok(validateZpl("^XA^JUS^FO10,10^FDX^FS^JUS^XZ")); + expect(v.findings.replayRisk).toEqual(["^JU"]); + }); + + it("strips the per-occurrence findings array from the compact bucket view", () => { + const v = ok(validateZpl("^XA^FO10,10^FDX^FS^XZ")); + expect(v.findings).not.toHaveProperty("findings"); + expect(Object.keys(v.findings).sort()).toEqual( + ["browserLimit", "deviceAction", "partial", "replayRisk", "unknown"], + ); + }); + + it("rounds derived geometry to 0.1 dot, dropping float tails", () => { + // A diagonal line's bbox comes from cos/sin, so it carries the float tail + // the payload should not (200·cos40°, 200·sin40° + thickness). + const created = createDraft({ + widthMm: 100, heightMm: 50, dpmm: 8, + objects: [{ type: "line", x: 0, y: 0, id: "diag", props: { angle: 40, length: 200, thickness: 4 } }], + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const b = created.bounds[0]; + expect(b?.width).toBe(157.2); + expect(b?.height).toBe(128.6); + }); + + it("validate_zpl uses caller size hints for streams without ^PW/^LL", () => { + const v = ok(validateZpl("^XA^FO10,10^FDX^FS^XZ", 8, 150, 100)); + expect(v.label.widthMm).toBe(150); + expect(v.label.heightMm).toBe(100); + }); + + it("rejects an oversized ZPL stream instead of parsing it", () => { + const huge = "^XA" + "^FO1,1^GB9,9,1^FS".repeat(20000) + "^XZ"; + const v = validateZpl(huge); + expect(v.ok).toBe(false); + if (v.ok) return; + expect(v.errors[0]).toMatch(/limit/); + }); + + it("caps overlaps and flags truncation on a degenerate label", () => { + // 40 boxes all at the origin: 40·39/2 = 780 pairs, over the 500 cap. + const boxes = Array.from({ length: 40 }, (_v, i) => + ({ type: "box" as const, x: 0, y: 0, id: `b${i}`, props: { width: 50, height: 50 } })); + const created = createDraft({ widthMm: 100, heightMm: 50, dpmm: 8, objects: boxes }); + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(created.overlaps.length).toBeLessThanOrEqual(500); + expect(created.geometryTruncated).toBe(true); + }); + + it("skips geometry for a page past the object cap", () => { + const objs = Array.from({ length: 2001 }, (_v, i) => + ({ type: "box" as const, x: i, y: 0, id: `b${i}`, props: { width: 5, height: 5 } })); + const created = createDraft({ widthMm: 400, heightMm: 50, dpmm: 8, objects: objs }); + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(created.bounds).toEqual([]); + expect(created.geometryTruncated).toBe(true); + }); + + it("does not flag truncation for a complete overlap set at the cap", () => { + // 32 boxes all at the origin: 32·31/2 = 496 pairs, just under the 500 cap. + const boxes = Array.from({ length: 32 }, (_v, i) => + ({ type: "box" as const, x: 0, y: 0, id: `b${i}`, props: { width: 50, height: 50 } })); + const created = createDraft({ widthMm: 100, heightMm: 50, dpmm: 8, objects: boxes }); + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(created.overlaps).toHaveLength(496); + expect(created.geometryTruncated).toBeUndefined(); + }); + + it("counts the size cap in bytes, not UTF-16 code units", () => { + // ~200k '€' = 200k code units but ~600k UTF-8 bytes, over the 256 KB cap. + const multibyte = "^XA^FO10,10^FD" + "€".repeat(200_000) + "^FS^XZ"; + expect(multibyte.length).toBeLessThan(256 * 1024); + const v = validateZpl(multibyte); + expect(v.ok).toBe(false); + }); + + it("counts group children against the object cap (no smuggling via subtree)", () => { + const children = Array.from({ length: 10001 }, (_v, i) => textObject(`c${i}`, "x")); + const df = { + schemaVersion: 3, + label: { widthMm: 100, heightMm: 50, dpmm: 8 }, + pages: [{ objects: [{ id: "g", type: "group", x: 0, y: 0, rotation: 0, children }] }], + }; + const r = validateDraft(df); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.errors[0]).toMatch(/object limit/); + }); + + it("rejects a design file past the page limit", () => { + const pages = Array.from({ length: 1001 }, () => ({ objects: [] as never[] })); + const df = { schemaVersion: 3, label: { widthMm: 100, heightMm: 50, dpmm: 8 }, pages }; + const r = validateDraft(df); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.errors[0]).toMatch(/page limit/); + }); + + it("caps object count on the raw-ZPL path too, not just bytes", () => { + // ~10001 tiny fields: well under the 256 KB byte cap but over the object cap. + const raw = "^XA" + "^FO1,1^GB2,2,1^FS".repeat(10001) + "^XZ"; + expect(Buffer.byteLength(raw, "utf8")).toBeLessThan(256 * 1024); + const v = validateZpl(raw); + expect(v.ok).toBe(false); + if (v.ok) return; + expect(v.errors[0]).toMatch(/object limit/); + }); + + it("carries positionType and fieldJustify from create_draft input", () => { + const created = createDraft({ + widthMm: 100, heightMm: 50, dpmm: 8, + objects: [{ + type: "text", x: 700, y: 20, id: "t", + positionType: "FT", fieldJustify: "R", + props: { content: "Right", fontHeight: 30 }, + }], + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const obj = created.designFile.pages[0]?.objects[0] as { positionType?: string; fieldJustify?: string }; + expect(obj.positionType).toBe("FT"); + expect(obj.fieldJustify).toBe("R"); + }); + + it("rejects a multi-^XA stream whose blocks set different ^PW/^LL sizes", () => { + const mixed = "^XA^PW400^LL200^FO10,10^FDA^FS^XZ^XA^PW800^LL400^FO10,10^FDB^FS^XZ"; + const v = validateZpl(mixed); + expect(v.ok).toBe(false); + if (v.ok) return; + expect(v.errors[0]).toMatch(/different \^PW\/\^LL|single-label/); + expect(importZpl(mixed).ok).toBe(false); + }); + + it("accepts multi-^XA blocks that share one explicit size", () => { + const same = "^XA^PW800^LL400^FO10,10^FDA^FS^XZ^XA^FO10,10^FDB^FS^XZ"; + const v = validateZpl(same); + expect(v.ok).toBe(true); + if (!v.ok) return; + expect(v.pageCount).toBe(2); + }); + + it("createDraft rejects an oversized object list before building it", () => { + const objs = Array.from({ length: 10001 }, (_v, i) => + ({ type: "box" as const, x: 0, y: 0, id: `b${i}`, props: { width: 1, height: 1 } })); + const created = createDraft({ widthMm: 100, heightMm: 50, dpmm: 8, objects: objs }); + expect(created.ok).toBe(false); + if (created.ok) return; + expect(created.errors[0]).toMatch(/object limit/); + }); +}); diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts new file mode 100644 index 00000000..a22e2c17 --- /dev/null +++ b/packages/mcp-server/src/tools.ts @@ -0,0 +1,584 @@ +import { z } from "zod"; +import { + parseDesignFile, + serializeDesign, + designFileErrors, + type DesignFile, + type DesignFilePage, +} from "@zplab/core/lib/designFile"; +import { generateMultiPageZPL } from "@zplab/core/lib/zplGenerator"; +import { importZplText, type ZplImportResult } from "@zplab/core/lib/zplImportService"; +import type { ImportReport } from "@zplab/core/lib/zplParser"; +import { computePreflight } from "@zplab/core/lib/preflight"; +import type { BoundingBoxDots } from "@zplab/core/lib/objectBounds"; +import { computeOverlaps, leafBoxesDots, MAX_OVERLAPS, type OverlapDots } from "@zplab/core/lib/objectOverlap"; +import { getEntry, ObjectRegistry } from "@zplab/core/registry"; +import { exportableLeaves, type LabelObject } from "@zplab/core/types/Group"; +import { DPMM_VALUES, isDpmm, type Dpmm, type LabelConfig } from "@zplab/core/types/LabelConfig"; +import type { PreflightKind, PreflightSeverity } from "@zplab/core/lib/preflight"; +import type { Variable } from "@zplab/core/types/Variable"; + +export const objectInputSchema = z.object({ + type: z.string(), + x: z.number(), + y: z.number(), + id: z.string().optional(), + // Anchor semantics: FO = top-left origin, FT = typeset baseline; fieldJustify + // right-aligns. Omitted keeps the model default (FO / left). + positionType: z.enum(["FO", "FT"]).optional(), + fieldJustify: z.enum(["L", "R"]).optional(), + props: z.record(z.string(), z.unknown()).optional(), +}); +export type ObjectInput = z.infer; + +const dpmmSchema = z.literal([...DPMM_VALUES]); + +export const createDraftShape = { + widthMm: z.number().positive(), + heightMm: z.number().positive(), + dpmm: dpmmSchema, + objects: z.array(objectInputSchema), +}; +export interface CreateDraftInput { + widthMm: number; + heightMm: number; + dpmm: Dpmm; + objects: ObjectInput[]; +} + +/** Serialised design as the tools exchange it, identical in shape to + * serializeDesign's output so create → validate → export round-trip. + * serializeDesign stamps the current schemaVersion, so it is not hardcoded. */ +export interface DesignFileJson { + schemaVersion: number; + label: LabelConfig; + pages: DesignFilePage[]; + // serializeDesign omits variables when empty, so this stays optional. + variables?: Variable[]; +} + +export const designFileEnvelopeSchema = z.object({ designFile: z.record(z.string(), z.unknown()) }); + +const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +export interface ToolError { + ok: false; + errors: string[]; +} + +/** Re-checks the dpmm/dimension bounds on a parsed label: the core design-file + * schema is deliberately lenient (app forward-compat), so without this the + * envelope tools would emit broken ZPL from garbage create_draft rejects. */ +function labelConfigIssues(label: LabelConfig): string[] { + const issues: string[] = []; + if (!isDpmm(label.dpmm)) { + issues.push(`dpmm must be one of ${DPMM_VALUES.join(", ")} (got ${label.dpmm})`); + } + if (!(label.widthMm > 0)) issues.push(`widthMm must be positive (got ${label.widthMm})`); + if (!(label.heightMm > 0)) issues.push(`heightMm must be positive (got ${label.heightMm})`); + return issues; +} + +/** Envelope size limits: the raw-ZPL tools cap the input string, so the + * design-file tools cap the parsed shape symmetrically to bound preflight and + * emit on a hostile envelope. Well beyond any real label. */ +const MAX_PAGES = 1000; +const MAX_TOTAL_OBJECTS = 10000; + +/** Shared by the envelope and raw-ZPL paths so both bound object/page count. + * Counts NODES (descending into group children): a top-level count would let + * one group smuggle an unbounded subtree past the cap into preflight. */ +function pagesSizeError(pages: readonly { objects: readonly unknown[] }[]): ToolError | null { + if (pages.length > MAX_PAGES) { + return { ok: false, errors: [`design exceeds the ${MAX_PAGES}-page limit`] }; + } + let total = 0; + const stack: unknown[] = []; + for (const p of pages) for (const o of p.objects) stack.push(o); + while (stack.length > 0) { + const node = stack.pop(); + total++; + if (total > MAX_TOTAL_OBJECTS) { + return { ok: false, errors: [`design exceeds the ${MAX_TOTAL_OBJECTS}-object limit`] }; + } + const children = (node as { children?: unknown })?.children; + if (Array.isArray(children)) for (const c of children) stack.push(c); + } + return null; +} + +/** Parse a caller's design file into a validated DesignFile, mapping schema + * errors and any throw to the shared ToolError shape. */ +function parseEnvelope(designFile: unknown): { ok: true; value: DesignFile } | ToolError { + try { + const parsed = parseDesignFile(JSON.stringify(designFile)); + if (!parsed.ok) return { ok: false, errors: [designFileErrors[parsed.error]] }; + const issues = labelConfigIssues(parsed.value.label); + if (issues.length > 0) return { ok: false, errors: issues }; + const oversize = pagesSizeError(parsed.value.pages); + if (oversize) return oversize; + return { ok: true, value: parsed.value }; + } catch (e) { + return { ok: false, errors: [errMsg(e)] }; + } +} + +/** Merge a caller's sparse object over the registry defaults so an LLM only + * needs to supply the props it wants to change. Unknown type keeps empty + * defaults; the schema is tolerant, so createDraft guards it up front. */ +function toLabelObject(input: ObjectInput, id: string): LabelObject { + const defaults = getEntry(input.type)?.defaultProps ?? {}; + return { + id, + type: input.type, + x: input.x, + y: input.y, + rotation: 0, + ...(input.positionType !== undefined ? { positionType: input.positionType } : {}), + ...(input.fieldJustify !== undefined ? { fieldJustify: input.fieldJustify } : {}), + props: { ...defaults, ...(input.props ?? {}) }, + // Schema is intentionally loose; createDraft rejects unknown types up front. + } as LabelObject; +} + +/** Assign every object its id: reject duplicate explicit ids, and skip + * auto-generated ids that collide with ids already taken in this draft. */ +function buildObjects(inputs: ObjectInput[]): { objects: LabelObject[] } | { error: string } { + const explicit = inputs.flatMap((o) => (o.id !== undefined ? [o.id] : [])); + const seen = new Set(); + const dupes = new Set(); + for (const id of explicit) { + if (seen.has(id)) dupes.add(id); + seen.add(id); + } + if (dupes.size > 0) return { error: `Duplicate object id(s): ${[...dupes].join(", ")}` }; + const taken = new Set(explicit); + let counter = 0; + const objects = inputs.map((o) => { + let id = o.id; + if (id === undefined) { + do { + id = `${o.type}-${++counter}`; + } while (taken.has(id)); + taken.add(id); + } + return toLabelObject(o, id); + }); + return { objects }; +} + +export interface PreflightWarning { + pageIndex: number; + objectId: string; + kind: PreflightKind; + severity: PreflightSeverity; + detail?: string; +} + +interface PageLike { + objects: LabelObject[]; +} + +/** Run a per-page report over every page of a design. */ +function perPage( + pages: PageLike[], + label: LabelConfig, + fn: (objects: LabelObject[], label: LabelConfig, pageIndex: number) => T[], +): T[] { + return pages.flatMap((page, i) => fn(page.objects, label, i)); +} + +function preflightOf( + objects: LabelObject[], + label: LabelConfig, + pageIndex: number, +): PreflightWarning[] { + return computePreflight(exportableLeaves(objects), { label }, "mm").map((f) => ({ + pageIndex, + objectId: f.objectId, + kind: f.kind, + severity: f.severity, + ...(f.detail !== undefined ? { detail: f.detail } : {}), + })); +} + +/** Per-object geometry so the agent can reason about size/placement without + * recomputing it. Dots, visual top-left. `approx` marks headless estimates + * (barcode footprints and single-line text), not render-exact bounds. */ +export interface ObjectBounds extends BoundingBoxDots { + pageIndex: number; + objectId: string; + approx: boolean; +} + +/** Axis-aligned bbox intersections. Neutral facts, not errors: a frame/reverse + * box overlaps its contents by design, so the agent judges relevance. */ +export interface ObjectOverlap extends OverlapDots { + pageIndex: number; +} + +interface Geometry { + bounds: ObjectBounds[]; + overlaps: ObjectOverlap[]; + /** Set when a page was too dense to report fully (see the caps below), so the + * agent knows the geometry is partial rather than assuming a clean label. */ + geometryTruncated?: boolean; +} + +/** Per-page object cap for geometry. The overlap scan is O(n²); past this a + * per-object report is noise anyway, so skip the page's geometry. Well beyond + * any real label. */ +const MAX_GEOMETRY_OBJECTS = 2000; + +/** 0.1-dot precision: parser coordinates carry float tails ("613.37336627…") + * that are sub-dot noise in the agent-facing payload. */ +const dot1 = (n: number): number => Math.round(n * 10) / 10; +const roundRect = (r: BoundingBoxDots): BoundingBoxDots => ({ + x: dot1(r.x), + y: dot1(r.y), + width: dot1(r.width), + height: dot1(r.height), +}); + +/** One box pass per page feeds both reports, so they cannot diverge. Bounded: + * dense pages skip geometry, and overlaps are capped, to keep the payload and + * the O(n²) scan finite on adversarial input. */ +function geometryFor(pages: PageLike[], label: LabelConfig): Geometry { + const bounds: ObjectBounds[] = []; + const overlaps: ObjectOverlap[] = []; + let truncated = false; + pages.forEach((page, pageIndex) => { + const leaves = exportableLeaves(page.objects); + if (leaves.length > MAX_GEOMETRY_OBJECTS) { + truncated = true; + return; + } + const boxes = leafBoxesDots(leaves, { label }); + for (const b of boxes) { + bounds.push({ pageIndex, objectId: b.id, ...roundRect(b.box), approx: b.approx }); + } + // Over-scan by one so a complete set of exactly MAX_OVERLAPS is not + // mistaken for a capped one; keep only MAX_OVERLAPS. + const scanned = computeOverlaps(boxes, MAX_OVERLAPS + 1); + if (scanned.length > MAX_OVERLAPS) truncated = true; + for (const o of scanned.slice(0, MAX_OVERLAPS)) { + overlaps.push({ ...o, pageIndex, ...roundRect(o) }); + } + }); + return truncated ? { bounds, overlaps, geometryTruncated: true } : { bounds, overlaps }; +} + +export type CreateDraftResult = + | { + ok: true; + designFile: DesignFileJson; + warnings: PreflightWarning[]; + bounds: ObjectBounds[]; + overlaps: ObjectOverlap[]; + geometryTruncated?: boolean; + } + | ToolError; + +export function createDraft(input: CreateDraftInput): CreateDraftResult { + const tooMany = pagesSizeError([{ objects: input.objects }]); + if (tooMany) return tooMany; + const unknown = input.objects.filter((o) => getEntry(o.type) === undefined).map((o) => o.type); + if (unknown.length > 0) { + return { ok: false, errors: [`Unknown object type(s): ${[...new Set(unknown)].join(", ")}`] }; + } + const built = buildObjects(input.objects); + if ("error" in built) return { ok: false, errors: [built.error] }; + const label: LabelConfig = { widthMm: input.widthMm, heightMm: input.heightMm, dpmm: input.dpmm }; + const serialized = serializeDesign(label, [{ objects: built.objects }]); + const designFile = JSON.parse(serialized) as DesignFileJson; + const parsed = parseEnvelope(designFile); + if (!parsed.ok) return parsed; + return { + ok: true, + designFile, + warnings: perPage(parsed.value.pages, label, preflightOf), + ...geometryFor(parsed.value.pages, label), + }; +} + +export type ValidateDraftResult = + | { + ok: true; + warnings: PreflightWarning[]; + bounds: ObjectBounds[]; + overlaps: ObjectOverlap[]; + geometryTruncated?: boolean; + } + | ToolError; + +export function validateDraft(designFile: unknown): ValidateDraftResult { + const parsed = parseEnvelope(designFile); + if (!parsed.ok) return parsed; + const { label, pages } = parsed.value; + return { + ok: true, + warnings: perPage(pages, label, preflightOf), + ...geometryFor(pages, label), + }; +} + +export type OpenInAppResult = { ok: true; line: string } | ToolError; + +/** Validate the design file and, on success, build the newline-delimited event + * line the desktop app reads off this child's stdout to open the draft. */ +export function openInApp(designFile: unknown): OpenInAppResult { + const parsed = parseEnvelope(designFile); + if (!parsed.ok) return parsed; + return { ok: true, line: JSON.stringify({ zplabEvent: "openDraft", designFile }) }; +} + +export type ExportZplResult = { ok: true; zpl: string } | ToolError; + +export function exportZpl(designFile: unknown): ExportZplResult { + const parsed = parseEnvelope(designFile); + if (!parsed.ok) return parsed; + const { label, pages, variables } = parsed.value; + // Same path as the app's export: per-page emit replays captured overlays. + return { ok: true, zpl: generateMultiPageZPL(label, pages, variables) }; +} + +// ── Raw-ZPL input: parse a ZPL stream back into the editable model, so the +// agent can bring/write ZPL and get it validated + turned into a draft. + +/** Label size a raw ZPL stream that omits ^PW/^LL falls back to. */ +const DEFAULT_WIDTH_MM = 100; +const DEFAULT_HEIGHT_MM = 50; + +/** Byte budget for a raw ZPL stream. Import retains overlay bytes and + * re-serializes, so an oversized stream costs several copies plus O(n²) + * geometry; real labels are far under this. */ +const MAX_ZPL_BYTES = 256 * 1024; + +function oversizeError(zpl: string): ToolError | null { + // Byte length, not zpl.length: multibyte ^FD content is up to ~4x the code + // unit count, which would slip past the memory budget the cap protects. + return Buffer.byteLength(zpl, "utf8") > MAX_ZPL_BYTES + ? { ok: false, errors: [`ZPL exceeds the ${MAX_ZPL_BYTES}-byte limit`] } + : null; +} + +/** Reject a parsed stream the single-label draft model can't represent: + * divergent per-block ^PW/^LL, or too many objects/pages. */ +function importRejection(imported: ZplImportResult): ToolError | null { + if (imported.mixedPageGeometry) { + return { + ok: false, + errors: [ + "^XA blocks set different ^PW/^LL sizes, which a single-label draft cannot " + + "represent; split the stream into one label per size.", + ], + }; + } + return pagesSizeError(imported.pages); +} + +export const zplInputShape = { + zpl: z.string(), + dpmm: z.optional(z.literal([...DPMM_VALUES])), + widthMm: z.optional(z.number().positive()), + heightMm: z.optional(z.number().positive()), +}; + +/** The report's deduped command buckets, so the agent learns which commands + * were dropped, lossy, or hardware-bound. Drops the per-occurrence findings + * (an app-UI navigation aid). */ +export type ZplFindings = Omit; + +const findingsOf = ({ findings: _drop, ...buckets }: ImportReport): ZplFindings => buckets; + +/** Complete the parsed (partial) label: the stream's own dpmm/size wins, then + * the caller's hints, then the fallback size. */ +function completeLabel( + parsed: Partial, + dpmm: Dpmm, + widthMm?: number, + heightMm?: number, +): LabelConfig { + return { + ...parsed, + dpmm: parsed.dpmm ?? dpmm, + widthMm: parsed.widthMm ?? widthMm ?? DEFAULT_WIDTH_MM, + heightMm: parsed.heightMm ?? heightMm ?? DEFAULT_HEIGHT_MM, + }; +} + +export type ValidateZplResult = + | { + ok: true; + objectCount: number; + pageCount: number; + label: LabelConfig; + findings: ZplFindings; + warnings: PreflightWarning[]; + bounds: ObjectBounds[]; + overlaps: ObjectOverlap[]; + geometryTruncated?: boolean; + } + | ToolError; + +/** Parse raw ZPL and report it: how many objects/pages came back, the detected + * label, the parser's findings, and preflight warnings. A lint pass, not a + * draft. Shares the app's import path, so multi-^XA streams report per page. */ +export function validateZpl( + zpl: string, + dpmm: Dpmm = 8, + widthMm?: number, + heightMm?: number, +): ValidateZplResult { + const oversize = oversizeError(zpl); + if (oversize) return oversize; + const imported = importZplText(zpl, dpmm); + const rejected = importRejection(imported); + if (rejected) return rejected; + const label = completeLabel(imported.labelConfig, dpmm, widthMm, heightMm); + return { + ok: true, + objectCount: imported.pages.reduce((n, p) => n + p.objects.length, 0), + pageCount: imported.pages.length, + label, + findings: findingsOf(imported.report), + warnings: perPage(imported.pages, label, preflightOf), + ...geometryFor(imported.pages, label), + }; +} + +export type ImportZplResult = + | { + ok: true; + designFile: DesignFileJson; + findings: ZplFindings; + warnings: PreflightWarning[]; + bounds: ObjectBounds[]; + overlaps: ObjectOverlap[]; + geometryTruncated?: boolean; + } + | ToolError; + +/** Parse raw ZPL into an editable design file (ready for export_zpl/open_in_app) + * plus the parser's findings. Shares the app's import path: one page per ^XA + * block, captured overlays so re-export replays unmodeled commands verbatim. */ +export function importZpl( + zpl: string, + dpmm: Dpmm = 8, + widthMm?: number, + heightMm?: number, +): ImportZplResult { + const oversize = oversizeError(zpl); + if (oversize) return oversize; + const imported = importZplText(zpl, dpmm); + const rejected = importRejection(imported); + if (rejected) return rejected; + const label = completeLabel(imported.labelConfig, dpmm, widthMm, heightMm); + const serialized = serializeDesign(label, imported.pages, imported.variables); + return { + ok: true, + designFile: JSON.parse(serialized) as DesignFileJson, + findings: findingsOf(imported.report), + warnings: perPage(imported.pages, label, preflightOf), + ...geometryFor(imported.pages, label), + }; +} + +/** Hand-written prop summaries for the types an LLM reaches for first. Every + * other registered type is listed by name + defaults from the registry. */ +const PROP_SUMMARIES: Record> = { + text: { + content: "string, the printed text", + fontHeight: "dots, glyph height", + fontWidth: "dots, 0 = auto from height", + rotation: "N | R | I | B (0/90/180/270)", + }, + code128: { + content: "string payload", + height: "bar height in dots", + moduleWidth: "narrow-bar width in dots", + printInterpretation: "boolean, show human-readable text", + checkDigit: "boolean", + rotation: "N | R | I | B", + gs1: "boolean, GS1-128 mode", + }, + qrcode: { + content: "string payload", + magnification: "module size 1-10", + errorCorrection: "L | M | Q | H", + model: "1 | 2", + rotation: "N | R | I | B", + }, + box: { + width: "dots", + height: "dots", + thickness: "border dots", + filled: "boolean", + color: "B | W", + rounding: "corner rounding 0-8", + }, + line: { + angle: "degrees", + length: "dots", + thickness: "dots", + color: "B | W", + }, + ean13: { + content: "12 digits (check digit computed)", + height: "bar height in dots", + printInterpretation: "boolean", + rotation: "N | R | I | B", + }, + datamatrix: { + content: "string payload", + dimension: "module size", + quality: "ECC level (200 = ECC200)", + rotation: "N | R | I | B", + gs1: "boolean, GS1 mode", + }, +}; + +export interface SchemaObjectType { + type: string; + label: string; + defaultProps: Record; + props?: Record; +} + +export interface SchemaResult { + note: string; + objectShape: Record; + types: SchemaObjectType[]; +} + +const SCHEMA: SchemaResult = { + note: + "Each object needs { type, x, y, props }. x/y are dots from the label origin; " + + "id is optional. Props merge over the type's defaultProps, so only supply " + + "overrides. Leaf orientation is props.rotation (N | R | I | B for 0/90/180/270), " + + "not a top-level field. Documented prop summaries cover the common types; the " + + "rest are described by their defaultProps.", + objectShape: { + type: "one of the registered types below", + x: "number, dots from left", + y: "number, dots from top", + positionType: "optional FO (top-left origin, default) | FT (typeset baseline)", + fieldJustify: "optional L (default) | R (right-align)", + props: "object, merged over defaultProps", + }, + types: (Object.keys(ObjectRegistry) as (keyof typeof ObjectRegistry)[]).map((type) => { + const entry = ObjectRegistry[type]; + const out: SchemaObjectType = { + type: String(type), + label: entry.label, + defaultProps: entry.defaultProps as Record, + }; + const summary = PROP_SUMMARIES[String(type)]; + if (summary) out.props = summary; + return out; + }), +}; + +export function getSchema(): SchemaResult { + return SCHEMA; +} diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 00000000..a4ef5e7b --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.app.json", + "compilerOptions": { + // DOM lib inherited from tsconfig.app.json: @zplab/core's type graph + // references DOM types (HTMLImageElement, Canvas, document, FontFace), so a + // Node-only lib fails to type-check even though this server never uses them. + "types": ["node"] + }, + "include": ["src"] +} From 37f7e34419a1423e8036932e22a8e7f64a3b02ae Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 18 Jul 2026 21:28:16 +0200 Subject: [PATCH 5/5] feat(desktop): MCP sidecar lifecycle, settings tab and open-draft bridge --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 15 +- src-tauri/src/lib.rs | 44 +- src-tauri/src/mcp.rs | 436 ++++++++++++++++++ src/components/Output/ImportSummary.tsx | 5 +- .../PrinterSettings/McpServerTab.test.tsx | 100 ++++ .../PrinterSettings/McpServerTab.tsx | 95 ++++ .../PrinterSettings/PrinterSettingsModal.tsx | 17 +- .../PrinterSettings/tabVisibility.ts | 12 + src/hooks/useDesignFileActions.ts | 31 +- src/hooks/useMcpServer.ts | 138 ++++++ src/lib/importReport.ts | 58 +-- src/lib/mcpServer.test.ts | 23 + src/lib/mcpServer.ts | 51 ++ src/locales/ar.ts | 14 + src/locales/bg.ts | 14 + src/locales/cs.ts | 14 + src/locales/da.ts | 14 + src/locales/de.ts | 14 + src/locales/el.ts | 14 + src/locales/en.ts | 14 + src/locales/es.ts | 14 + src/locales/et.ts | 14 + src/locales/fa.ts | 14 + src/locales/fi.ts | 14 + src/locales/fr.ts | 14 + src/locales/he.ts | 14 + src/locales/hr.ts | 14 + src/locales/hu.ts | 14 + src/locales/it.ts | 14 + src/locales/ja.ts | 14 + src/locales/ko.ts | 14 + src/locales/lt.ts | 14 + src/locales/lv.ts | 14 + src/locales/nl.ts | 14 + src/locales/no.ts | 14 + src/locales/pl.ts | 14 + src/locales/pt.ts | 14 + src/locales/ro.ts | 14 + src/locales/sk.ts | 14 + src/locales/sl.ts | 14 + src/locales/sr.ts | 14 + src/locales/sv.ts | 14 + src/locales/tr.ts | 14 + src/locales/zh-hans.ts | 14 + src/locales/zh-hant.ts | 14 + src/main.tsx | 21 +- src/store/labelStore.tripwire.test.ts | 3 + src/store/labelStore.ts | 3 + src/store/slices/uiSlice.ts | 45 ++ 50 files changed, 1472 insertions(+), 74 deletions(-) create mode 100644 src-tauri/src/mcp.rs create mode 100644 src/components/PrinterSettings/McpServerTab.test.tsx create mode 100644 src/components/PrinterSettings/McpServerTab.tsx create mode 100644 src/components/PrinterSettings/tabVisibility.ts create mode 100644 src/hooks/useMcpServer.ts create mode 100644 src/lib/mcpServer.test.ts create mode 100644 src/lib/mcpServer.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3aba9341..b035e9ff 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5404,6 +5404,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "tokio", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dd4e930f..476f6b60 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,11 +28,20 @@ printers = "2.3.0" # Keychain, Linux DBus Secret Service. Linux CI needs libdbus-1-dev. keyring = { version = "3", features = ["windows-native", "apple-native", "sync-secret-service"] } -[target.'cfg(target_os = "linux")'.dependencies] -# O_NONBLOCK for the usblp read-back fd (AsyncFd needs a non-blocking fd). -# Already transitive via printers, so this pins no new code. +[target.'cfg(unix)'.dependencies] +# Linux: O_NONBLOCK for the usblp read-back fd (AsyncFd needs a non-blocking +# fd). All unix: killpg to SIGKILL the MCP child's process group. Already +# transitive via printers, so this pins no new code. libc = "0.2" +[target.'cfg(windows)'.dependencies] +# Kill-on-close Job Object so the MCP child's cmd/pnpm/node tree dies with the +# app, including on crash (the OS closes the handle on process exit). +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_JobObjects", +] } + [target.'cfg(target_os = "macos")'.dependencies] # Direct USB printer IO: macOS has no usblp equivalent, so list/send/query go # through IOKit via this pure-Rust stack (no libusb to link). The tokio feature diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d7608f8c..8e74f328 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,23 +1,31 @@ mod credentials; +mod mcp; mod print; mod transport; mod usb; +use tauri::Manager; + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let builder = tauri::Builder::default().invoke_handler(tauri::generate_handler![ - print::send_zpl_tcp, - print::query_zpl_tcp, - print::list_printers, - print::send_zpl_local, - usb::list_usb_printers, - usb::send_zpl_usb, - usb::query_zpl_usb, - usb::setup_usb_access, - credentials::credential_get, - credentials::credential_set, - credentials::credential_delete - ]); + let builder = tauri::Builder::default() + .manage(mcp::McpState::default()) + .invoke_handler(tauri::generate_handler![ + print::send_zpl_tcp, + print::query_zpl_tcp, + print::list_printers, + print::send_zpl_local, + usb::list_usb_printers, + usb::send_zpl_usb, + usb::query_zpl_usb, + usb::setup_usb_access, + credentials::credential_get, + credentials::credential_set, + credentials::credential_delete, + mcp::mcp_start, + mcp::mcp_stop, + mcp::mcp_status + ]); // On the builder, not in setup(): config windows exist before the setup // closure runs, and window-state only restores/tracks via on_window_ready. #[cfg(desktop)] @@ -29,6 +37,12 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()); builder - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app, event| { + // Kill the MCP child on exit so it never outlives the app window. + if let tauri::RunEvent::Exit = event { + app.state::().kill(); + } + }); } diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs new file mode 100644 index 00000000..6429bf07 --- /dev/null +++ b/src-tauri/src/mcp.rs @@ -0,0 +1,436 @@ +//! Lifecycle for the loopback MCP server child process: a bearer-authenticated +//! HTTP MCP server the desktop app can toggle. These commands spawn, stop, and +//! probe it, and the app kills it on exit so it never outlives the window. + +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::time::Duration; +use tauri::{AppHandle, Emitter, State}; +use tokio::sync::oneshot; + +/// Tauri event carrying an openDraft design file to the editor. +const OPEN_DRAFT_EVENT: &str = "mcp://open-draft"; + +/// Windows: kill-on-close Job Object that ties the child's cmd/pnpm/node tree +/// to the app. Closing the handle (explicitly, or when the process dies and the +/// OS closes it) terminates the whole tree, so grandchildren never orphan. +#[cfg(windows)] +mod job { + use std::os::windows::io::AsRawHandle; + use std::process::Child; + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + + /// Job handle stored as isize so McpState stays Send (a raw HANDLE pointer is + /// not). We only ever hand it back to Win32, never dereference it. + pub struct Job(isize); + + // SAFETY: a Win32 job handle is a process-global kernel object, safe to move + // and close from any thread; the pointer is never dereferenced. + unsafe impl Send for Job {} + + impl Drop for Job { + /// Closing the kill-on-close handle terminates any process still in the job. + fn drop(&mut self) { + // SAFETY: self.0 is the sole owner of a live job handle, closed once here. + unsafe { CloseHandle(self.0 as HANDLE) }; + } + } + + /// Create a kill-on-close job and assign `child` to it. None if any Win32 call + /// fails, in which case the caller falls back to a plain child kill. + pub fn assign(child: &Child) -> Option { + // SAFETY: CreateJobObjectW returns a valid handle we own until CloseHandle + // (via Job's Drop). `info` is a fully-zeroed, correctly-sized struct and we + // pass its matching byte length. child.as_raw_handle() is a live process + // handle owned by `child`, valid for the duration of this call. + unsafe { + let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if handle.is_null() { + return None; + } + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let set = SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + std::ptr::addr_of!(info).cast::(), + std::mem::size_of::() as u32, + ); + let assigned = AssignProcessToJobObject(handle, child.as_raw_handle() as HANDLE); + if set == 0 || assigned == 0 { + CloseHandle(handle); + return None; + } + Some(Job(handle as isize)) + } + } +} + +/// Liveness of the stored child, distinguishing "gone" (concurrently stopped) +/// from "crashed" so startup can report the right failure. +enum ChildStatus { + Alive, + Exited, + Absent, +} + +/// Managed handle to the running server child, if any. +#[derive(Default)] +pub struct McpState { + child: Mutex>, + /// Held for the whole of mcp_start so concurrent starts serialize instead of + /// racing the TOCTOU is_running() check. + start_lock: tokio::sync::Mutex<()>, + #[cfg(windows)] + job: Mutex>, +} + +impl McpState { + /// Kill and reap the child if one is running. Idempotent. + pub fn kill(&self) { + if let Some(mut child) = self.child.lock().unwrap().take() { + #[cfg(unix)] + { + // The child leads its own process group (process_group(0)); signal the + // group so pnpm/node grandchildren die with it, not just the shell. + unsafe { libc::killpg(child.id() as libc::pid_t, libc::SIGKILL) }; + } + let _ = child.kill(); + let _ = child.wait(); + } + // Drop the job after the child: closing its kill-on-close handle terminates + // any grandchildren that survived the direct kill. take()->drop is a no-op + // when already cleared, so kill stays idempotent. + #[cfg(windows)] + drop(self.job.lock().unwrap().take()); + } + + /// True while the child is alive. Reaps and clears it if it has exited, so + /// status reflects a server that crashed instead of a stale handle. + fn is_running(&self) -> bool { + let mut guard = self.child.lock().unwrap(); + match guard.as_mut() { + Some(child) => match child.try_wait() { + Ok(Some(_)) => { + *guard = None; + false + } + _ => true, + }, + None => false, + } + } + + fn child_status(&self) -> ChildStatus { + let mut guard = self.child.lock().unwrap(); + match guard.as_mut() { + None => ChildStatus::Absent, + Some(child) => match child.try_wait() { + Ok(Some(_)) => ChildStatus::Exited, + _ => ChildStatus::Alive, + }, + } + } +} + +/// How long to await the child's `listening` signal before declaring the spawn +/// a failure. +const READY_TIMEOUT: Duration = Duration::from_secs(2); + +#[cfg(windows)] +fn suppress_console(cmd: &mut Command) { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(windows))] +fn suppress_console(_cmd: &mut Command) {} + +/// Whether this build can spawn the sidecar; the capability mcp_status reports +/// so the UI can hide the controls instead of offering a server that cannot +/// start. Must flip together with mcp_command when bundling lands. +pub const SIDECAR_AVAILABLE: bool = cfg!(debug_assertions); + +/// The child that serves the MCP HTTP transport. Dev runs the workspace package +/// from source; release fails honestly until the sidecar binary is bundled. +#[cfg(debug_assertions)] +fn mcp_command(port: u16, token: &str) -> Result { + // cwd is the repo root (src-tauri's parent) so the pnpm workspace filter + // resolves the package. + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .ok_or("cannot resolve repo root")?; + // cmd /C so CreateProcess finds corepack's pnpm.cmd shim (no pnpm.exe). + #[cfg(windows)] + let mut cmd = { + let mut c = Command::new("cmd"); + c.args(["/C", "pnpm"]); + c + }; + #[cfg(not(windows))] + let mut cmd = Command::new("pnpm"); + cmd.current_dir(repo_root).args([ + "--filter", + "@zplab/mcp-server", + "exec", + "tsx", + "src/index.ts", + "--http", + "--port", + &port.to_string(), + "--token", + token, + ]); + Ok(cmd) +} + +#[cfg(not(debug_assertions))] +fn mcp_command(_port: u16, _token: &str) -> Result { + // Bundling the sidecar binary is a deferred follow-up; fail instead of + // spawning a dev toolchain that release machines do not have. Stable code the + // frontend maps to a localized message. + Err("sidecar_not_bundled".to_string()) +} + +/// Extract the design file payload from a child stdout line, or None if the +/// line is not an openDraft event (dev logging, partial output). The child +/// emits these lines only in HTTP mode, where stdout is not the JSON-RPC channel. +fn open_draft_payload(line: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + if value.get("zplabEvent")?.as_str()? != "openDraft" { + return None; + } + Some(value.get("designFile")?.to_string()) +} + +/// True for the child's one-shot `{"zplabEvent":"listening"}` line, emitted once +/// the HTTP server has bound its port. +fn is_listening_event(line: &str) -> bool { + serde_json::from_str::(line) + .ok() + .and_then(|v| v.get("zplabEvent")?.as_str().map(|s| s == "listening")) + .unwrap_or(false) +} + +/// Signal readiness on the child's first `listening` stdout line, then forward +/// openDraft events. Ends when stdout closes; dropping an unused `ready` sender +/// then makes wait_until_ready observe the child as gone. +fn forward_open_draft( + stdout: std::process::ChildStdout, + app: AppHandle, + ready: oneshot::Sender<()>, +) { + use std::io::BufRead; + let mut ready = Some(ready); + for line in std::io::BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + if is_listening_event(&line) { + if let Some(tx) = ready.take() { + let _ = tx.send(()); + } + continue; + } + if let Some(payload) = open_draft_payload(&line) { + let _ = app.emit(OPEN_DRAFT_EVENT, payload); + } + } +} + +/// Await the child's own `listening` signal: only OUR child bound the port (a +/// foreign listener can't forge it), and a child dying first drops the sender, +/// surfacing as "exited during startup" rather than a timeout. +async fn wait_until_ready(ready: oneshot::Receiver<()>) -> Result<(), String> { + match tokio::time::timeout(READY_TIMEOUT, ready).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => Err("mcp server exited during startup (port in use?)".to_string()), + Err(_) => Err("mcp server did not start within timeout".to_string()), + } +} + +/// Start the MCP server on `port` with bearer `token`; a no-op if already +/// running. Waits for the child's `listening` signal, killing the child and +/// returning an error on timeout. The token is never logged. +#[tauri::command] +pub async fn mcp_start( + app: AppHandle, + state: State<'_, McpState>, + port: u16, + token: String, +) -> Result<(), String> { + // Serialize starts: a second concurrent call waits, then sees is_running(). + let _start = state.start_lock.lock().await; + if state.is_running() { + return Ok(()); + } + let mut cmd = mcp_command(port, &token)?; + suppress_console(&mut cmd); + // Pipe stdout for the openDraft event channel; stderr stays inherited. + cmd.stdout(Stdio::piped()); + #[cfg(unix)] + { + // Own process group so kill() can SIGKILL the whole pnpm/node tree. + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + let mut child = cmd + .spawn() + .map_err(|e| format!("failed to spawn mcp server: {e}"))?; + // Known micro-race: a grandchild forked between spawn and AssignProcessToJobObject + // escapes the job's kill-on-close. The canonical fix (CREATE_SUSPENDED + ResumeThread) + // needs the primary-thread handle std::process::Child does not expose. + #[cfg(windows)] + { + *state.job.lock().unwrap() = job::assign(&child); + } + let (ready_tx, ready_rx) = oneshot::channel(); + if let Some(stdout) = child.stdout.take() { + std::thread::spawn(move || forward_open_draft(stdout, app, ready_tx)); + } + // Store before the wait so a teardown mid-startup still reaps the child. + *state.child.lock().unwrap() = Some(child); + match wait_until_ready(ready_rx).await { + // A concurrent mcp_stop can take our child while we waited; only report + // success if it is still ours and alive. + Ok(()) if matches!(state.child_status(), ChildStatus::Alive) => Ok(()), + // Also clears the job handle on this path so it is not leaked. + Ok(()) => { + state.kill(); + Err("stopped during startup".to_string()) + } + Err(e) => { + state.kill(); + Err(e) + } + } +} + +#[tauri::command] +pub fn mcp_stop(state: State<'_, McpState>) { + state.kill(); +} + +#[derive(serde::Serialize)] +pub struct McpStatus { + pub running: bool, + pub available: bool, +} + +#[tauri::command] +pub fn mcp_status(state: State<'_, McpState>) -> McpStatus { + McpStatus { + running: state.is_running(), + available: SIDECAR_AVAILABLE, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_serializes_running_and_available_flags() { + let json = serde_json::to_string(&McpStatus { running: true, available: false }).unwrap(); + assert_eq!(json, r#"{"running":true,"available":false}"#); + } + + #[test] + fn empty_state_reports_not_running() { + let state = McpState::default(); + assert!(!state.is_running()); + // kill is a no-op when nothing runs. + state.kill(); + assert!(!state.is_running()); + } + + #[test] + fn open_draft_payload_extracts_design_file() { + let line = r#"{"zplabEvent":"openDraft","designFile":{"schemaVersion":3}}"#; + assert_eq!( + open_draft_payload(line), + Some(r#"{"schemaVersion":3}"#.to_string()) + ); + } + + #[test] + fn open_draft_payload_ignores_non_events() { + assert_eq!(open_draft_payload("plain dev log line"), None); + assert_eq!(open_draft_payload(r#"{"other":"json"}"#), None); + assert_eq!( + open_draft_payload(r#"{"zplabEvent":"openDraft"}"#), + None, + "missing designFile is not forwardable" + ); + } + + #[test] + fn is_listening_event_matches_only_the_listening_line() { + assert!(is_listening_event( + r#"{"zplabEvent":"listening","port":4923}"# + )); + assert!(!is_listening_event(r#"{"zplabEvent":"openDraft"}"#)); + assert!(!is_listening_event("plain dev log line")); + } + + #[test] + fn wait_until_ready_succeeds_on_signal() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let (tx, rx) = oneshot::channel(); + tx.send(()).unwrap(); + assert!(wait_until_ready(rx).await.is_ok()); + }); + } + + #[test] + fn wait_until_ready_reports_exit_when_sender_dropped() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + // Sender dropped without a value = child's stdout closed = child gone. + let (tx, rx) = oneshot::channel::<()>(); + drop(tx); + let err = wait_until_ready(rx).await.unwrap_err(); + assert_eq!(err, "mcp server exited during startup (port in use?)"); + }); + } + + #[test] + fn wait_until_ready_times_out_when_never_signaled() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + // Hold the sender so it neither signals nor drops: only the timeout fires. + let (_tx, rx) = oneshot::channel::<()>(); + let err = wait_until_ready(rx).await.unwrap_err(); + assert_eq!(err, "mcp server did not start within timeout"); + }); + } + + #[test] + fn start_lock_serializes_holders() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let state = McpState::default(); + let held = state.start_lock.lock().await; + assert!(state.start_lock.try_lock().is_err()); + drop(held); + assert!(state.start_lock.try_lock().is_ok()); + }); + } +} diff --git a/src/components/Output/ImportSummary.tsx b/src/components/Output/ImportSummary.tsx index b99ade14..7a98a89f 100644 --- a/src/components/Output/ImportSummary.tsx +++ b/src/components/Output/ImportSummary.tsx @@ -1,5 +1,6 @@ import { describeFinding } from '../../lib/importReport'; -import type { ImportFinding, ImportFindingKind, ImportResult } from '../../lib/importReport'; +import type { ImportResult } from '../../lib/importReport'; +import type { ImportFinding, ImportFindingKind } from '@zplab/core/lib/importReport'; export type { ImportResult }; @@ -20,6 +21,8 @@ const TONE: Record = { fnRenumbered: 'text-muted', // Higher severity: a page's divergent default was discarded (99 slots full). fnDefaultDropped: 'text-red-400', + // Higher severity: later pages render at the wrong size (single-label model). + mixedPageGeometry: 'text-red-400', }; export function FindingRow({ finding, showPage }: { finding: ImportFinding; showPage: boolean }) { diff --git a/src/components/PrinterSettings/McpServerTab.test.tsx b/src/components/PrinterSettings/McpServerTab.test.tsx new file mode 100644 index 00000000..0ffe0e11 --- /dev/null +++ b/src/components/PrinterSettings/McpServerTab.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, cleanup, act, renderHook } from "@testing-library/react"; +import { TAB_GATES } from "./tabVisibility"; + +// The hook pings the sidecar on mount; the mock keeps jsdom off real Tauri +// invokes. The modal (TAB_GATES) decides visibility, so the tab itself is +// only ever mounted when the build can spawn the sidecar. +const mcpServerStatus = vi.fn(async () => ({ running: false, available: true })); +const startMcpServer = vi.fn(async () => undefined); +const stopMcpServer = vi.fn(async () => undefined); +vi.mock("../../lib/mcpServer", () => ({ + mcpServerStatus: (...args: unknown[]) => mcpServerStatus(...(args as [])), + startMcpServer: (...args: unknown[]) => startMcpServer(...(args as [])), + stopMcpServer: (...args: unknown[]) => stopMcpServer(...(args as [])), + mcpConfigSnippet: vi.fn(() => "{}"), + generateMcpToken: vi.fn(() => "tok"), +})); + +import { McpServerTab } from "./McpServerTab"; +import { useMcpAvailability, useMcpServer } from "../../hooks/useMcpServer"; +import { useLabelStore } from "../../store/labelStore"; + +afterEach(cleanup); +beforeEach(() => { + mcpServerStatus.mockClear(); + startMcpServer.mockReset(); + startMcpServer.mockResolvedValue(undefined); + stopMcpServer.mockReset(); + stopMcpServer.mockResolvedValue(undefined); + useLabelStore.setState({ mcpSidecarAvailable: null, mcpServerEnabled: false }); +}); + +describe("McpServerTab", () => { + it("renders the server controls when mounted", async () => { + const { container } = render(); + await act(() => Promise.resolve()); + expect(container.querySelector("input[type=checkbox]")).not.toBeNull(); + }); +}); + +describe("mcpServer tab gate", () => { + it("is visible only when the build can spawn the sidecar", () => { + const gate = TAB_GATES.mcpServer; + expect(gate?.({ mcpSidecarAvailable: true })).toBe(true); + // Web/unbundled release report false; boot ping not yet resolved is null. + expect(gate?.({ mcpSidecarAvailable: false })).toBe(false); + expect(gate?.({ mcpSidecarAvailable: null })).toBe(false); + }); +}); + +describe("useMcpServer toggle race", () => { + const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + + it("skips the superseded stop so on→off→on ends running, not stopped", async () => { + useLabelStore.setState({ mcpSidecarAvailable: true, mcpServerEnabled: false }); + const resolvers: (() => void)[] = []; + startMcpServer.mockImplementation( + () => new Promise((r) => resolvers.push(() => r(undefined))), + ); + stopMcpServer.mockImplementation( + () => new Promise((r) => resolvers.push(() => r(undefined))), + ); + + const { result } = renderHook(() => useMcpServer()); + await flush(); // mount reconcile (enabled=false → returns before starting) + + act(() => result.current.toggle(true)); + act(() => result.current.toggle(false)); + act(() => result.current.toggle(true)); + await flush(); // chain runs: the first start and the stop are superseded → skipped + + // The stale stop never reached the backend; only the newest start ran. + expect(stopMcpServer).not.toHaveBeenCalled(); + expect(startMcpServer).toHaveBeenCalledTimes(1); + + await act(async () => { + resolvers.forEach((r) => r()); + await new Promise((r) => setTimeout(r, 0)); + }); + expect(result.current.run.kind).toBe("running"); + }); +}); + +describe("useMcpAvailability", () => { + it("stamps the capability when it is still unknown (boot-ping recovery)", async () => { + renderHook(() => useMcpAvailability()); + await act(() => Promise.resolve()); + expect(useLabelStore.getState().mcpSidecarAvailable).toBe(true); + expect(mcpServerStatus).toHaveBeenCalledTimes(1); + }); + + it("does not re-ping when the capability is already known", async () => { + useLabelStore.setState({ mcpSidecarAvailable: false }); + renderHook(() => useMcpAvailability()); + await act(() => Promise.resolve()); + expect(mcpServerStatus).not.toHaveBeenCalled(); + expect(useLabelStore.getState().mcpSidecarAvailable).toBe(false); + }); +}); diff --git a/src/components/PrinterSettings/McpServerTab.tsx b/src/components/PrinterSettings/McpServerTab.tsx new file mode 100644 index 00000000..52a8fa0d --- /dev/null +++ b/src/components/PrinterSettings/McpServerTab.tsx @@ -0,0 +1,95 @@ +import { CheckIcon, ClipboardDocumentIcon } from "@heroicons/react/16/solid"; +import { useT } from "../../hooks/useT"; +import { useMcpServer, type RunState } from "../../hooks/useMcpServer"; +import { labelCls, inputCls, buttonCls } from "../ui/formStyles"; + +/** Local MCP loopback server controls. Only mounted when the build can spawn + * the sidecar (gated by TAB_GATES in the modal). */ +export function McpServerTab() { + const loc = useT().printerSettings.mcp; + const { run, enabled, port, token, running, toggle, regenerate, setPort, copy, copied } = + useMcpServer(); + + return ( +
+

{loc.heading}

+ + + {loc.hint} + + + +
+
+ + { + const next = Number(e.target.value); + if (!Number.isNaN(next)) setPort(next); + }} + className={`${inputCls} disabled:opacity-50`} + /> +
+ +
+ +
+ + +
+
+ + +
+
+ ); +} + +function StatusLine({ run, loc }: { run: RunState; loc: ReturnType["printerSettings"]["mcp"] }) { + if (run.kind === "error") { + return {run.message}; + } + const on = run.kind === "running" || run.kind === "starting"; + return ( + + + {on ? loc.statusRunning : loc.statusStopped} + + ); +} diff --git a/src/components/PrinterSettings/PrinterSettingsModal.tsx b/src/components/PrinterSettings/PrinterSettingsModal.tsx index 53232ddd..3c528b76 100644 --- a/src/components/PrinterSettings/PrinterSettingsModal.tsx +++ b/src/components/PrinterSettings/PrinterSettingsModal.tsx @@ -1,10 +1,12 @@ import { useId, useRef, useState, type FC } from "react"; import { CheckIcon, ClipboardDocumentIcon, TrashIcon, XMarkIcon } from "@heroicons/react/16/solid"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { useMcpAvailability } from "../../hooks/useMcpServer"; import { useT } from "../../hooks/useT"; import { generateSetupScript } from "../../lib/zplSetupScript"; import { useLabelStore, selectHasPerLabelOverrides } from "../../store/labelStore"; import type { PrinterSettingsTab } from "../../store/slices/uiSlice"; +import { TAB_GATES, type TabGateCtx } from "./tabVisibility"; import type { PrinterProfile } from "@zplab/core/types/PrinterProfile"; import { DialogShell } from "../ui/DialogShell"; import { Tooltip } from "../ui/Tooltip"; @@ -15,6 +17,7 @@ import { EncodingAndLanguageTab } from "./EncodingAndLanguageTab"; import { FontsTab } from "./FontsTab"; import { IdentityTab } from "./IdentityTab"; import { MaintenanceTab } from "./MaintenanceTab"; +import { McpServerTab } from "./McpServerTab"; import { MediaFeedTab } from "./MediaFeedTab"; import { OutputTab } from "./OutputTab"; import { PreviewSettingsTab } from "./PreviewSettingsTab"; @@ -28,6 +31,7 @@ type TopTabId = 'app' | 'perLabel' | 'setupScript'; const TOP_TAB_OF = { appSettings: 'app', previewSettings: 'app', + mcpServer: 'app', mediaFeed: 'perLabel', printQuality: 'perLabel', output: 'perLabel', @@ -60,6 +64,7 @@ const TABS_BY_TOP_TAB: Record = (() => const TAB_COMPONENTS: Partial> = { appSettings: AppSettingsTab, previewSettings: PreviewSettingsTab, + mcpServer: McpServerTab, mediaFeed: MediaFeedTab, printQuality: PrintQualityTab, output: OutputTab, @@ -123,16 +128,24 @@ export function PrinterSettingsModal() { resetPerLabelConfig(); }; const hasPerLabelOverrides = useLabelStore(selectHasPerLabelOverrides); + const mcpSidecarAvailable = useLabelStore((s) => s.mcpSidecarAvailable); + const gateCtx: TabGateCtx = { mcpSidecarAvailable }; const openZebraPrint = useLabelStore((s) => s.openZebraPrint); const titleId = useId(); const subtitleId = useId(); + // Recover the capability stamp if the boot ping never landed, so the MCP tab + // is not permanently hidden by a transient status reject. + useMcpAvailability(); if (!tab) return null; const activeTopTab = TOP_TAB_OF[tab]; - const activeTabs = TABS_BY_TOP_TAB[activeTopTab]; + const isVisible = (id: PrinterSettingsTab) => TAB_GATES[id]?.(gateCtx) ?? true; + const activeTabs = TABS_BY_TOP_TAB[activeTopTab].filter(isVisible); const activeLabelKey = TOP_TAB_LABEL_KEY[activeTopTab]; - const ActiveTab = TAB_COMPONENTS[tab]; + // Gate the content pane too, not just the rail, so a gated-out tab never + // renders its body (the tab component then needs no self-guard). + const ActiveTab = isVisible(tab) ? TAB_COMPONENTS[tab] : undefined; const setupScript = generateSetupScript(printerProfile); diff --git a/src/components/PrinterSettings/tabVisibility.ts b/src/components/PrinterSettings/tabVisibility.ts new file mode 100644 index 00000000..bfb5dde2 --- /dev/null +++ b/src/components/PrinterSettings/tabVisibility.ts @@ -0,0 +1,12 @@ +import type { PrinterSettingsTab } from "../../store/slices/uiSlice"; + +/** Capability gates for conditional settings tabs; absent = always visible. + * Gates both the rail entry and the content pane. */ +export interface TabGateCtx { + mcpSidecarAvailable: boolean | null; +} + +export const TAB_GATES: Partial boolean>> = { + // Web and sidecar-less releases report unavailable, so one fact gates both. + mcpServer: (ctx) => ctx.mcpSidecarAvailable === true, +}; diff --git a/src/hooks/useDesignFileActions.ts b/src/hooks/useDesignFileActions.ts index 27d1c949..7f39deb2 100644 --- a/src/hooks/useDesignFileActions.ts +++ b/src/hooks/useDesignFileActions.ts @@ -1,8 +1,9 @@ -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { useLabelStore } from "../store/labelStore"; import { parseDesignFile, serializeDesign, designFileErrors } from "@zplab/core/lib/designFile"; import { readFileAsText } from "../lib/readFile"; import { pickFileText, pickViaMenu, saveTextFile, saveErrorMessage, DESIGN_FILTER } from "../lib/fileDialogs"; +import { isDesktopShell } from "../lib/platform"; export function useDesignFileActions() { const label = useLabelStore((s) => s.label); @@ -55,6 +56,34 @@ export function useDesignFileActions() { ); }; + // The app-spawned MCP server pushes drafts through this Tauri event; route + // them through the same parse+load path as opening a file so an invalid + // payload surfaces identically. Read via ref so re-subscribing is unneeded. + const applyRef = useRef(applyDesignText); + useEffect(() => { + applyRef.current = applyDesignText; + }); + useEffect(() => { + if (!isDesktopShell) return; + let unlisten: (() => void) | undefined; + let cancelled = false; + void import("@tauri-apps/api/event") + .then(({ listen }) => + listen("mcp://open-draft", (event) => applyRef.current(event.payload)), + ) + .then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }) + // Bridge setup is desktop-only and non-actionable if it fails; swallow so + // it is not an unhandled rejection (matches the boot-start in main.tsx). + .catch(() => undefined); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + const handleLoad = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; diff --git a/src/hooks/useMcpServer.ts b/src/hooks/useMcpServer.ts new file mode 100644 index 00000000..fe4ba6e6 --- /dev/null +++ b/src/hooks/useMcpServer.ts @@ -0,0 +1,138 @@ +import { useEffect, useRef, useState } from "react"; +import { useCopyToClipboard } from "./useCopyToClipboard"; +import { useLabelStore } from "../store/labelStore"; +import { + mcpConfigSnippet, + mcpServerStatus, + startMcpServer, + stopMcpServer, +} from "../lib/mcpServer"; + +export type RunState = + | { kind: "stopped" } + | { kind: "starting" } + | { kind: "running" } + | { kind: "error"; message: string }; + +/** Re-stamp the sidecar-capability fact while it is unknown. The boot ping + * (main.tsx) stamps it once; mounting this in the settings modal recovers the + * MCP tab without a restart if that ping never landed. */ +export function useMcpAvailability(): void { + const available = useLabelStore((s) => s.mcpSidecarAvailable); + useEffect(() => { + if (available !== null) return; + let cancelled = false; + void mcpServerStatus() + .then((s) => { + if (!cancelled) useLabelStore.getState().setMcpSidecarAvailable(s.available); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [available]); +} + +export interface McpServerController { + run: RunState; + enabled: boolean; + port: number; + token: string; + running: boolean; + toggle: (checked: boolean) => void; + regenerate: () => void; + setPort: (port: number) => void; + copy: () => void; + copied: boolean; +} + +/** Orchestration for the local MCP loopback server: the RunState machine, the + * start/stop toggle, and the mount reconcile with the real sidecar. Keeps this + * side-effect logic out of the view. */ +export function useMcpServer(): McpServerController { + const enabled = useLabelStore((s) => s.mcpServerEnabled); + const port = useLabelStore((s) => s.mcpServerPort); + const token = useLabelStore((s) => s.mcpServerToken); + const setEnabled = useLabelStore((s) => s.setMcpServerEnabled); + const setPortState = useLabelStore((s) => s.setMcpServerPort); + const regenerate = useLabelStore((s) => s.regenerateMcpToken); + + const [run, setRun] = useState({ kind: "stopped" }); + // Serialize sidecar calls in intent order and run only the newest transition: + // a superseded op is skipped before it reaches the backend, so a stale stop + // can't kill a just-started server (nor desync the UI from it). + const runSeq = useRef(0); + const opChain = useRef>(Promise.resolve()); + const enqueue = (op: (current: () => boolean) => Promise) => { + const seq = ++runSeq.current; + const current = () => runSeq.current === seq; + opChain.current = opChain.current.then(() => op(current)).catch(() => undefined); + }; + // resetSettings can flip the opt-in off without unmounting; derive the shown + // state from it so a stale local "running" can't outlive the toggle. + const shownRun: RunState = enabled ? run : { kind: "stopped" }; + const running = shownRun.kind === "running" || shownRun.kind === "starting"; + + const { copy, copied } = useCopyToClipboard(() => mcpConfigSnippet(port, token)); + + const fail = (e: unknown) => { + setRun({ kind: "error", message: e instanceof Error ? e.message : String(e) }); + }; + + // Mount reconcile: a persisted opt-in that never started re-attempts so the + // actual reason shows. The tab only mounts when the build has the sidecar + // (TAB_GATES), so no availability re-check is needed here. + useEffect(() => { + let mounted = true; + enqueue(async (current) => { + // catch: a rejected status must not be an unhandled rejection. + const status = await mcpServerStatus().catch(() => null); + if (!status || !mounted || !current()) return; + if (status.running) { + setRun({ kind: "running" }); + return; + } + const s = useLabelStore.getState(); + if (!status.available || !s.mcpServerEnabled) return; + setRun({ kind: "starting" }); + try { + await startMcpServer({ port: s.mcpServerPort, token: s.mcpServerToken }); + if (mounted && current()) setRun({ kind: "running" }); + } catch (e) { + if (mounted && current()) fail(e); + } + }); + return () => { + mounted = false; + }; + }, []); + + const toggle = (checked: boolean) => { + // Token is generated synchronously by setEnabled; the op reads it back fresh. + if (checked) { + setEnabled(true); + setRun({ kind: "starting" }); + } else { + setEnabled(false); // shownRun derives "stopped" from the opt-in immediately + } + enqueue(async (current) => { + if (!current()) return; // superseded: don't send a stale start/stop + try { + if (checked) { + const s = useLabelStore.getState(); + await startMcpServer({ port: s.mcpServerPort, token: s.mcpServerToken }); + if (current()) setRun({ kind: "running" }); + } else { + await stopMcpServer().catch(() => undefined); + if (current()) setRun({ kind: "stopped" }); + } + } catch (e) { + if (current()) fail(e); + } + }); + }; + + const setPort = (next: number) => setPortState(Math.min(65535, Math.max(1024, next))); + + return { run: shownRun, enabled, port, token, running, toggle, regenerate, setPort, copy, copied }; +} diff --git a/src/lib/importReport.ts b/src/lib/importReport.ts index 5b3a3c27..ea039434 100644 --- a/src/lib/importReport.ts +++ b/src/lib/importReport.ts @@ -1,61 +1,11 @@ -import type { ImportFinding, ImportFindingKind, ImportReport } from '@zplab/core/lib/zplParser'; +import type { ImportFinding, ImportReport } from '@zplab/core/lib/importReport'; import { ZPL_COMMAND_MAP } from './zplCommandSupport'; -// Re-export the parser-side domain types so UI code talks to the report -// surface through this module exclusively instead of reaching into the -// parser for type imports. -export type { ImportFinding, ImportFindingKind, ImportReport }; - export interface ImportResult { objectCount: number; report: ImportReport; } -/** Distinct command codes of one finding kind; backs the report's bucket views. */ -export function dedupCommandsByKind( - findings: readonly ImportFinding[], - kind: ImportFindingKind, -): string[] { - return [...new Set(findings.filter((f) => f.kind === kind).map((f) => f.command))]; -} - -/** Routable setup-script findings; drives the import routing prompt. */ -export function replayRiskFindings(report: ImportReport): ImportFinding[] { - return report.findings.filter((f) => f.kind === 'replayRisk'); -} - -/** Routable setup commands plus non-routable device actions, for the prompt list. */ -export function printerCommandFindings(report: ImportReport): ImportFinding[] { - return report.findings.filter((f) => f.kind === 'replayRisk' || f.kind === 'deviceAction'); -} - -/** Report after routing setup commands out of the label: replayRisk resolved, - * overlay-dependent findings on routed pages moot, findings on dropped pages - * removed and survivors remapped to `keptPageIndexes` order. */ -export function resolveRoutedReport( - report: ImportReport, - keptPageIndexes: readonly number[], -): ImportReport { - const riskPages = new Set(replayRiskFindings(report).map((f) => f.pageIndex)); - const newIndexOf = new Map(keptPageIndexes.map((orig, i) => [orig, i])); - const findings = report.findings.flatMap((f) => { - if (f.kind === 'replayRisk') return []; - if ((f.kind === 'lossyEdit' || f.kind === 'deviceAction') && riskPages.has(f.pageIndex)) { - return []; - } - const newIndex = newIndexOf.get(f.pageIndex); - return newIndex === undefined ? [] : [{ ...f, pageIndex: newIndex }]; - }); - return { - findings, - partial: dedupCommandsByKind(findings, 'partial'), - browserLimit: dedupCommandsByKind(findings, 'browserLimit'), - unknown: dedupCommandsByKind(findings, 'unknown'), - replayRisk: dedupCommandsByKind(findings, 'replayRisk'), - deviceAction: dedupCommandsByKind(findings, 'deviceAction'), - }; -} - /** Returns the loss description for a partial command code, e.g. "^A@" → font face note. */ function partialLoss(cmd: string): string { const key = cmd.slice(1); @@ -116,6 +66,12 @@ export function describeFinding(f: ImportFinding): { title: string; detail: stri detail: f.command, }; } + if (f.kind === 'mixedPageGeometry') { + return { + title: 'Multiple label sizes: later pages use the first size', + detail: `^PW/^LL differ between blocks (${f.command})`, + }; + } return { title: 'Skipped: command not recognised', detail: f.command, diff --git a/src/lib/mcpServer.test.ts b/src/lib/mcpServer.test.ts new file mode 100644 index 00000000..b90bd87f --- /dev/null +++ b/src/lib/mcpServer.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { generateMcpToken, mcpConfigSnippet } from "./mcpServer"; + +describe("generateMcpToken", () => { + it("returns 32 lowercase hex chars", () => { + expect(generateMcpToken()).toMatch(/^[0-9a-f]{32}$/); + }); + + it("is unlikely to collide", () => { + expect(generateMcpToken()).not.toBe(generateMcpToken()); + }); +}); + +describe("mcpConfigSnippet", () => { + it("embeds the loopback url and bearer token", () => { + const snippet = mcpConfigSnippet(4123, "deadbeef"); + const parsed = JSON.parse(snippet) as { + mcpServers: { zplab: { url: string; headers: { Authorization: string } } }; + }; + expect(parsed.mcpServers.zplab.url).toBe("http://127.0.0.1:4123/"); + expect(parsed.mcpServers.zplab.headers.Authorization).toBe("Bearer deadbeef"); + }); +}); diff --git a/src/lib/mcpServer.ts b/src/lib/mcpServer.ts new file mode 100644 index 00000000..90514ddf --- /dev/null +++ b/src/lib/mcpServer.ts @@ -0,0 +1,51 @@ +import { isDesktopShell } from "./platform"; + +/** Mirrors the Rust McpStatus DTO. `available` is the build capability: false + * in a release shipped without the bundled sidecar. */ +export interface McpStatus { + running: boolean; + available: boolean; +} + +/** 32 hex chars (16 random bytes) for the loopback server's bearer token. */ +export function generateMcpToken(): string { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** Claude-Desktop style config for the loopback Streamable HTTP server. The + * server serves the transport on any path, so the tested root `/` is the URL; + * every request must carry the bearer token. */ +export function mcpConfigSnippet(port: number, token: string): string { + return JSON.stringify( + { + mcpServers: { + zplab: { + url: `http://127.0.0.1:${port}/`, + headers: { Authorization: `Bearer ${token}` }, + }, + }, + }, + null, + 2, + ); +} + +export async function startMcpServer(opts: { port: number; token: string }): Promise { + if (!isDesktopShell) throw new Error("The MCP server requires the desktop app"); + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("mcp_start", { port: opts.port, token: opts.token }); +} + +export async function stopMcpServer(): Promise { + if (!isDesktopShell) return; + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("mcp_stop"); +} + +export async function mcpServerStatus(): Promise { + if (!isDesktopShell) return { running: false, available: false }; + const { invoke } = await import("@tauri-apps/api/core"); + return await invoke("mcp_status"); +} diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 54f3e038..e3e09e2c 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -446,6 +446,7 @@ const ar = { printerSupportLimited: 'دعم محدود', qrRotatedStatic: 'رمز QR الدوّار يطبع قيمة ثابتة', qrRotatedModel2: 'رمز QR الدوّار يطبع كطراز 2', + unknownType: 'نوع كائن غير معروف', }, printerSettings: { @@ -465,6 +466,7 @@ const ar = { mediaFeed: 'الوسائط والتغذية', appSettings: 'التطبيق', previewSettings: 'معاينة', + mcpServer: 'MCP', printQuality: 'جودة الطباعة', output: 'الإخراج', clockTime: 'التاريخ والوقت', @@ -692,6 +694,18 @@ const ar = { apiHintDesktop: 'لخطط Labelary المدفوعة. اتركه فارغًا لاستخدام الخدمة العامة المجانية. يُخزَّن المفتاح في سلسلة مفاتيح نظام التشغيل.', apiHintWeb: 'لخطط Labelary المدفوعة. اتركه فارغًا لاستخدام الخدمة العامة المجانية. يُخزَّن المفتاح في هذا المتصفح.', }, + mcp: { + heading: 'خادم MCP', + enable: 'تفعيل خادم MCP', + hint: 'يتيح لمساعد ذكاء اصطناعي محلي مثل Claude Desktop قراءة هذا الملصق وتحريره عبر Model Context Protocol.', + port: 'منفذ', + token: 'رمز الوصول', + regenerate: 'إعادة توليد', + copyConfig: 'نسخ الإعداد', + copied: 'تم النسخ', + statusRunning: 'قيد التشغيل', + statusStopped: 'متوقف', + }, }, app: { diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 4059489f..9a1957c3 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -446,6 +446,7 @@ const bg = { printerSupportLimited: 'Ограничена поддръжка', qrRotatedStatic: 'Завъртян QR код отпечатва статична стойност', qrRotatedModel2: 'Завъртян QR код отпечатва като Модел 2', + unknownType: 'Неизвестен тип обект', }, printerSettings: { @@ -465,6 +466,7 @@ const bg = { mediaFeed: 'Носител и подаване', appSettings: 'Приложение', previewSettings: 'Преглед', + mcpServer: 'MCP', printQuality: 'Качество на печат', output: 'Изход', clockTime: 'Дата и час', @@ -692,6 +694,18 @@ const bg = { apiHintDesktop: 'За премиум планове на Labelary. Оставете празно, за да използвате безплатната публична услуга. Ключът се съхранява в ключодържателя на операционната система.', apiHintWeb: 'За премиум планове на Labelary. Оставете празно, за да използвате безплатната публична услуга. Ключът се съхранява в този браузър.', }, + mcp: { + heading: 'MCP сървър', + enable: 'Активиране на MCP сървъра', + hint: 'Позволява на локален AI асистент като Claude Desktop да чете и редактира този етикет чрез Model Context Protocol.', + port: 'Порт', + token: 'Токен за достъп', + regenerate: 'Генериране наново', + copyConfig: 'Копиране на конфигурацията', + copied: 'Копирано', + statusRunning: 'Работи', + statusStopped: 'Спрян', + }, }, app: { diff --git a/src/locales/cs.ts b/src/locales/cs.ts index aab930bb..b608894e 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -446,6 +446,7 @@ const cs = { printerSupportLimited: 'Omezená podpora', qrRotatedStatic: 'Otočený QR kód tiskne statickou hodnotu', qrRotatedModel2: 'Otočený QR kód tiskne jako Model 2', + unknownType: 'Neznámý typ objektu', }, printerSettings: { @@ -465,6 +466,7 @@ const cs = { mediaFeed: 'Média a posuv', appSettings: 'Aplikace', previewSettings: 'Náhled', + mcpServer: 'MCP', printQuality: 'Kvalita tisku', output: 'Výstup', clockTime: 'Datum a čas', @@ -692,6 +694,18 @@ const cs = { apiHintDesktop: 'Pro prémiové plány Labelary. Ponechte prázdné pro použití bezplatné veřejné služby. Klíč se ukládá do klíčenky operačního systému.', apiHintWeb: 'Pro prémiové plány Labelary. Ponechte prázdné pro použití bezplatné veřejné služby. Klíč se ukládá do tohoto prohlížeče.', }, + mcp: { + heading: 'Server MCP', + enable: 'Povolit server MCP', + hint: 'Umožní místnímu AI asistentovi, jako je Claude Desktop, číst a upravovat tento štítek prostřednictvím Model Context Protocol.', + port: 'Port', + token: 'Přístupový token', + regenerate: 'Vygenerovat znovu', + copyConfig: 'Kopírovat konfiguraci', + copied: 'Zkopírováno', + statusRunning: 'Běží', + statusStopped: 'Zastaveno', + }, }, app: { diff --git a/src/locales/da.ts b/src/locales/da.ts index d4fca5b2..c6e55715 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -446,6 +446,7 @@ const da = { printerSupportLimited: 'Begrænset support', qrRotatedStatic: 'Roteret QR-kode udskriver statisk værdi', qrRotatedModel2: 'Roteret QR-kode udskriver som Model 2', + unknownType: 'Ukendt objekttype', }, printerSettings: { @@ -465,6 +466,7 @@ const da = { mediaFeed: 'Medie og fremføring', appSettings: 'App', previewSettings: 'Forhåndsvisning', + mcpServer: 'MCP', printQuality: 'Udskriftskvalitet', output: 'Output', clockTime: 'Dato og tid', @@ -692,6 +694,18 @@ const da = { apiHintDesktop: 'Til Labelary premium-abonnementer. Lad feltet være tomt for at bruge den gratis offentlige tjeneste. Nøglen gemmes i operativsystemets nøglering.', apiHintWeb: 'Til Labelary premium-abonnementer. Lad feltet være tomt for at bruge den gratis offentlige tjeneste. Nøglen gemmes i denne browser.', }, + mcp: { + heading: 'MCP-server', + enable: 'Aktivér MCP-server', + hint: 'Lader en lokal AI-assistent som Claude Desktop læse og redigere denne etiket via Model Context Protocol.', + port: 'Port', + token: 'Adgangstoken', + regenerate: 'Generér igen', + copyConfig: 'Kopiér konfiguration', + copied: 'Kopieret', + statusRunning: 'Kører', + statusStopped: 'Stoppet', + }, }, app: { diff --git a/src/locales/de.ts b/src/locales/de.ts index f477bbd2..816636e0 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -446,6 +446,7 @@ const de = { printerSupportLimited: 'Eingeschränkt unterstützt', qrRotatedStatic: 'Gedrehter QR-Code druckt statischen Wert', qrRotatedModel2: 'Gedrehter QR-Code druckt als Modell 2', + unknownType: 'Unbekannter Objekttyp', }, printerSettings: { @@ -465,6 +466,7 @@ const de = { mediaFeed: 'Medien & Vorschub', appSettings: 'App', previewSettings: 'Vorschau', + mcpServer: 'MCP', printQuality: 'Druckqualität', output: 'Ausgabe', clockTime: 'Datum & Uhrzeit', @@ -692,6 +694,18 @@ const de = { apiHintDesktop: 'Für Labelary-Premium-Tarife. Leer lassen, um den kostenlosen öffentlichen Dienst zu nutzen. Der Schlüssel wird im Schlüsselbund des Betriebssystems gespeichert.', apiHintWeb: 'Für Labelary-Premium-Tarife. Leer lassen, um den kostenlosen öffentlichen Dienst zu nutzen. Der Schlüssel wird in diesem Browser gespeichert.', }, + mcp: { + heading: 'MCP-Server', + enable: 'MCP-Server aktivieren', + hint: 'Ermöglicht einem lokalen KI-Assistenten wie Claude Desktop, dieses Etikett über das Model Context Protocol zu lesen und zu bearbeiten.', + port: 'Port', + token: 'Zugriffstoken', + regenerate: 'Neu generieren', + copyConfig: 'Konfiguration kopieren', + copied: 'Kopiert', + statusRunning: 'Aktiv', + statusStopped: 'Gestoppt', + }, }, app: { diff --git a/src/locales/el.ts b/src/locales/el.ts index 33f39036..ce63760c 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -446,6 +446,7 @@ const el = { printerSupportLimited: 'Περιορισμένη υποστήριξη', qrRotatedStatic: 'Περιστραμμένος κωδικός QR εκτυπώνει στατική τιμή', qrRotatedModel2: 'Περιστραμμένος κωδικός QR εκτυπώνει ως Μοντέλο 2', + unknownType: 'Άγνωστος τύπος αντικειμένου', }, printerSettings: { @@ -465,6 +466,7 @@ const el = { mediaFeed: 'Μέσα και τροφοδοσία', appSettings: 'Εφαρμογή', previewSettings: 'Προεπισκόπηση', + mcpServer: 'MCP', printQuality: 'Ποιότητα εκτύπωσης', output: 'Έξοδος', clockTime: 'Ημερομηνία και ώρα', @@ -692,6 +694,18 @@ const el = { apiHintDesktop: 'Για τα προγράμματα premium του Labelary. Αφήστε το κενό για χρήση της δωρεάν δημόσιας υπηρεσίας. Το κλειδί αποθηκεύεται στην κλειδοθήκη του λειτουργικού συστήματος.', apiHintWeb: 'Για τα προγράμματα premium του Labelary. Αφήστε το κενό για χρήση της δωρεάν δημόσιας υπηρεσίας. Το κλειδί αποθηκεύεται σε αυτό το πρόγραμμα περιήγησης.', }, + mcp: { + heading: 'Διακομιστής MCP', + enable: 'Ενεργοποίηση διακομιστή MCP', + hint: 'Επιτρέπει σε έναν τοπικό βοηθό AI όπως το Claude Desktop να διαβάζει και να επεξεργάζεται αυτήν την ετικέτα μέσω του Model Context Protocol.', + port: 'Θύρα', + token: 'Διακριτικό πρόσβασης', + regenerate: 'Επαναδημιουργία', + copyConfig: 'Αντιγραφή διαμόρφωσης', + copied: 'Αντιγράφηκε', + statusRunning: 'Εκτελείται', + statusStopped: 'Σταματημένο', + }, }, app: { diff --git a/src/locales/en.ts b/src/locales/en.ts index c941ad8b..cc9f378f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -446,6 +446,7 @@ const en = { printerSupportLimited: 'Limited support', qrRotatedStatic: 'Rotated QR prints a static value', qrRotatedModel2: 'Rotated QR prints as Model 2', + unknownType: 'Unknown object type', }, printerSettings: { @@ -465,6 +466,7 @@ const en = { mediaFeed: 'Media & Feed', appSettings: 'App', previewSettings: 'Preview', + mcpServer: 'MCP', printQuality: 'Print Quality', output: 'Output', clockTime: 'Clock & Time', @@ -692,6 +694,18 @@ const en = { apiHintDesktop: 'For Labelary premium plans. Leave empty to use the free public service. The key is stored in the operating system keychain.', apiHintWeb: 'For Labelary premium plans. Leave empty to use the free public service. The key is stored in this browser.', }, + mcp: { + heading: 'MCP server', + enable: 'Enable MCP server', + hint: 'Lets a local AI assistant like Claude Desktop read and edit this label through the Model Context Protocol.', + port: 'Port', + token: 'Access token', + regenerate: 'Regenerate', + copyConfig: 'Copy config', + copied: 'Copied', + statusRunning: 'Running', + statusStopped: 'Stopped', + }, }, app: { diff --git a/src/locales/es.ts b/src/locales/es.ts index 154def04..5bbf4b3c 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -446,6 +446,7 @@ const es = { printerSupportLimited: 'Soporte limitado', qrRotatedStatic: 'Código QR rotado imprime un valor estático', qrRotatedModel2: 'Código QR rotado imprime como Modelo 2', + unknownType: 'Tipo de objeto desconocido', }, printerSettings: { @@ -465,6 +466,7 @@ const es = { mediaFeed: 'Medio y avance', appSettings: 'App', previewSettings: 'Vista previa', + mcpServer: 'MCP', printQuality: 'Calidad de impresión', output: 'Salida', clockTime: 'Fecha y hora', @@ -692,6 +694,18 @@ const es = { apiHintDesktop: 'Para los planes premium de Labelary. Déjelo vacío para usar el servicio público gratuito. La clave se almacena en el llavero del sistema operativo.', apiHintWeb: 'Para los planes premium de Labelary. Déjelo vacío para usar el servicio público gratuito. La clave se almacena en este navegador.', }, + mcp: { + heading: 'Servidor MCP', + enable: 'Activar el servidor MCP', + hint: 'Permite que un asistente de IA local como Claude Desktop lea y edite esta etiqueta a través del Model Context Protocol.', + port: 'Puerto', + token: 'Token de acceso', + regenerate: 'Regenerar', + copyConfig: 'Copiar configuración', + copied: 'Copiado', + statusRunning: 'En ejecución', + statusStopped: 'Detenido', + }, }, app: { diff --git a/src/locales/et.ts b/src/locales/et.ts index ad8192c4..4f5d0fd8 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -446,6 +446,7 @@ const et = { printerSupportLimited: 'Piiratud tugi', qrRotatedStatic: 'Pööratud QR-kood trükib staatilise väärtuse', qrRotatedModel2: 'Pööratud QR-kood trükib mudelina 2', + unknownType: 'Tundmatu objekti tüüp', }, printerSettings: { @@ -465,6 +466,7 @@ const et = { mediaFeed: 'Kandja ja söötmine', appSettings: 'Rakendus', previewSettings: 'Eelvaade', + mcpServer: 'MCP', printQuality: 'Printimise kvaliteet', output: 'Väljund', clockTime: 'Kuupäev ja kellaaeg', @@ -692,6 +694,18 @@ const et = { apiHintDesktop: 'Labelary premium-pakettide jaoks. Jätke tühjaks, et kasutada tasuta avalikku teenust. Võti salvestatakse operatsioonisüsteemi võtmehoidlasse.', apiHintWeb: 'Labelary premium-pakettide jaoks. Jätke tühjaks, et kasutada tasuta avalikku teenust. Võti salvestatakse sellesse brauserisse.', }, + mcp: { + heading: 'MCP-server', + enable: 'Luba MCP-server', + hint: 'Võimaldab kohalikul AI-abilisel, näiteks Claude Desktopil, seda silti Model Context Protocoli kaudu lugeda ja muuta.', + port: 'Port', + token: 'Juurdepääsuluba', + regenerate: 'Loo uuesti', + copyConfig: 'Kopeeri seadistus', + copied: 'Kopeeritud', + statusRunning: 'Töötab', + statusStopped: 'Peatatud', + }, }, app: { diff --git a/src/locales/fa.ts b/src/locales/fa.ts index cb69c2ce..c7e4b6ac 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -446,6 +446,7 @@ const fa = { printerSupportLimited: 'پشتیبانی محدود', qrRotatedStatic: 'کد QR چرخانده‌شده مقدار ثابت چاپ می‌کند', qrRotatedModel2: 'کد QR چرخانده‌شده به‌صورت مدل ۲ چاپ می‌کند', + unknownType: 'نوع شیء ناشناخته', }, printerSettings: { @@ -465,6 +466,7 @@ const fa = { mediaFeed: 'رسانه و تغذیه', appSettings: 'برنامه', previewSettings: 'پیش‌نمایش', + mcpServer: 'MCP', printQuality: 'کیفیت چاپ', output: 'خروجی', clockTime: 'تاریخ و زمان', @@ -692,6 +694,18 @@ const fa = { apiHintDesktop: 'برای پلن‌های پرمیوم Labelary. برای استفاده از سرویس عمومی رایگان، خالی بگذارید. کلید در جاکلیدی سیستم‌عامل ذخیره می‌شود.', apiHintWeb: 'برای پلن‌های پرمیوم Labelary. برای استفاده از سرویس عمومی رایگان، خالی بگذارید. کلید در این مرورگر ذخیره می‌شود.', }, + mcp: { + heading: 'سرور MCP', + enable: 'فعال‌سازی سرور MCP', + hint: 'به یک دستیار هوش مصنوعی محلی مانند Claude Desktop اجازه می‌دهد این برچسب را از طریق Model Context Protocol بخواند و ویرایش کند.', + port: 'پورت', + token: 'توکن دسترسی', + regenerate: 'بازتولید', + copyConfig: 'کپی پیکربندی', + copied: 'کپی شد', + statusRunning: 'در حال اجرا', + statusStopped: 'متوقف', + }, }, app: { diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 26e98c70..ba01de4b 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -446,6 +446,7 @@ const fi = { printerSupportLimited: 'Rajoitettu tuki', qrRotatedStatic: 'Käännetty QR-koodi tulostaa staattisen arvon', qrRotatedModel2: 'Käännetty QR-koodi tulostaa mallina 2', + unknownType: 'Tuntematon objektityyppi', }, printerSettings: { @@ -465,6 +466,7 @@ const fi = { mediaFeed: 'Materiaali ja syöttö', appSettings: 'Sovellus', previewSettings: 'Esikatselu', + mcpServer: 'MCP', printQuality: 'Tulostuslaatu', output: 'Tuloste', clockTime: 'Päivämäärä ja aika', @@ -692,6 +694,18 @@ const fi = { apiHintDesktop: 'Labelaryn premium-tilauksiin. Jätä tyhjäksi käyttääksesi ilmaista julkista palvelua. Avain tallennetaan käyttöjärjestelmän avainnippuun.', apiHintWeb: 'Labelaryn premium-tilauksiin. Jätä tyhjäksi käyttääksesi ilmaista julkista palvelua. Avain tallennetaan tähän selaimeen.', }, + mcp: { + heading: 'MCP-palvelin', + enable: 'Ota MCP-palvelin käyttöön', + hint: 'Antaa paikallisen tekoälyavustajan, kuten Claude Desktopin, lukea ja muokata tätä etikettiä Model Context Protocolin kautta.', + port: 'Portti', + token: 'Käyttöoikeustunnus', + regenerate: 'Luo uudelleen', + copyConfig: 'Kopioi asetukset', + copied: 'Kopioitu', + statusRunning: 'Käynnissä', + statusStopped: 'Pysäytetty', + }, }, app: { diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 4ffa4d01..b75c683c 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -446,6 +446,7 @@ const fr = { printerSupportLimited: 'Support limité', qrRotatedStatic: 'QR Code pivoté imprime une valeur statique', qrRotatedModel2: 'QR Code pivoté imprime en modèle 2', + unknownType: "Type d'objet inconnu", }, printerSettings: { @@ -465,6 +466,7 @@ const fr = { mediaFeed: 'Support et avance', appSettings: 'App', previewSettings: 'Aperçu', + mcpServer: 'MCP', printQuality: 'Qualité d’impression', output: 'Sortie', clockTime: 'Date et heure', @@ -692,6 +694,18 @@ const fr = { apiHintDesktop: "Pour les forfaits premium Labelary. Laissez vide pour utiliser le service public gratuit. La clé est stockée dans le trousseau du système d'exploitation.", apiHintWeb: 'Pour les forfaits premium Labelary. Laissez vide pour utiliser le service public gratuit. La clé est stockée dans ce navigateur.', }, + mcp: { + heading: 'Serveur MCP', + enable: 'Activer le serveur MCP', + hint: 'Permet à un assistant IA local comme Claude Desktop de lire et de modifier cette étiquette via le Model Context Protocol.', + port: 'Port', + token: "Jeton d'accès", + regenerate: 'Régénérer', + copyConfig: 'Copier la configuration', + copied: 'Copié', + statusRunning: "En cours d'exécution", + statusStopped: 'Arrêté', + }, }, app: { diff --git a/src/locales/he.ts b/src/locales/he.ts index 256315b1..f10156a8 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -446,6 +446,7 @@ const he = { printerSupportLimited: 'תמיכה מוגבלת', qrRotatedStatic: 'קוד QR מסובב מדפיס ערך סטטי', qrRotatedModel2: 'קוד QR מסובב מדפיס כדגם 2', + unknownType: 'סוג אובייקט לא ידוע', }, printerSettings: { @@ -465,6 +466,7 @@ const he = { mediaFeed: 'מדיה והזנה', appSettings: 'אפליקציה', previewSettings: 'תצוגה מקדימה', + mcpServer: 'MCP', printQuality: 'איכות הדפסה', output: 'פלט', clockTime: 'תאריך ושעה', @@ -692,6 +694,18 @@ const he = { apiHintDesktop: 'לתוכניות הפרימיום של Labelary. השאירו ריק כדי להשתמש בשירות הציבורי החינמי. המפתח נשמר במחזיק המפתחות של מערכת ההפעלה.', apiHintWeb: 'לתוכניות הפרימיום של Labelary. השאירו ריק כדי להשתמש בשירות הציבורי החינמי. המפתח נשמר בדפדפן זה.', }, + mcp: { + heading: 'שרת MCP', + enable: 'הפעלת שרת MCP', + hint: 'מאפשר לעוזר AI מקומי כמו Claude Desktop לקרוא ולערוך תווית זו באמצעות Model Context Protocol.', + port: 'פורט', + token: 'אסימון גישה', + regenerate: 'יצירה מחדש', + copyConfig: 'העתקת תצורה', + copied: 'הועתק', + statusRunning: 'פועל', + statusStopped: 'הופסק', + }, }, app: { diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 005b161a..14bc8760 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -446,6 +446,7 @@ const hr = { printerSupportLimited: 'Ograničena podrška', qrRotatedStatic: 'Rotirani QR kod ispisuje statičnu vrijednost', qrRotatedModel2: 'Rotirani QR kod ispisuje kao Model 2', + unknownType: 'Nepoznata vrsta objekta', }, printerSettings: { @@ -465,6 +466,7 @@ const hr = { mediaFeed: 'Medij i pomak', appSettings: 'Aplikacija', previewSettings: 'Pregled', + mcpServer: 'MCP', printQuality: 'Kvaliteta ispisa', output: 'Izlaz', clockTime: 'Datum i vrijeme', @@ -692,6 +694,18 @@ const hr = { apiHintDesktop: 'Za Labelary premium planove. Ostavite prazno za korištenje besplatne javne usluge. Ključ se pohranjuje u svežanj ključeva operativnog sustava.', apiHintWeb: 'Za Labelary premium planove. Ostavite prazno za korištenje besplatne javne usluge. Ključ se pohranjuje u ovaj preglednik.', }, + mcp: { + heading: 'MCP poslužitelj', + enable: 'Omogući MCP poslužitelj', + hint: 'Omogućuje lokalnom AI asistentu poput Claude Desktopa čitanje i uređivanje ove etikete putem Model Context Protocola.', + port: 'Priključak', + token: 'Pristupni token', + regenerate: 'Ponovno generiraj', + copyConfig: 'Kopiraj konfiguraciju', + copied: 'Kopirano', + statusRunning: 'Radi', + statusStopped: 'Zaustavljeno', + }, }, app: { diff --git a/src/locales/hu.ts b/src/locales/hu.ts index bd11280a..b848d6cc 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -446,6 +446,7 @@ const hu = { printerSupportLimited: 'Korlátozott támogatás', qrRotatedStatic: 'Forgatott QR-kód statikus értéket nyomtat', qrRotatedModel2: 'Forgatott QR-kód 2. modellként nyomtat', + unknownType: 'Ismeretlen objektumtípus', }, printerSettings: { @@ -465,6 +466,7 @@ const hu = { mediaFeed: 'Hordozó és továbbítás', appSettings: 'Alkalmazás', previewSettings: 'Előnézet', + mcpServer: 'MCP', printQuality: 'Nyomtatási minőség', output: 'Kimenet', clockTime: 'Dátum és idő', @@ -692,6 +694,18 @@ const hu = { apiHintDesktop: 'A Labelary prémium csomagokhoz. Hagyja üresen az ingyenes nyilvános szolgáltatás használatához. A kulcs az operációs rendszer kulcstartójában tárolódik.', apiHintWeb: 'A Labelary prémium csomagokhoz. Hagyja üresen az ingyenes nyilvános szolgáltatás használatához. A kulcs ebben a böngészőben tárolódik.', }, + mcp: { + heading: 'MCP-kiszolgáló', + enable: 'MCP-kiszolgáló engedélyezése', + hint: 'Lehetővé teszi egy helyi AI-asszisztens, például a Claude Desktop számára, hogy a Model Context Protocol segítségével olvassa és szerkessze ezt a címkét.', + port: 'Port', + token: 'Hozzáférési token', + regenerate: 'Újragenerálás', + copyConfig: 'Konfiguráció másolása', + copied: 'Másolva', + statusRunning: 'Fut', + statusStopped: 'Leállítva', + }, }, app: { diff --git a/src/locales/it.ts b/src/locales/it.ts index 7e320809..1c3c7722 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -446,6 +446,7 @@ const it = { printerSupportLimited: 'Supporto limitato', qrRotatedStatic: 'Codice QR ruotato stampa un valore statico', qrRotatedModel2: 'Codice QR ruotato stampa come Modello 2', + unknownType: 'Tipo di oggetto sconosciuto', }, printerSettings: { @@ -465,6 +466,7 @@ const it = { mediaFeed: 'Supporto e avanzamento', appSettings: 'App', previewSettings: 'Anteprima', + mcpServer: 'MCP', printQuality: 'Qualità di stampa', output: 'Output', clockTime: 'Data e ora', @@ -692,6 +694,18 @@ const it = { apiHintDesktop: 'Per i piani premium di Labelary. Lasciare vuoto per usare il servizio pubblico gratuito. La chiave è memorizzata nel portachiavi del sistema operativo.', apiHintWeb: 'Per i piani premium di Labelary. Lasciare vuoto per usare il servizio pubblico gratuito. La chiave è memorizzata in questo browser.', }, + mcp: { + heading: 'Server MCP', + enable: 'Attiva il server MCP', + hint: 'Consente a un assistente IA locale come Claude Desktop di leggere e modificare questa etichetta tramite il Model Context Protocol.', + port: 'Porta', + token: 'Token di accesso', + regenerate: 'Rigenera', + copyConfig: 'Copia configurazione', + copied: 'Copiato', + statusRunning: 'In esecuzione', + statusStopped: 'Arrestato', + }, }, app: { diff --git a/src/locales/ja.ts b/src/locales/ja.ts index d256a08e..9a4a5be7 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -446,6 +446,7 @@ const ja = { printerSupportLimited: '一部非対応', qrRotatedStatic: '回転したQRコードは固定値を印刷', qrRotatedModel2: '回転したQRコードはモデル2として印刷', + unknownType: '不明なオブジェクトタイプ', }, printerSettings: { @@ -465,6 +466,7 @@ const ja = { mediaFeed: 'メディアと給紙', appSettings: 'アプリ', previewSettings: 'プレビュー', + mcpServer: 'MCP', printQuality: '印刷品質', output: '出力', clockTime: '日付と時刻', @@ -692,6 +694,18 @@ const ja = { apiHintDesktop: 'Labelaryのプレミアムプラン用です。空欄のままにすると無料の公開サービスを使用します。キーはオペレーティングシステムのキーチェーンに保存されます。', apiHintWeb: 'Labelaryのプレミアムプラン用です。空欄のままにすると無料の公開サービスを使用します。キーはこのブラウザーに保存されます。', }, + mcp: { + heading: 'MCP サーバー', + enable: 'MCP サーバーを有効にする', + hint: 'Claude Desktop などのローカル AI アシスタントが Model Context Protocol を通じてこのラベルを読み書きできるようにします。', + port: 'ポート', + token: 'アクセストークン', + regenerate: '再生成', + copyConfig: '設定をコピー', + copied: 'コピーしました', + statusRunning: '実行中', + statusStopped: '停止中', + }, }, app: { diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 43aacd77..c08f7698 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -446,6 +446,7 @@ const ko = { printerSupportLimited: '지원 제한', qrRotatedStatic: '회전된 QR 코드는 고정값을 인쇄', qrRotatedModel2: '회전된 QR 코드는 모델 2로 인쇄', + unknownType: '알 수 없는 개체 유형', }, printerSettings: { @@ -465,6 +466,7 @@ const ko = { mediaFeed: '미디어 및 급지', appSettings: '앱', previewSettings: '미리보기', + mcpServer: 'MCP', printQuality: '인쇄 품질', output: '출력', clockTime: '날짜 및 시간', @@ -692,6 +694,18 @@ const ko = { apiHintDesktop: 'Labelary 프리미엄 요금제용입니다. 무료 공개 서비스를 사용하려면 비워 두세요. 키는 운영 체제의 키체인에 저장됩니다.', apiHintWeb: 'Labelary 프리미엄 요금제용입니다. 무료 공개 서비스를 사용하려면 비워 두세요. 키는 이 브라우저에 저장됩니다.', }, + mcp: { + heading: 'MCP 서버', + enable: 'MCP 서버 사용', + hint: 'Claude Desktop 같은 로컬 AI 어시스턴트가 Model Context Protocol을 통해 이 라벨을 읽고 편집할 수 있게 합니다.', + port: '포트', + token: '액세스 토큰', + regenerate: '재생성', + copyConfig: '구성 복사', + copied: '복사됨', + statusRunning: '실행 중', + statusStopped: '중지됨', + }, }, app: { diff --git a/src/locales/lt.ts b/src/locales/lt.ts index a102f476..a4699ba2 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -446,6 +446,7 @@ const lt = { printerSupportLimited: 'Ribotas palaikymas', qrRotatedStatic: 'Pasuktas QR kodas spausdina statinę reikšmę', qrRotatedModel2: 'Pasuktas QR kodas spausdina kaip 2 modelis', + unknownType: 'Nežinomas objekto tipas', }, printerSettings: { @@ -465,6 +466,7 @@ const lt = { mediaFeed: 'Laikmena ir tiekimas', appSettings: 'Programa', previewSettings: 'Peržiūra', + mcpServer: 'MCP', printQuality: 'Spausdinimo kokybė', output: 'Išvestis', clockTime: 'Data ir laikas', @@ -692,6 +694,18 @@ const lt = { apiHintDesktop: 'Labelary premium planams. Palikite tuščią, kad naudotumėte nemokamą viešąją paslaugą. Raktas saugomas operacinės sistemos raktų pakabuke.', apiHintWeb: 'Labelary premium planams. Palikite tuščią, kad naudotumėte nemokamą viešąją paslaugą. Raktas saugomas šioje naršyklėje.', }, + mcp: { + heading: 'MCP serveris', + enable: 'Įjungti MCP serverį', + hint: 'Leidžia vietiniam DI asistentui, pavyzdžiui, „Claude Desktop", skaityti ir redaguoti šią etiketę per „Model Context Protocol".', + port: 'Prievadas', + token: 'Prieigos raktas', + regenerate: 'Sugeneruoti iš naujo', + copyConfig: 'Kopijuoti konfigūraciją', + copied: 'Nukopijuota', + statusRunning: 'Veikia', + statusStopped: 'Sustabdyta', + }, }, app: { diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 0f964566..cecc4f47 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -446,6 +446,7 @@ const lv = { printerSupportLimited: 'Ierobežots atbalsts', qrRotatedStatic: 'Pagriezts QR kods drukā statisku vērtību', qrRotatedModel2: 'Pagriezts QR kods drukā kā 2. modeli', + unknownType: 'Nezināms objekta tips', }, printerSettings: { @@ -465,6 +466,7 @@ const lv = { mediaFeed: 'Materiāls un padeve', appSettings: 'Lietotne', previewSettings: 'Priekšskatījums', + mcpServer: 'MCP', printQuality: 'Drukas kvalitāte', output: 'Izvade', clockTime: 'Datums un laiks', @@ -692,6 +694,18 @@ const lv = { apiHintDesktop: 'Labelary premium plāniem. Atstājiet tukšu, lai izmantotu bezmaksas publisko pakalpojumu. Atslēga tiek glabāta operētājsistēmas atslēgu saišķī.', apiHintWeb: 'Labelary premium plāniem. Atstājiet tukšu, lai izmantotu bezmaksas publisko pakalpojumu. Atslēga tiek glabāta šajā pārlūkprogrammā.', }, + mcp: { + heading: 'MCP serveris', + enable: 'Iespējot MCP serveri', + hint: 'Ļauj vietējam MI asistentam, piemēram, Claude Desktop, lasīt un rediģēt šo uzlīmi, izmantojot Model Context Protocol.', + port: 'Ports', + token: 'Piekļuves marķieris', + regenerate: 'Ģenerēt no jauna', + copyConfig: 'Kopēt konfigurāciju', + copied: 'Nokopēts', + statusRunning: 'Darbojas', + statusStopped: 'Apturēts', + }, }, app: { diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 9d9caa82..2103ed6d 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -446,6 +446,7 @@ const nl = { printerSupportLimited: 'Beperkte ondersteuning', qrRotatedStatic: 'Gedraaide QR-code drukt statische waarde', qrRotatedModel2: 'Gedraaide QR-code drukt als model 2', + unknownType: 'Onbekend objecttype', }, printerSettings: { @@ -465,6 +466,7 @@ const nl = { mediaFeed: 'Materiaal en doorvoer', appSettings: 'App', previewSettings: 'Voorbeeld', + mcpServer: 'MCP', printQuality: 'Afdrukkwaliteit', output: 'Uitvoer', clockTime: 'Datum en tijd', @@ -692,6 +694,18 @@ const nl = { apiHintDesktop: 'Voor Labelary premium-abonnementen. Laat leeg om de gratis openbare dienst te gebruiken. De sleutel wordt opgeslagen in de sleutelhanger van het besturingssysteem.', apiHintWeb: 'Voor Labelary premium-abonnementen. Laat leeg om de gratis openbare dienst te gebruiken. De sleutel wordt in deze browser opgeslagen.', }, + mcp: { + heading: 'MCP-server', + enable: 'MCP-server inschakelen', + hint: 'Laat een lokale AI-assistent zoals Claude Desktop dit label lezen en bewerken via het Model Context Protocol.', + port: 'Poort', + token: 'Toegangstoken', + regenerate: 'Opnieuw genereren', + copyConfig: 'Configuratie kopiëren', + copied: 'Gekopieerd', + statusRunning: 'Actief', + statusStopped: 'Gestopt', + }, }, app: { diff --git a/src/locales/no.ts b/src/locales/no.ts index 4b105d2f..dbe2964c 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -446,6 +446,7 @@ const no = { printerSupportLimited: 'Begrenset støtte', qrRotatedStatic: 'Rotert QR-kode skriver ut statisk verdi', qrRotatedModel2: 'Rotert QR-kode skriver ut som Modell 2', + unknownType: 'Ukjent objekttype', }, printerSettings: { @@ -465,6 +466,7 @@ const no = { mediaFeed: 'Medium og mating', appSettings: 'App', previewSettings: 'Forhåndsvisning', + mcpServer: 'MCP', printQuality: 'Utskriftskvalitet', output: 'Utdata', clockTime: 'Dato og tid', @@ -692,6 +694,18 @@ const no = { apiHintDesktop: 'For Labelary premium-abonnementer. La feltet stå tomt for å bruke den gratis offentlige tjenesten. Nøkkelen lagres i operativsystemets nøkkelring.', apiHintWeb: 'For Labelary premium-abonnementer. La feltet stå tomt for å bruke den gratis offentlige tjenesten. Nøkkelen lagres i denne nettleseren.', }, + mcp: { + heading: 'MCP-server', + enable: 'Aktiver MCP-server', + hint: 'Lar en lokal AI-assistent som Claude Desktop lese og redigere denne etiketten via Model Context Protocol.', + port: 'Port', + token: 'Tilgangstoken', + regenerate: 'Generer på nytt', + copyConfig: 'Kopier konfigurasjon', + copied: 'Kopiert', + statusRunning: 'Kjører', + statusStopped: 'Stoppet', + }, }, app: { diff --git a/src/locales/pl.ts b/src/locales/pl.ts index e1ad46f7..76c9408f 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -446,6 +446,7 @@ const pl = { printerSupportLimited: 'Ograniczone wsparcie', qrRotatedStatic: 'Obrócony kod QR drukuje statyczną wartość', qrRotatedModel2: 'Obrócony kod QR drukuje jako Model 2', + unknownType: 'Nieznany typ obiektu', }, printerSettings: { @@ -465,6 +466,7 @@ const pl = { mediaFeed: 'Nośnik i podawanie', appSettings: 'Aplikacja', previewSettings: 'Podgląd', + mcpServer: 'MCP', printQuality: 'Jakość druku', output: 'Wyjście', clockTime: 'Data i godzina', @@ -692,6 +694,18 @@ const pl = { apiHintDesktop: 'Dla planów premium Labelary. Pozostaw puste, aby korzystać z bezpłatnej usługi publicznej. Klucz jest przechowywany w pęku kluczy systemu operacyjnego.', apiHintWeb: 'Dla planów premium Labelary. Pozostaw puste, aby korzystać z bezpłatnej usługi publicznej. Klucz jest przechowywany w tej przeglądarce.', }, + mcp: { + heading: 'Serwer MCP', + enable: 'Włącz serwer MCP', + hint: 'Umożliwia lokalnemu asystentowi AI, takiemu jak Claude Desktop, odczyt i edycję tej etykiety za pośrednictwem Model Context Protocol.', + port: 'Port', + token: 'Token dostępu', + regenerate: 'Wygeneruj ponownie', + copyConfig: 'Kopiuj konfigurację', + copied: 'Skopiowano', + statusRunning: 'Uruchomiony', + statusStopped: 'Zatrzymany', + }, }, app: { diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 46dbd3f2..a491b757 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -446,6 +446,7 @@ const pt = { printerSupportLimited: 'Suporte limitado', qrRotatedStatic: 'Código QR rotacionado imprime um valor estático', qrRotatedModel2: 'Código QR rotacionado imprime como Modelo 2', + unknownType: 'Tipo de objeto desconhecido', }, printerSettings: { @@ -465,6 +466,7 @@ const pt = { mediaFeed: 'Mídia e avanço', appSettings: 'App', previewSettings: 'Pré-visualização', + mcpServer: 'MCP', printQuality: 'Qualidade de impressão', output: 'Saída', clockTime: 'Data e hora', @@ -692,6 +694,18 @@ const pt = { apiHintDesktop: 'Para os planos premium do Labelary. Deixe vazio para usar o serviço público gratuito. A chave é guardada no porta-chaves do sistema operativo.', apiHintWeb: 'Para os planos premium do Labelary. Deixe vazio para usar o serviço público gratuito. A chave é guardada neste navegador.', }, + mcp: { + heading: 'Servidor MCP', + enable: 'Ativar o servidor MCP', + hint: 'Permite que um assistente de IA local como o Claude Desktop leia e edite esta etiqueta através do Model Context Protocol.', + port: 'Porta', + token: 'Token de acesso', + regenerate: 'Regenerar', + copyConfig: 'Copiar configuração', + copied: 'Copiado', + statusRunning: 'Em execução', + statusStopped: 'Parado', + }, }, app: { diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 7ed7a042..b37914b5 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -446,6 +446,7 @@ const ro = { printerSupportLimited: 'Suport limitat', qrRotatedStatic: 'Codul QR rotit imprimă o valoare statică', qrRotatedModel2: 'Codul QR rotit imprimă ca Modelul 2', + unknownType: 'Tip de obiect necunoscut', }, printerSettings: { @@ -465,6 +466,7 @@ const ro = { mediaFeed: 'Suport și alimentare', appSettings: 'Aplicație', previewSettings: 'Previzualizare', + mcpServer: 'MCP', printQuality: 'Calitate imprimare', output: 'Ieșire', clockTime: 'Data și ora', @@ -692,6 +694,18 @@ const ro = { apiHintDesktop: 'Pentru planurile premium Labelary. Lăsați gol pentru a utiliza serviciul public gratuit. Cheia este stocată în inelul de chei al sistemului de operare.', apiHintWeb: 'Pentru planurile premium Labelary. Lăsați gol pentru a utiliza serviciul public gratuit. Cheia este stocată în acest browser.', }, + mcp: { + heading: 'Server MCP', + enable: 'Activează serverul MCP', + hint: 'Permite unui asistent IA local precum Claude Desktop să citească și să editeze această etichetă prin Model Context Protocol.', + port: 'Port', + token: 'Token de acces', + regenerate: 'Regenerează', + copyConfig: 'Copiază configurația', + copied: 'Copiat', + statusRunning: 'În execuție', + statusStopped: 'Oprit', + }, }, app: { diff --git a/src/locales/sk.ts b/src/locales/sk.ts index e82bc969..80471d17 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -446,6 +446,7 @@ const sk = { printerSupportLimited: 'Obmedzená podpora', qrRotatedStatic: 'Otočený QR kód tlačí statickú hodnotu', qrRotatedModel2: 'Otočený QR kód tlačí ako Model 2', + unknownType: 'Neznámy typ objektu', }, printerSettings: { @@ -465,6 +466,7 @@ const sk = { mediaFeed: 'Médium a posuv', appSettings: 'Aplikácia', previewSettings: 'Náhľad', + mcpServer: 'MCP', printQuality: 'Kvalita tlače', output: 'Výstup', clockTime: 'Dátum a čas', @@ -692,6 +694,18 @@ const sk = { apiHintDesktop: 'Pre prémiové plány Labelary. Ponechajte prázdne na použitie bezplatnej verejnej služby. Kľúč sa ukladá do zväzku kľúčov operačného systému.', apiHintWeb: 'Pre prémiové plány Labelary. Ponechajte prázdne na použitie bezplatnej verejnej služby. Kľúč sa ukladá do tohto prehliadača.', }, + mcp: { + heading: 'Server MCP', + enable: 'Povoliť server MCP', + hint: 'Umožňuje miestnemu asistentovi AI, ako je Claude Desktop, čítať a upravovať tento štítok prostredníctvom Model Context Protocol.', + port: 'Port', + token: 'Prístupový token', + regenerate: 'Vygenerovať znova', + copyConfig: 'Kopírovať konfiguráciu', + copied: 'Skopírované', + statusRunning: 'Beží', + statusStopped: 'Zastavené', + }, }, app: { diff --git a/src/locales/sl.ts b/src/locales/sl.ts index c984056f..1bba8705 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -446,6 +446,7 @@ const sl = { printerSupportLimited: 'Omejena podpora', qrRotatedStatic: 'Zasukana QR koda natisne statično vrednost', qrRotatedModel2: 'Zasukana QR koda natisne kot Model 2', + unknownType: 'Neznana vrsta predmeta', }, printerSettings: { @@ -465,6 +466,7 @@ const sl = { mediaFeed: 'Medij in podajanje', appSettings: 'Aplikacija', previewSettings: 'Predogled', + mcpServer: 'MCP', printQuality: 'Kakovost tiskanja', output: 'Izhod', clockTime: 'Datum in čas', @@ -692,6 +694,18 @@ const sl = { apiHintDesktop: 'Za premium načrte Labelary. Pustite prazno za uporabo brezplačne javne storitve. Ključ se shrani v zbirko ključev operacijskega sistema.', apiHintWeb: 'Za premium načrte Labelary. Pustite prazno za uporabo brezplačne javne storitve. Ključ se shrani v ta brskalnik.', }, + mcp: { + heading: 'Strežnik MCP', + enable: 'Omogoči strežnik MCP', + hint: 'Lokalnemu pomočniku UI, kot je Claude Desktop, omogoča branje in urejanje te etikete prek Model Context Protocol.', + port: 'Vrata', + token: 'Žeton za dostop', + regenerate: 'Znova ustvari', + copyConfig: 'Kopiraj konfiguracijo', + copied: 'Kopirano', + statusRunning: 'Se izvaja', + statusStopped: 'Zaustavljeno', + }, }, app: { diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 0914030a..d559b76c 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -446,6 +446,7 @@ const sr = { printerSupportLimited: 'Ограничена подршка', qrRotatedStatic: 'Ротирани QR код штампа статичну вредност', qrRotatedModel2: 'Ротирани QR код штампа као Модел 2', + unknownType: 'Непозната врста објекта', }, printerSettings: { @@ -465,6 +466,7 @@ const sr = { mediaFeed: 'Медијум и увлачење', appSettings: 'Апликација', previewSettings: 'Pregled', + mcpServer: 'MCP', printQuality: 'Квалитет штампе', output: 'Излаз', clockTime: 'Датум и време', @@ -692,6 +694,18 @@ const sr = { apiHintDesktop: 'За Labelary премијум планове. Оставите празно да бисте користили бесплатну јавну услугу. Кључ се чува у привеску за кључеве оперативног система.', apiHintWeb: 'За Labelary премијум планове. Оставите празно да бисте користили бесплатну јавну услугу. Кључ се чува у овом прегледачу.', }, + mcp: { + heading: 'MCP server', + enable: 'Omogući MCP server', + hint: 'Omogućava lokalnom AI asistentu poput Claude Desktop da čita i uređuje ovu etiketu putem Model Context Protocol.', + port: 'Port', + token: 'Pristupni token', + regenerate: 'Ponovo generiši', + copyConfig: 'Kopiraj konfiguraciju', + copied: 'Kopirano', + statusRunning: 'Radi', + statusStopped: 'Zaustavljeno', + }, }, app: { diff --git a/src/locales/sv.ts b/src/locales/sv.ts index a0a5d663..f5e49100 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -446,6 +446,7 @@ const sv = { printerSupportLimited: 'Begränsat stöd', qrRotatedStatic: 'Roterad QR-kod skriver ut statiskt värde', qrRotatedModel2: 'Roterad QR-kod skriver ut som Modell 2', + unknownType: 'Okänd objekttyp', }, printerSettings: { @@ -465,6 +466,7 @@ const sv = { mediaFeed: 'Material och matning', appSettings: 'App', previewSettings: 'Förhandsvisning', + mcpServer: 'MCP', printQuality: 'Utskriftskvalitet', output: 'Utmatning', clockTime: 'Datum och tid', @@ -692,6 +694,18 @@ const sv = { apiHintDesktop: 'För Labelary premium-abonnemang. Lämna tomt för att använda den kostnadsfria offentliga tjänsten. Nyckeln lagras i operativsystemets nyckelring.', apiHintWeb: 'För Labelary premium-abonnemang. Lämna tomt för att använda den kostnadsfria offentliga tjänsten. Nyckeln lagras i den här webbläsaren.', }, + mcp: { + heading: 'MCP-server', + enable: 'Aktivera MCP-server', + hint: 'Låter en lokal AI-assistent som Claude Desktop läsa och redigera denna etikett via Model Context Protocol.', + port: 'Port', + token: 'Åtkomsttoken', + regenerate: 'Generera på nytt', + copyConfig: 'Kopiera konfiguration', + copied: 'Kopierad', + statusRunning: 'Körs', + statusStopped: 'Stoppad', + }, }, app: { diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 36ac613d..e3832c94 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -446,6 +446,7 @@ const tr = { printerSupportLimited: 'Sınırlı destek', qrRotatedStatic: 'Döndürülmüş QR Kodu statik değer basar', qrRotatedModel2: 'Döndürülmüş QR Kodu Model 2 olarak basar', + unknownType: 'Bilinmeyen nesne türü', }, printerSettings: { @@ -465,6 +466,7 @@ const tr = { mediaFeed: 'Ortam ve besleme', appSettings: 'Uygulama', previewSettings: 'Önizleme', + mcpServer: 'MCP', printQuality: 'Baskı kalitesi', output: 'Çıktı', clockTime: 'Tarih ve saat', @@ -692,6 +694,18 @@ const tr = { apiHintDesktop: 'Labelary premium planları için. Ücretsiz genel hizmeti kullanmak için boş bırakın. Anahtar, işletim sisteminin anahtar zincirinde saklanır.', apiHintWeb: 'Labelary premium planları için. Ücretsiz genel hizmeti kullanmak için boş bırakın. Anahtar bu tarayıcıda saklanır.', }, + mcp: { + heading: 'MCP sunucusu', + enable: 'MCP sunucusunu etkinleştir', + hint: 'Claude Desktop gibi yerel bir yapay zekâ asistanının bu etiketi Model Context Protocol üzerinden okumasını ve düzenlemesini sağlar.', + port: 'Bağlantı noktası', + token: 'Erişim belirteci', + regenerate: 'Yeniden oluştur', + copyConfig: 'Yapılandırmayı kopyala', + copied: 'Kopyalandı', + statusRunning: 'Çalışıyor', + statusStopped: 'Durduruldu', + }, }, app: { diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index e8df57ea..7c358c55 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -446,6 +446,7 @@ const zhHans = { printerSupportLimited: '支持有限', qrRotatedStatic: '旋转的二维码打印静态值', qrRotatedModel2: '旋转的二维码以型号2打印', + unknownType: '未知的对象类型', }, printerSettings: { @@ -465,6 +466,7 @@ const zhHans = { mediaFeed: '介质与走纸', appSettings: '应用', previewSettings: '预览', + mcpServer: 'MCP', printQuality: '打印质量', output: '输出', clockTime: '日期与时间', @@ -692,6 +694,18 @@ const zhHans = { apiHintDesktop: '适用于 Labelary 高级套餐。留空则使用免费的公共服务。密钥存储在操作系统的密钥链中。', apiHintWeb: '适用于 Labelary 高级套餐。留空则使用免费的公共服务。密钥存储在此浏览器中。', }, + mcp: { + heading: 'MCP 服务器', + enable: '启用 MCP 服务器', + hint: '允许 Claude Desktop 等本地 AI 助手通过 Model Context Protocol 读取和编辑此标签。', + port: '端口', + token: '访问令牌', + regenerate: '重新生成', + copyConfig: '复制配置', + copied: '已复制', + statusRunning: '运行中', + statusStopped: '已停止', + }, }, app: { diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 41752d5d..7fbaf8d1 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -446,6 +446,7 @@ const zhHant = { printerSupportLimited: '支援有限', qrRotatedStatic: '旋轉的二維碼列印靜態值', qrRotatedModel2: '旋轉的二維碼以型號2列印', + unknownType: '未知的物件類型', }, printerSettings: { @@ -465,6 +466,7 @@ const zhHant = { mediaFeed: '介質與送紙', appSettings: '應用程式', previewSettings: '預覽', + mcpServer: 'MCP', printQuality: '列印品質', output: '輸出', clockTime: '日期與時間', @@ -692,6 +694,18 @@ const zhHant = { apiHintDesktop: '適用於 Labelary 進階方案。留空則使用免費的公用服務。金鑰儲存在作業系統的鑰匙圈中。', apiHintWeb: '適用於 Labelary 進階方案。留空則使用免費的公用服務。金鑰儲存在此瀏覽器中。', }, + mcp: { + heading: 'MCP 伺服器', + enable: '啟用 MCP 伺服器', + hint: '允許 Claude Desktop 等本機 AI 助理透過 Model Context Protocol 讀取及編輯此標籤。', + port: '連接埠', + token: '存取權杖', + regenerate: '重新產生', + copyConfig: '複製設定', + copied: '已複製', + statusRunning: '執行中', + statusStopped: '已停止', + }, }, app: { diff --git a/src/main.tsx b/src/main.tsx index 5eca0a4d..3cb9970b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,6 +11,7 @@ import '@fontsource/ibm-plex-mono/600.css' import './index.css' import App from './App.tsx' import { useLabelStore } from './store/labelStore' +import { mcpServerStatus, startMcpServer } from './lib/mcpServer' const rootEl = document.getElementById('root'); if (!rootEl) throw new Error('Root element not found'); @@ -25,10 +26,28 @@ const LOCALE_BOOT_WAIT_MS = 250; // the app renders with en and the dictionary swaps in when it arrives // (applyLocale keeps running and never rejects). async function bootstrap() { - const { locale, applyLocale, hydrateLabelaryApiKey } = useLabelStore.getState(); + const { + locale, + applyLocale, + hydrateLabelaryApiKey, + mcpServerEnabled, + mcpServerPort, + mcpServerToken, + } = useLabelStore.getState(); // Load the API key from the OS credential store into memory before any // preview can fire; fire-and-forget so a slow keychain never delays paint. void hydrateLabelaryApiKey(); + // Stamp the build's sidecar capability so the settings rail and MCP tab can + // read it synchronously; fire-and-forget like the key hydration above. + void mcpServerStatus() + .then((s) => useLabelStore.getState().setMcpSidecarAvailable(s.available)) + .catch(() => undefined); + // Bring the opt-in MCP server up on launch so an assistant can reach it + // without opening settings; swallow errors so a failure never blocks paint + // (the settings tab re-attempts when the build has the sidecar). + if (mcpServerEnabled) { + void startMcpServer({ port: mcpServerPort, token: mcpServerToken }).catch(() => undefined); + } let timeoutId: number | undefined; await Promise.race([ applyLocale(locale), diff --git a/src/store/labelStore.tripwire.test.ts b/src/store/labelStore.tripwire.test.ts index 307a15e6..9fe33119 100644 --- a/src/store/labelStore.tripwire.test.ts +++ b/src/store/labelStore.tripwire.test.ts @@ -11,6 +11,9 @@ const EXPECTED_PERSIST_KEYS = [ 'labelaryHost', 'labelaryNoticeAcknowledged', 'locale', + 'mcpServerEnabled', + 'mcpServerPort', + 'mcpServerToken', 'pages', 'paletteRows', 'paletteView', diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 803435be..e9d285f1 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -416,6 +416,9 @@ export const persistPartialize = (state: LabelState) => ({ paletteRows: state.paletteRows, paletteView: state.paletteView, showZplCommands: state.showZplCommands, + mcpServerEnabled: state.mcpServerEnabled, + mcpServerPort: state.mcpServerPort, + mcpServerToken: state.mcpServerToken, variables: state.variables, csvMapping: state.csvMapping, }); diff --git a/src/store/slices/uiSlice.ts b/src/store/slices/uiSlice.ts index 85513d27..d9d3bd41 100644 --- a/src/store/slices/uiSlice.ts +++ b/src/store/slices/uiSlice.ts @@ -10,6 +10,7 @@ import { import type { LabelState } from '../labelStore'; import { defaultPaletteRows } from '../../registry/paletteTypes'; import { getCredential, setCredential } from '../../lib/credentialStore'; +import { generateMcpToken, stopMcpServer } from '../../lib/mcpServer'; import { selectEffectivePreviewProvider } from '../labelStore.selectors'; /** Credential-store account name for the Labelary API key. */ @@ -52,6 +53,7 @@ export type PreviewProvider = 'labelary' | 'printer'; export type PrinterSettingsTab = | 'appSettings' | 'previewSettings' + | 'mcpServer' | 'mediaFeed' | 'printQuality' | 'output' @@ -129,6 +131,18 @@ export interface UiSlice { /** Power-user opt-in: show the emitted ZPL command next to each properties * field. Persisted UI preference; default off so beginners aren't burdened. */ showZplCommands: boolean; + /** Desktop-only opt-in for the local MCP loopback server. Off by default so + * no port opens unless the user asks; persisted like other prefs. */ + mcpServerEnabled: boolean; + mcpServerPort: number; + /** Bearer token for the loopback server, generated on first enable and reused + * so a Claude Desktop config the user already wired up keeps working. + * Persisted but out of the settings-reset defaults (see labelaryHost). */ + mcpServerToken: string; + /** Build capability from mcp_status: whether this build can spawn the MCP + * sidecar (false on web and sidecar-less releases). Stamped at boot; + * null until the first status lands. Transient. */ + mcpSidecarAvailable: boolean | null; /** Right-sidebar tab. Lives in the store so canvas interactions can * drive the panel (e.g. double-click a text field). Transient; not @@ -187,6 +201,13 @@ export interface UiSlice { setPaletteView: (view: PaletteView) => void; togglePaletteEditing: () => void; setShowZplCommands: (show: boolean) => void; + /** Persist the enable flag; generates a token on first enable so the section + * never starts the server tokenless. Does not start/stop the server itself + * (that side effect is orchestrated by the useMcpServer hook). */ + setMcpServerEnabled: (enabled: boolean) => void; + setMcpServerPort: (port: number) => void; + regenerateMcpToken: () => void; + setMcpSidecarAvailable: (available: boolean) => void; setSidebarTab: (tab: SidebarTab) => void; setBlockDragMode: (mode: BlockDragMode) => void; setAlignRef: (ref: AlignSelectionRef) => void; @@ -230,8 +251,14 @@ type UiPrefs = Pick< | 'paletteView' | 'paletteEditing' | 'showZplCommands' + | 'mcpServerEnabled' + | 'mcpServerPort' >; +/** Default port for the MCP loopback server. High and IANA-unassigned, so it + * is unlikely to collide with a service already bound on the loopback. */ +export const DEFAULT_MCP_PORT = 4923; + /** Defaults `resetSettings` restores. Shared with initial state so the `Pick` * turns a forgotten entry into a compile error. Excludes `locale` (reset keeps * the language). */ @@ -246,6 +273,8 @@ function defaultUiPrefs(): UiPrefs { paletteView: 'flat', paletteEditing: false, showZplCommands: false, + mcpServerEnabled: false, + mcpServerPort: DEFAULT_MCP_PORT, }; } @@ -261,6 +290,10 @@ export const createUiSlice: StateCreator = (set, ge labelaryHost: '', labelaryApiKey: '', labelaryApiKeyLoaded: false, + // Token is persisted but out of `defaultUiPrefs`, so a settings reset keeps + // it (regenerating would break a Claude Desktop config already pointing here). + mcpServerToken: '', + mcpSidecarAvailable: null, sidebarTab: 'properties', blockDragMode: 'frame', alignRef: 'selection', @@ -362,6 +395,9 @@ export const createUiSlice: StateCreator = (set, ge }); // Reset drops Labelary consent; end any live preview so it doesn't linger. get().exitPreviewMode(); + // Reset also drops the MCP opt-in; a running sidecar must not outlive it. + // stopMcpServer no-ops in the web build; catch only guards a real invoke failure. + void stopMcpServer().catch(() => undefined); }, setCanvasSettings: (settings) => set((state) => ({ canvasSettings: { ...state.canvasSettings, ...settings } })), @@ -389,6 +425,15 @@ export const createUiSlice: StateCreator = (set, ge setPaletteView: (view) => set({ paletteView: view }), togglePaletteEditing: () => set((state) => ({ paletteEditing: !state.paletteEditing })), setShowZplCommands: (show) => set({ showZplCommands: show }), + setMcpServerEnabled: (enabled) => + set((state) => ({ + mcpServerEnabled: enabled, + mcpServerToken: + enabled && !state.mcpServerToken ? generateMcpToken() : state.mcpServerToken, + })), + setMcpServerPort: (port) => set({ mcpServerPort: port }), + regenerateMcpToken: () => set({ mcpServerToken: generateMcpToken() }), + setMcpSidecarAvailable: (available) => set({ mcpSidecarAvailable: available }), setSidebarTab: (tab) => set({ sidebarTab: tab }), setBlockDragMode: (mode) => set({ blockDragMode: mode }), setAlignRef: (ref) => set({ alignRef: ref }),