diff --git a/legacy/types.ts b/legacy/types.ts new file mode 100644 index 0000000..ca5f43c --- /dev/null +++ b/legacy/types.ts @@ -0,0 +1,62 @@ + +export type ViewState = 'Dashboard' | 'Live!' | 'Ausschreibungen' | 'Kunden' | 'Experten' | 'Laufende Projekte' | 'Organigramm' | 'Einstellungen'; + +export interface Expert { + id: string; + name: string; + role: string; + email: string; + phone: string; + linkedin: string; + status: 'Interessiert' | 'Interview' | 'Verfügbar' | 'Gebucht'; + avatar: string; + company?: string; + bio?: string; + tags?: string[]; +} + +export interface Contact { + id: string; + customer_id: string; + name: string; + role: string; + email: string; + phone: string; + avatar?: string; +} + +export interface Customer { + id: string; + company: string; + name?: string; + logo?: string; + notes?: string; + contacts?: Contact[]; +} + +export interface Tender { + id: string; + source: string; + title: string; + applicantsCount: number; + status: 'Aktiv' | 'Ausgelaufen' | 'Archiviert' | 'Inaktiv'; + kanbanStatus: KanbanColumn; + details?: string; + notes?: string; + link?: string; + selectedCustomer?: Customer; + selectedExperts?: Expert[]; +} + +export type KanbanColumn = 'Ausschreibung' | 'Experten sichten' | 'Interview' | 'Kunden Interview' | 'Vertragsgestaltung' | 'Abschluss' | 'Geschlossen'; + +export interface Project { + id: string; + title: string; + customer: string; + expert: string; + start: string; + end: string; + status: 'Aktiv' | 'Nicht aktiv' | 'Abgeschlossen'; + notes?: string; +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..a2112c9 --- /dev/null +++ b/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "Icon Expert CRM", + "description": "A high-end CRM system for expert placement agencies, featuring Kanban pipelines, tender management, and AI-assisted matching.", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bc118c5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3426 @@ +{ + "name": "icon-crm", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "icon-crm", + "version": "1.0.0", + "dependencies": { + "@huggingface/transformers": "^3.8.1", + "@tailwindcss/vite": "^4.2.0", + "d3-force": "^3.0.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwindcss": "^4.2.0" + }, + "devDependencies": { + "@types/d3-force": "^3.0.10", + "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.10.tgz", + "integrity": "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/transformers": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", + "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.3", + "onnxruntime-node": "1.21.0", + "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", + "sharp": "^0.34.1" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz", + "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz", + "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz", + "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz", + "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz", + "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz", + "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz", + "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz", + "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz", + "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz", + "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz", + "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz", + "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz", + "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz", + "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz", + "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz", + "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz", + "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz", + "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz", + "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz", + "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz", + "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz", + "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz", + "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz", + "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz", + "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", + "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz", + "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "global-agent": "^3.0.0", + "onnxruntime-common": "1.21.0", + "tar": "^7.0.1" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz", + "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.2", + "@rollup/rollup-android-arm64": "4.63.2", + "@rollup/rollup-darwin-arm64": "4.63.2", + "@rollup/rollup-darwin-x64": "4.63.2", + "@rollup/rollup-freebsd-arm64": "4.63.2", + "@rollup/rollup-freebsd-x64": "4.63.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.2", + "@rollup/rollup-linux-arm-musleabihf": "4.63.2", + "@rollup/rollup-linux-arm64-gnu": "4.63.2", + "@rollup/rollup-linux-arm64-musl": "4.63.2", + "@rollup/rollup-linux-loong64-gnu": "4.63.2", + "@rollup/rollup-linux-loong64-musl": "4.63.2", + "@rollup/rollup-linux-ppc64-gnu": "4.63.2", + "@rollup/rollup-linux-ppc64-musl": "4.63.2", + "@rollup/rollup-linux-riscv64-gnu": "4.63.2", + "@rollup/rollup-linux-riscv64-musl": "4.63.2", + "@rollup/rollup-linux-s390x-gnu": "4.63.2", + "@rollup/rollup-linux-x64-gnu": "4.63.2", + "@rollup/rollup-linux-x64-musl": "4.63.2", + "@rollup/rollup-openbsd-x64": "4.63.2", + "@rollup/rollup-openharmony-arm64": "4.63.2", + "@rollup/rollup-win32-arm64-msvc": "4.63.2", + "@rollup/rollup-win32-ia32-msvc": "4.63.2", + "@rollup/rollup-win32-x64-gnu": "4.63.2", + "@rollup/rollup-win32-x64-msvc": "4.63.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a1dd33a --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "icon-crm", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@huggingface/transformers": "^3.8.1", + "@tailwindcss/vite": "^4.2.0", + "d3-force": "^3.0.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwindcss": "^4.2.0" + }, + "devDependencies": { + "@types/d3-force": "^3.0.10", + "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..b535f8c Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..ab4ef31 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..0580ed8 Binary files /dev/null and b/public/logo.png differ diff --git a/public/themes/halflife-header.svg b/public/themes/halflife-header.svg new file mode 100644 index 0000000..e505897 --- /dev/null +++ b/public/themes/halflife-header.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/halflife-sidebar.svg b/public/themes/halflife-sidebar.svg new file mode 100644 index 0000000..d6016e8 --- /dev/null +++ b/public/themes/halflife-sidebar.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/pixel-header.svg b/public/themes/pixel-header.svg new file mode 100644 index 0000000..7e070f9 --- /dev/null +++ b/public/themes/pixel-header.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/themes/pixel-sidebar.svg b/public/themes/pixel-sidebar.svg new file mode 100644 index 0000000..e0173e4 --- /dev/null +++ b/public/themes/pixel-sidebar.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..4c54264 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,54 @@ +# ICON CRM Backend +# Als .env kopieren und Werte setzen. .env gehoert nicht ins Repository. + +PORT=5007 +# Nur lokal binden - von aussen erreichbar ist die Anwendung ausschliesslich +# ueber den Apache-Reverse-Proxy auf icrm.icon-experts.de. +HOST=127.0.0.1 + +DB_HOST=localhost +DB_PORT=3306 +DB_USER=iconcrm +DB_PASSWORD=changeme +DB_NAME=icon_CRM + +# Signiert die JWT-Sitzungen. Langer Zufallswert, z. B.: +# node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" +JWT_SECRET=changeme-langer-zufallswert +JWT_EXPIRES_IN=12h + +# Wird beim ersten Start angelegt, solange noch kein Benutzer existiert. +# Ohne ADMIN_PASSWORD erzeugt das Backend eines und schreibt es einmalig ins Log. +ADMIN_USERNAME=Admin +ADMIN_NAME=Administrator +ADMIN_EMAIL=admin@icon-experts.de +ADMIN_PASSWORD= + +# Erlaubte Herkunft fuer den lokalen Vite-Dev-Server. +CORS_ORIGINS=http://localhost:3101,http://127.0.0.1:3101 + +# Ablage der hochgeladenen Lebenslaeufe. Leer = /../uploads. +UPLOAD_DIR=/var/www/icon_CRM/uploads + +# --------------------------------------------------------------------------- +# KI (CV-Auslesung und Schreibhilfe fuer Ausschreibungen) +# --------------------------------------------------------------------------- +# Der Schluessel bleibt auf dem Server - der Browser spricht nie direkt mit +# OpenAI. Fehlt er, laeuft das CRM normal weiter und blendet die KI-Knoepfe aus. +# +# Hinweis zum Modell: gpt-5.6-luna akzeptiert KEIN eigenes "temperature"; +# ai.js schickt deshalb nur den Standardwert. +OPENAI_API_KEY= +OPENAI_MODEL=gpt-5.6-luna + +# --------------------------------------------------------------------------- +# Lesender Zugriff auf die Kontakte des perinco CRM +# --------------------------------------------------------------------------- +# Das ICON CRM zeigt die perinco-Kontakte zusaetzlich an - gekennzeichnet und +# ausschliesslich lesend. Gespeichert wird nichts. +# +# Der Token steht drueben in server/.env als ICON_API_TOKEN und muss +# uebereinstimmen. Fehlt einer der beiden Werte, bleibt der Schalter in der +# Oberflaeche aus und das CRM laeuft normal weiter. +PERINCO_API_URL=https://pcrm.perinco.de +PERINCO_API_TOKEN= diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..332982e --- /dev/null +++ b/server/package.json @@ -0,0 +1,23 @@ +{ + "name": "icon-crm-backend", + "version": "1.0.0", + "private": true, + "description": "REST-Backend fuer das ICON CRM (MariaDB icon_CRM)", + "main": "src/server.js", + "type": "module", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "mammoth": "^1.8.0", + "multer": "^2.0.2", + "mysql2": "^3.11.5", + "pdf-parse": "^1.1.1" + } +} diff --git a/server/src/ai.js b/server/src/ai.js new file mode 100644 index 0000000..ba3834c --- /dev/null +++ b/server/src/ai.js @@ -0,0 +1,195 @@ +import { getSetting, setSetting } from './db.js'; + +/* + * Anbindung an OpenAI. + * + * Bewusst ohne SDK: gebraucht werden genau zwei Aufrufe (Text und JSON), und + * `fetch` ist in Node 22 eingebaut. Ein Paket weniger, das gepflegt werden muss. + * + * Der Schluessel verlaesst den Server nie in Richtung Browser - jede KI-Anfrage + * laeuft ueber das Backend, und nach aussen gibt es ihn nur verkuerzt + * ("sk-proj-...a1b2"). + * + * Er kann an zwei Stellen stehen: + * + * 1. in der Datenbank (icon_settings), gepflegt unter Einstellungen -> KI + * 2. in server/.env als OPENAI_API_KEY + * + * Die Datenbank gewinnt. Grund: ein Schluessel laeuft ab oder wird gewechselt, + * und dann soll niemand dafuer eine Datei auf dem Server bearbeiten und einen + * Dienst neu starten muessen. Die .env bleibt als Startwert und als Weg, das + * System ohne Oberflaeche in Gang zu bringen. + */ + +const ENDPOINT = 'https://api.openai.com/v1/chat/completions'; + +/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */ +const TIMEOUT_MS = 120_000; + +export const DEFAULT_MODEL = 'gpt-5.6-luna'; + +const KEY_SETTING = 'openai_api_key'; +const MODEL_SETTING = 'openai_model'; + +/* + * Im Speicher gehalten, damit nicht jede Anfrage die Datenbank fragt. Gefuellt + * beim Start (loadAiConfig) und bei jeder Aenderung. + */ +const config = { + key: process.env.OPENAI_API_KEY || '', + model: process.env.OPENAI_MODEL || DEFAULT_MODEL, + /** Woher der gueltige Schluessel stammt - fuer die Anzeige. */ + source: process.env.OPENAI_API_KEY ? 'env' : 'none', +}; + +/** Beim Start aufrufen, sobald die Datenbank steht. */ +export async function loadAiConfig() { + try { + const [key, model] = await Promise.all([getSetting(KEY_SETTING), getSetting(MODEL_SETTING)]); + if (key) { + config.key = key; + config.source = 'db'; + } + if (model) config.model = model; + } catch (err) { + // Ohne Datenbank bleibt es bei der .env - das Backend soll trotzdem starten. + console.warn('[ICON CRM] KI-Einstellungen nicht lesbar:', err.message); + } +} + +export function aiConfigured() { + return Boolean(config.key); +} + +export function aiModel() { + return config.model; +} + +/** + * Zustand fuer die Oberflaeche. Der Schluessel wird nur verkuerzt gezeigt - + * genug, um zwei Schluessel auseinanderzuhalten, zu wenig, um ihn zu benutzen. + */ +export function aiStatus() { + return { + configured: aiConfigured(), + model: config.model, + defaultModel: DEFAULT_MODEL, + source: config.source, + masked: maskKey(config.key), + /** Steht in der .env noch einer? Dann greift er, wenn der aus der DB faellt. */ + envAvailable: Boolean(process.env.OPENAI_API_KEY), + }; +} + +function maskKey(key) { + if (!key) return null; + return key.length <= 12 ? '****' : `${key.slice(0, 7)}…${key.slice(-4)}`; +} + +/** + * Schluessel setzen oder entfernen. + * + * Entfernen heisst: der Wert in der Datenbank faellt weg. Steht in der .env + * einer, gilt wieder dieser - das ist kein Versehen, sondern der Rueckfall. + */ +export async function saveAiKey(key) { + const value = String(key ?? '').trim(); + await setSetting(KEY_SETTING, value || null); + if (value) { + config.key = value; + config.source = 'db'; + } else { + config.key = process.env.OPENAI_API_KEY || ''; + config.source = config.key ? 'env' : 'none'; + } +} + +/** Modell setzen; leer heisst zurueck zur Vorgabe. */ +export async function saveAiModel(model) { + const value = String(model ?? '').trim(); + await setSetting(MODEL_SETTING, value || null); + config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL; +} + +class AiError extends Error { + constructor(message, status = 502) { + super(message); + this.status = status; + } +} + +/** + * Ein Aufruf an das Modell. + * + * `temperature` wird bewusst NICHT mitgeschickt: gpt-5.6-luna laesst nur den + * Standardwert zu und weist jeden anderen mit 400 ab. + * + * @param {object} options + * @param {string} options.system Rollenanweisung + * @param {string} options.user Eigentliche Anfrage + * @param {boolean} [options.json] true erzwingt eine JSON-Antwort + * @param {number} [options.maxTokens] + * @returns {Promise} der Antworttext + */ +export async function askModel({ system, user, json = false, maxTokens }) { + if (!aiConfigured()) { + throw new AiError( + 'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI, oder OPENAI_API_KEY in server/.env).', + 503 + ); + } + + const body = { + model: config.model, + messages: [ + ...(system ? [{ role: 'system', content: system }] : []), + { role: 'user', content: user }, + ], + }; + if (json) body.response_format = { type: 'json_object' }; + if (maxTokens) body.max_completion_tokens = maxTokens; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + + let response; + try { + response = await fetch(ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${config.key}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (err) { + if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504); + throw new AiError('OpenAI ist gerade nicht erreichbar.', 502); + } finally { + clearTimeout(timer); + } + + const payload = await response.json().catch(() => null); + if (!response.ok) { + // Die Meldung von OpenAI ist meist praezise ("model not found", "quota") - + // sie hilft mehr als ein eigener Text, enthaelt aber nie den Schluessel. + throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502); + } + + const text = payload?.choices?.[0]?.message?.content; + if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502); + return text; +} + +/** Wie askModel, gibt aber das geparste JSON zurueck. */ +export async function askModelJson(options) { + const text = await askModel({ ...options, json: true }); + try { + return JSON.parse(text); + } catch { + throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502); + } +} + +export { AiError }; diff --git a/server/src/cvPhoto.js b/server/src/cvPhoto.js new file mode 100644 index 0000000..ee54660 --- /dev/null +++ b/server/src/cvPhoto.js @@ -0,0 +1,280 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import zlib from 'node:zlib'; + +/* + * Das Bewerbungsfoto aus einem Lebenslauf holen. + * + * Viele Lebenslaeufe tragen oben rechts ein Portrait. Es von Hand nachzupflegen + * ist Fleissarbeit, die niemand macht - danach steht im CRM eine Kachelwand aus + * Initialen, obwohl das Foto in der Datei lag. + * + * Bewusst ohne zusaetzliches Paket: + * + * DOCX ist ein ZIP. Gebraucht wird genau eine Sache - die Dateien unter + * `word/media/` herausloesen. Das leistet das eingebaute zlib + * (`inflateRawSync`) zusammen mit dem Lesen des Zentralverzeichnisses. + * PDF speichert JPEG-Bilder mit `/DCTDecode` unveraendert im Datenstrom. + * Sie lassen sich deshalb an ihren eigenen Markierungen (FFD8 … FFD9) + * aus der Datei schneiden. Bilder in anderen Kodierungen (FlateDecode, + * also rohe Bitmaps) bleiben aussen vor: sie muessten neu kodiert + * werden, und dafuer braeuchte es eine Bildbibliothek. + * + * Verkleinert wird hier NICHT. Das macht der Browser beim Uebernehmen - dort + * liegt schon die Skalierung fuer alle anderen Fotos (src/lib/imageCompress.ts), + * und ein zweiter Weg mit eigener Bibliothek auf dem Server waere eine zweite + * Stelle, an der sich Ergebnisse unterscheiden koennen. + */ + +/** Groesser wird nicht uebernommen - das waere ein Scan der ganzen Seite. */ +const MAX_BYTES = 6 * 1024 * 1024; + +/** Kleiner ist kein Portrait, sondern ein Symbol, ein Logo oder eine Linie. */ +const MIN_SIDE = 100; + +/** + * Sucht das wahrscheinlichste Portrait in der Datei. + * + * @param {string} filePath absoluter Pfad + * @param {string} originalName fuer die Endung + * @returns {Promise<{dataUrl: string, mime: string, width: number, height: number, bytes: number} | null>} + */ +export async function extractPhoto(filePath, originalName) { + const ext = path.extname(originalName || filePath).toLowerCase(); + + let candidates = []; + try { + const buffer = await fs.readFile(filePath); + if (ext === '.docx') candidates = imagesFromDocx(buffer); + else if (ext === '.pdf') candidates = imagesFromPdf(buffer); + } catch { + // Ein unleserliches Bild darf den Import nicht aufhalten - der Text ist + // das Wesentliche, das Foto die Zugabe. + return null; + } + + const best = pickPortrait(candidates); + if (!best) return null; + + return { + dataUrl: `data:${best.mime};base64,${best.data.toString('base64')}`, + mime: best.mime, + width: best.width, + height: best.height, + bytes: best.data.length, + }; +} + +/** + * Welches der gefundenen Bilder ist das Portrait? + * + * Die Reihenfolge in der Datei hilft nicht: in DOCX steht das Firmenlogo oft + * vor dem Foto. Entschieden wird deshalb ueber die Form. + * + * * zu klein - Symbol, Trennlinie, Aufzaehlungszeichen + * * sehr breit oder flach - Kopfzeile, Banner, Diagramm + * * hochkant bis leicht quer bleibt uebrig; davon das groesste + * + * Hochformat gewinnt gegen Querformat, auch wenn es kleiner ist: ein Portrait + * ist so gut wie immer hoeher als breit, ein Banner nie. + */ +function pickPortrait(images) { + const usable = images.filter((image) => { + if (!image.width || !image.height) return false; + if (image.data.length > MAX_BYTES) return false; + if (image.width < MIN_SIDE || image.height < MIN_SIDE) return false; + const ratio = image.width / image.height; + return ratio >= 0.4 && ratio <= 1.6; + }); + + if (usable.length === 0) return null; + + return usable.sort((a, b) => { + const portraitA = a.height >= a.width ? 1 : 0; + const portraitB = b.height >= b.width ? 1 : 0; + if (portraitA !== portraitB) return portraitB - portraitA; + return b.width * b.height - a.width * a.height; + })[0]; +} + +/* ----------------------------------------------------------------- DOCX */ + +/** + * Die Bilder unter `word/media/` aus dem ZIP holen. + * + * Gelesen wird das Zentralverzeichnis am Dateiende - nur dort stehen die + * Namen zuverlaessig. Die lokalen Kopfsaetze davor koennen Groessenangaben + * nachreichen (Bit 3 der Flags), das Zentralverzeichnis nie. + */ +function imagesFromDocx(buffer) { + const images = []; + const endOffset = findEndOfCentralDirectory(buffer); + if (endOffset < 0) return images; + + const entryCount = buffer.readUInt16LE(endOffset + 10); + let offset = buffer.readUInt32LE(endOffset + 16); + + for (let index = 0; index < entryCount; index += 1) { + if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) break; + + const method = buffer.readUInt16LE(offset + 10); + const compressedSize = buffer.readUInt32LE(offset + 20); + const nameLength = buffer.readUInt16LE(offset + 28); + const extraLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const localOffset = buffer.readUInt32LE(offset + 42); + const name = buffer.toString('utf8', offset + 46, offset + 46 + nameLength); + + offset += 46 + nameLength + extraLength + commentLength; + + if (!/^word\/media\//i.test(name)) continue; + if (compressedSize === 0 || compressedSize > MAX_BYTES) continue; + + const data = readLocalEntry(buffer, localOffset, method, compressedSize); + if (!data) continue; + + const info = describeImage(data); + if (info) images.push({ ...info, data }); + } + + return images; +} + +/** Nutzdaten eines Eintrags ab seinem lokalen Kopfsatz. */ +function readLocalEntry(buffer, localOffset, method, compressedSize) { + if (localOffset + 30 > buffer.length) return null; + if (buffer.readUInt32LE(localOffset) !== 0x04034b50) return null; + + const nameLength = buffer.readUInt16LE(localOffset + 26); + const extraLength = buffer.readUInt16LE(localOffset + 28); + const start = localOffset + 30 + nameLength + extraLength; + const slice = buffer.subarray(start, start + compressedSize); + + try { + if (method === 0) return Buffer.from(slice); // unkomprimiert abgelegt + if (method === 8) return zlib.inflateRawSync(slice); + } catch { + return null; + } + return null; +} + +/** Das "End of Central Directory"-Kennzeichen steht am Dateiende. */ +function findEndOfCentralDirectory(buffer) { + // Der Kommentar am Ende darf 64 KB lang sein - weiter zurueck lohnt nicht. + const from = Math.max(0, buffer.length - 22 - 0xffff); + for (let index = buffer.length - 22; index >= from; index -= 1) { + if (buffer.readUInt32LE(index) === 0x06054b50) return index; + } + return -1; +} + +/* ------------------------------------------------------------------ PDF */ + +/** + * JPEG-Bilder aus einem PDF schneiden. + * + * Mit `/DCTDecode` abgelegte Bilder stehen unveraendert in der Datei; ihre + * eigenen Markierungen begrenzen sie zuverlaessiger als ein halber + * PDF-Parser. Was dabei kein gueltiges JPEG ergibt, faellt bei der + * Groessenpruefung ohnehin durch. + */ +function imagesFromPdf(buffer) { + const images = []; + let index = 0; + + while (index < buffer.length - 3) { + // Start of Image, gefolgt von einer Markierung (FFE0-FFEF oder FFDB). + if (buffer[index] === 0xff && buffer[index + 1] === 0xd8 && buffer[index + 2] === 0xff) { + const end = findJpegEnd(buffer, index + 2); + if (end > index) { + const data = buffer.subarray(index, end); + const info = describeImage(data); + if (info) images.push({ ...info, data: Buffer.from(data) }); + index = end; + continue; + } + } + index += 1; + } + + return images; +} + +/** Laeuft die Markierungen entlang bis zum "End of Image". */ +function findJpegEnd(buffer, start) { + let index = start; + while (index < buffer.length - 1) { + if (buffer[index] !== 0xff) { + index += 1; + continue; + } + const marker = buffer[index + 1]; + if (marker === 0xd9) return index + 2; // EOI + if (marker === 0xda) { + // Beginn der Bilddaten: darin stehen beliebige Bytes, nur FF00 und die + // Restart-Markierungen sind erlaubt. Bis zum naechsten echten Marker. + index += 2; + while (index < buffer.length - 1) { + if (buffer[index] === 0xff && buffer[index + 1] !== 0x00 && + !(buffer[index + 1] >= 0xd0 && buffer[index + 1] <= 0xd7)) { + break; + } + index += 1; + } + continue; + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { + index += 2; + continue; + } + if (index + 3 >= buffer.length) return -1; + index += 2 + buffer.readUInt16BE(index + 2); + } + return -1; +} + +/* --------------------------------------------------------------- Format */ + +/** + * Art und Abmessungen eines Bildes - ohne es zu dekodieren. + * + * Gebraucht werden sie fuer die Auswahl: ohne Abmessungen liesse sich ein + * Portrait nicht von einem Logo unterscheiden. + */ +function describeImage(data) { + if (data.length < 24) return null; + + // PNG: Abmessungen stehen im IHDR direkt am Anfang. + if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) { + return { mime: 'image/png', width: data.readUInt32BE(16), height: data.readUInt32BE(20) }; + } + + // JPEG: die Abmessungen stehen im Start-of-Frame (C0-CF, ohne C4, C8, CC). + if (data[0] === 0xff && data[1] === 0xd8) { + let index = 2; + while (index < data.length - 9) { + if (data[index] !== 0xff) { + index += 1; + continue; + } + const marker = data[index + 1]; + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return { + mime: 'image/jpeg', + height: data.readUInt16BE(index + 5), + width: data.readUInt16BE(index + 7), + }; + } + if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + index += 2; + continue; + } + if (index + 3 >= data.length) break; + index += 2 + data.readUInt16BE(index + 2); + } + return null; + } + + return null; +} diff --git a/server/src/cvText.js b/server/src/cvText.js new file mode 100644 index 0000000..8e3c923 --- /dev/null +++ b/server/src/cvText.js @@ -0,0 +1,83 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +/* + * Text aus einem Lebenslauf holen. + * + * Unterstuetzt PDF, DOCX und reinen Text. Das alte .doc-Format (Word 97) bleibt + * aussen vor: es braeuchte einen eigenen Parser, und wer eine solche Datei hat, + * speichert sie in Sekunden als PDF. + * + * pdf-parse wird ueber den internen Pfad geladen. Der Paket-Einstieg fuehrt + * beim Import eine Selbstprobe aus und sucht dafuer eine Testdatei, die im + * npm-Paket nicht mitgeliefert wird - das laesst den Start scheitern. + */ +const pdfParse = require('pdf-parse/lib/pdf-parse.js'); + +/** Mehr Text braucht kein Modell, um einen Lebenslauf zu verstehen. */ +const MAX_CHARS = 60_000; + +export function isSupported(filename, mime = '') { + const ext = path.extname(filename).toLowerCase(); + return ( + ['.pdf', '.docx', '.txt', '.md'].includes(ext) || + mime === 'application/pdf' || + mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || + mime.startsWith('text/') + ); +} + +/** + * Liest die Datei und gibt ihren Text zurueck. + * + * @param {string} filePath absoluter Pfad + * @param {string} originalName fuer die Endung + */ +export async function extractText(filePath, originalName) { + const ext = path.extname(originalName || filePath).toLowerCase(); + + if (ext === '.pdf') { + const result = await pdfParse(await fs.readFile(filePath)); + return clean(result.text); + } + + if (ext === '.docx') { + // mammoth liefert den Fliesstext ohne Formatierung - genau das, was das + // Modell braucht. + const mammoth = require('mammoth'); + const result = await mammoth.extractRawText({ path: filePath }); + return clean(result.value); + } + + if (ext === '.txt' || ext === '.md') { + return clean(await fs.readFile(filePath, 'utf8')); + } + + throw Object.assign(new Error('Dateityp wird nicht unterstützt (PDF, DOCX oder Text).'), { + status: 400, + }); +} + +/* + * Aufraeumen: PDF-Auszuege enthalten viele harte Zeilenumbrueche und + * Mehrfachleerzeichen aus dem Satzspiegel. Das kostet nur Token und erschwert + * dem Modell das Lesen. + */ +function clean(text) { + const result = String(text ?? '') + .replace(/\r/g, '') + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + if (result.length <= MAX_CHARS) return result; + + // Anfang und Ende behalten: vorn stehen Name und Kontaktdaten, hinten oft + // Ausbildung und Zertifikate. + const head = result.slice(0, Math.floor(MAX_CHARS * 0.7)); + const tail = result.slice(-Math.floor(MAX_CHARS * 0.3)); + return `${head}\n\n[… gekürzt …]\n\n${tail}`; +} diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..9a13651 --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,200 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import mysql from 'mysql2/promise'; +import bcrypt from 'bcryptjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const pool = mysql.createPool({ + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT || 3306), + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || 'icon_CRM', + waitForConnections: true, + connectionLimit: 8, + queueLimit: 0, + charset: 'utf8mb4', + // Datumswerte als Zeichenkette: sonst rechnet der Treiber sie in die + // Zeitzone des Prozesses um und ein DATE verschiebt sich um einen Tag. + dateStrings: true, +}); + +/** Fuehrt schema.sql aus und legt bei leerer Benutzertabelle den Admin an. */ +export async function initDatabase() { + const sql = await fs.readFile(path.join(__dirname, 'schema.sql'), 'utf8'); + + // schema.sql enthaelt mehrere Statements. Statt multipleStatements zu + // aktivieren (was SQL-Injection deutlich gefaehrlicher machen wuerde), wird + // an Semikolons am Zeilenende getrennt. Kommentarzeilen fliegen vorher raus, + // sonst bestuende ein Statement nach dem Split nur aus Kommentar. + const statements = sql + .split('\n') + .filter((line) => !line.trim().startsWith('--')) + .join('\n') + .split(/;\s*$/m) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + const conn = await pool.getConnection(); + try { + for (const statement of statements) { + await conn.query(statement); + } + } finally { + conn.release(); + } + + await migrateColumns(); + await migrateContactKind(); + await seedOptions(); + await ensureAdminUser(); +} + +/* + * Die Kontaktart war ein ENUM mit drei festen Werten. Damit sie sich in den + * Einstellungen pflegen laesst, wird daraus ein Textfeld - die erlaubten Werte + * stehen jetzt in icon_options. + * + * Erkannt wird der Altstand am Spaltentyp. Ein ENUM zu erweitern haette bei + * jeder neuen Art eine Migration bedeutet. + */ +async function migrateContactKind() { + const [rows] = await pool.query( + `SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'icon_contacts' + AND COLUMN_NAME = 'kind' LIMIT 1` + ); + if (rows[0]?.DATA_TYPE !== 'enum') return; + + await pool.query("ALTER TABLE icon_contacts MODIFY kind VARCHAR(60) NOT NULL DEFAULT 'Experte'"); + console.log('[ICON CRM] Kontaktart ist jetzt eine gepflegte Auswahlliste.'); +} + +/* + * Startbestand der Auswahllisten: die Werte, die bis zur Umstellung im + * Quelltext standen, plus alles, was in vorhandenen Datensaetzen schon als + * Freitext steht. + * + * Laeuft genau einmal, festgehalten per Merker. Ohne den waere jeder Neustart + * ein Wiederherstellen: eine Branche, die jemand bewusst entfernt hat, waere + * danach wieder da. + */ +async function seedOptions() { + if ((await getSetting('options_seeded')) === 'true') return; + + const kinds = ['Experte', 'Ansprechpartner', 'Lead']; + await pool.query( + `INSERT IGNORE INTO icon_options (field, value, position) + VALUES ${kinds.map((_, index) => `('contact_kind', ?, ${index})`).join(', ')}`, + kinds + ); + + // Vorhandene Freitexte uebernehmen, damit kein Unternehmen eine Branche + // traegt, die sein Auswahlfeld nicht kennt. + await pool.query( + `INSERT IGNORE INTO icon_options (field, value) + SELECT 'industry', TRIM(industry) FROM icon_companies + WHERE industry IS NOT NULL AND TRIM(industry) <> '' + GROUP BY TRIM(industry)` + ); + await pool.query( + `INSERT IGNORE INTO icon_options (field, value) + SELECT 'contact_kind', TRIM(kind) FROM icon_contacts + WHERE kind IS NOT NULL AND TRIM(kind) <> '' + GROUP BY TRIM(kind)` + ); + + await setSetting('options_seeded', 'true'); + const [[count]] = await pool.query('SELECT COUNT(*) AS count FROM icon_options'); + console.log(`[ICON CRM] Auswahllisten angelegt - ${count.count} Eintrag/Einträge.`); +} + +/* + * Spalten, die zu einer bereits bestehenden Tabelle dazugekommen sind. + * + * `CREATE TABLE IF NOT EXISTS` in schema.sql fasst eine vorhandene Tabelle + * nicht an - Nachtraege gehoeren deshalb hierher. Jeder Eintrag prueft erst + * INFORMATION_SCHEMA und ist damit bei jedem weiteren Start ein Blick in den + * Katalog und sonst nichts. + */ +async function migrateColumns() { + const additions = [ + // Hauptansprechpartner eines Unternehmens - Auswahl aus den zugeordneten + // Kontakten (Featureliste, Abschnitt "Kunden"). + [ + 'icon_companies', + 'primary_contact_id', + `ALTER TABLE icon_companies + ADD COLUMN primary_contact_id INT UNSIGNED NULL AFTER owner_id, + ADD CONSTRAINT fk_icon_companies_primary FOREIGN KEY (primary_contact_id) + REFERENCES icon_contacts (id) ON DELETE SET NULL`, + ], + // Das im Lebenslauf gefundene Portrait - Zwischenablage zwischen Auslesen + // und Uebernehmen (siehe server/src/cvPhoto.js). + [ + 'icon_cv_imports', + 'photo', + 'ALTER TABLE icon_cv_imports ADD COLUMN photo MEDIUMTEXT NULL AFTER extracted', + ], + ]; + + for (const [table, column, statement] of additions) { + const [rows] = await pool.query( + `SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`, + [table, column] + ); + if (rows.length > 0) continue; + + await pool.query(statement); + console.log(`[ICON CRM] Spalte ${table}.${column} ergänzt.`); + } +} + +/** Liest einen Wert aus icon_settings. */ +export async function getSetting(key) { + const [rows] = await pool.query('SELECT value FROM icon_settings WHERE key_name = ? LIMIT 1', [key]); + return rows[0]?.value ?? null; +} + +/** Schreibt einen Wert nach icon_settings. */ +export async function setSetting(key, value) { + await pool.query( + `INSERT INTO icon_settings (key_name, value) VALUES (?, ?) + ON DUPLICATE KEY UPDATE value = VALUES(value)`, + [key, value] + ); +} + +async function ensureAdminUser() { + const [rows] = await pool.query('SELECT COUNT(*) AS count FROM icon_users'); + if (rows[0].count > 0) return; + + const username = process.env.ADMIN_USERNAME || 'Admin'; + const name = process.env.ADMIN_NAME || 'Administrator'; + const email = process.env.ADMIN_EMAIL || null; + + // Kein festes Ersatzpasswort im Quelltext: ohne ADMIN_PASSWORD wird ein + // Zufallswert erzeugt und einmalig ins Log geschrieben. Ein bekannter + // Standardwert waere auf jeder Installation derselbe. + const generated = !process.env.ADMIN_PASSWORD; + const password = process.env.ADMIN_PASSWORD || randomBytes(12).toString('base64url'); + const hash = await bcrypt.hash(password, 12); + + await pool.query( + `INSERT INTO icon_users (username, email, name, role, password_hash, is_active) + VALUES (?, ?, ?, 'admin', ?, 1)`, + [username, email, name, hash] + ); + + console.log(`[ICON CRM] Admin-Benutzer "${username}" wurde initial angelegt.`); + if (generated) { + console.log( + `[ICON CRM] ADMIN_PASSWORD war nicht gesetzt - erzeugtes Passwort: ${password}\n` + + ' Jetzt notieren und in der Anwendung unter "Mein Konto" aendern.' + ); + } +} diff --git a/server/src/history.js b/server/src/history.js new file mode 100644 index 0000000..ea5a896 --- /dev/null +++ b/server/src/history.js @@ -0,0 +1,176 @@ +import { pool } from './db.js'; + +/* + * Historie und Erwaehnungen. + * + * Jede schreibende Route ruft hier herein, statt selbst in icon_history zu + * schreiben. Damit gilt an einer Stelle, was die Featureliste verlangt: in + * jeder Historie steht, welcher Benutzer was geaendert hat, und jedes @ im + * Text taucht als eigener Eintrag auf. + */ + +/** + * Mention-Token im Text: `@[Anzeigename](contact:12)`. + * + * Der Name steht mit im Token, damit ein Text auch ohne geladene Listen + * lesbar bleibt; beim Rendern gewinnt der aktuelle Name aus den Daten. + */ +const MENTION_TOKEN = /@\[([^\]\n]+)\]\((contact|company|user):(\d+)\)/g; + +export function extractMentions(text) { + if (!text) return []; + const found = new Map(); + MENTION_TOKEN.lastIndex = 0; + let match; + while ((match = MENTION_TOKEN.exec(String(text))) !== null) { + const key = `${match[2]}:${match[3]}`; + if (!found.has(key)) found.set(key, { type: match[2], id: Number(match[3]), label: match[1] }); + } + return [...found.values()]; +} + +/** Text ohne Token-Syntax - fuer Suche und Vorschauen. */ +export function mentionPlainText(text) { + if (!text) return ''; + return String(text).replace(MENTION_TOKEN, (_all, label) => `@${label}`); +} + +/** + * Schreibt einen Historieneintrag und indiziert die darin enthaltenen + * Erwaehnungen. + * + * @param {object} entry + * @param {'contact'|'company'|'tender'|'project'|'lead'|'user'|'system'} entry.entityType + * @param {number|null} entry.entityId + * @param {string|null} entry.entityLabel Name zum Zeitpunkt des Eintrags + * @param {string} entry.action created | updated | deleted | note | ... + * @param {string|null} entry.text + * @param {object|null} entry.changes { feld: [vorher, nachher] } + * @param {number|null} entry.userId + * @returns {Promise} id des Eintrags + */ +export async function logHistory({ + entityType, + entityId = null, + entityLabel = null, + action = 'note', + text = null, + changes = null, + userId = null, +}) { + const [result] = await pool.query( + `INSERT INTO icon_history (entity_type, entity_id, entity_label, action, text, changes, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + entityType, + entityId, + entityLabel ? String(entityLabel).slice(0, 300) : null, + action, + text ?? null, + changes && Object.keys(changes).length > 0 ? JSON.stringify(changes) : null, + userId, + ] + ); + + const historyId = result.insertId; + await indexMentions('history', historyId, text); + return historyId; +} + +/** + * Traegt die Erwaehnungen eines Textes in icon_mentions nach. + * + * Erst loeschen, dann neu schreiben: so verschwindet ein @, das aus einer + * Notiz wieder entfernt wurde, auch aus dem Index. + */ +export async function indexMentions(sourceType, sourceId, text) { + const mentions = extractMentions(text); + await pool.query('DELETE FROM icon_mentions WHERE source_type = ? AND source_id = ?', [ + sourceType, + sourceId, + ]); + if (mentions.length === 0) return []; + + const values = mentions.flatMap((m) => [sourceType, sourceId, m.type, m.id]); + await pool.query( + `INSERT IGNORE INTO icon_mentions (source_type, source_id, target_type, target_id) + VALUES ${mentions.map(() => '(?, ?, ?, ?)').join(', ')}`, + values + ); + return mentions; +} + +/** + * Vergleicht alten und neuen Datensatz und liefert nur die geaenderten Felder. + * + * Verglichen wird ueber String(), weil MariaDB Zahlen mal als Number und mal + * als String liefert (DECIMAL etwa immer als String) - ein strikter Vergleich + * wuerde daraus eine Scheinaenderung machen. + */ +export function diffFields(before, after, fields) { + const changes = {}; + for (const field of fields) { + if (after[field] === undefined) continue; + const from = before?.[field] ?? null; + const to = after[field] ?? null; + if (String(from ?? '') === String(to ?? '')) continue; + changes[field] = [from, to]; + } + return changes; +} + +/** Menschenlesbare Namen der Felder - fuer den Text in der Historie. */ +export const FIELD_LABELS = { + name: 'Name', + first_name: 'Vorname', + last_name: 'Nachname', + position: 'Position', + email: 'E-Mail', + phone: 'Telefon', + linkedin: 'LinkedIn', + company_id: 'Unternehmen', + kind: 'Art', + status: 'Status', + lead_source: 'Lead-Quelle', + contact_state: 'Kontaktstand', + freelancers: 'Freelancer-Einsatz', + bio: 'Notiz', + tags: 'Schlagworte', + rate_onsite: 'Stundensatz vor Ort', + rate_remote: 'Stundensatz remote', + cv_url: 'CV-Link', + avatar: 'Foto', + owner_id: 'Zustaendig', + group_name: 'Gruppe', + industry: 'Branche', + employees: 'Mitarbeiterzahl', + website: 'Website', + address: 'Adresse', + logo: 'Logo', + rating: 'Bewertung', + is_customer: 'Kunde', + customer_state: 'Kundenstatus', + notes: 'Notizen', + title: 'Titel', + source: 'Quelle', + link: 'Link', + stage: 'Phase', + online: 'Online', + details: 'Details', + deadline: 'Frist', + started_on: 'Beginn', + ended_on: 'Ende', + rate: 'Satz', + contact_id: 'Kontakt', + archived: 'Archiviert', + remind_at: 'Erinnerung', + reminder_days: 'Erinnerungsintervall', +}; + +/** "Status, Telefon und 2 weitere Felder geaendert" */ +export function describeChanges(changes) { + const labels = Object.keys(changes).map((key) => FIELD_LABELS[key] || key); + if (labels.length === 0) return 'ohne Änderung gespeichert'; + if (labels.length <= 3) return `${labels.join(', ')} geändert`; + return `${labels.slice(0, 2).join(', ')} und ${labels.length - 2} weitere Felder geändert`; +} diff --git a/server/src/linkedinPhoto.js b/server/src/linkedinPhoto.js new file mode 100644 index 0000000..4d439e8 --- /dev/null +++ b/server/src/linkedinPhoto.js @@ -0,0 +1,199 @@ +/* + * Profilfoto zu einem LinkedIn-Profil besorgen. + * + * LinkedIn liefert oeffentlichen Profilseiten eine Open-Graph-Vorschau mit, + * die das Profilfoto enthaelt (og:image, ausgeliefert ueber media.licdn.com). + * Genau diese Vorschau wird hier gelesen - es findet keine Anmeldung und kein + * Auslesen geschuetzter Inhalte statt. Ist das Profil nicht oeffentlich oder + * blockt LinkedIn die Anfrage, gibt es eine verstaendliche Fehlermeldung + * statt eines Bildes; der Benutzer kann das Foto dann wie gehabt hochladen. + * + * Der Aufruf laeuft bewusst ueber das Backend: aus dem Browser heraus waere er + * an der Same-Origin-Policy gescheitert. + */ + +/** Ohne Browser-Kennung antwortet LinkedIn haeufig gar nicht erst. */ +const BROWSER_HEADERS = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/140.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8', +}; + +/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */ +const TIMEOUT_MS = 12_000; + +/** Groessere Bilder sind kein Profilfoto mehr; der Client verkleinert ohnehin. */ +const MAX_IMAGE_BYTES = 5_000_000; + +/** Bilder liefert LinkedIn ausschliesslich ueber dieses CDN aus. */ +const IMAGE_HOSTS = ['licdn.com']; + +class PhotoError extends Error { + constructor(message, status = 502) { + super(message); + this.status = status; + } +} + +function hostMatches(hostname, domain) { + return hostname === domain || hostname.endsWith(`.${domain}`); +} + +/** + * Eingabe des Benutzers zu einer aufrufbaren Profil-URL machen. Akzeptiert + * auch "linkedin.com/in/vorname" ohne Schema. + */ +export function normalizeProfileUrl(input) { + const raw = String(input ?? '').trim(); + if (!raw) throw new PhotoError('Es ist kein LinkedIn-Profil hinterlegt.', 400); + + let url; + try { + url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`); + } catch { + throw new PhotoError('Die LinkedIn-Adresse ist keine gültige URL.', 400); + } + + if (!hostMatches(url.hostname.toLowerCase(), 'linkedin.com')) { + throw new PhotoError('Die Adresse zeigt nicht auf ein LinkedIn-Profil.', 400); + } + + // Tracking-Parameter stoeren nur und aendern die Seite nicht. + url.protocol = 'https:'; + url.search = ''; + url.hash = ''; + return url.toString(); +} + +/** + * Bild-URL aus dem HTML der Profilseite ziehen - ausschliesslich aus der + * Open-Graph-Vorschau (`og:image`, ersatzweise `twitter:image`). + * + * Bewusst wird NICHT im uebrigen Quelltext nach einer Bildadresse gesucht: eine + * Profilseite enthaelt Dutzende Adressen des LinkedIn-CDN - Firmenlogos, + * Beitragsbilder und die Fotos der "aehnlichen Profile" am Rand. Die erste + * davon zu nehmen, haengt irgendwann einem Kontakt das Gesicht einer fremden + * Person an. Nur die Vorschau gehoert nachweislich zum aufgerufenen Profil. + */ +function extractImageUrl(html) { + const metaPattern = /]+>/gi; + for (const tag of html.match(metaPattern) ?? []) { + const isImageTag = /(property|name)\s*=\s*["'](og:image|twitter:image)["']/i.test(tag); + if (!isImageTag) continue; + const content = tag.match(/content\s*=\s*["']([^"']+)["']/i); + if (content?.[1]) return decodeEntities(content[1]); + } + return null; +} + +/** Im HTML stehen die URLs mit maskierten Und-Zeichen. */ +function decodeEntities(value) { + return value + .replace(/&/g, '&') + .replace(///gi, '/') + .replace(/&/g, '&') + .replace(/"/g, '"'); +} + +async function requestWithTimeout(url, headers) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + return await fetch(url, { headers, redirect: 'follow', signal: controller.signal }); + } catch (err) { + if (err?.name === 'AbortError') { + throw new PhotoError('LinkedIn hat nicht rechtzeitig geantwortet.', 504); + } + throw new PhotoError('LinkedIn ist gerade nicht erreichbar.', 502); + } finally { + clearTimeout(timer); + } +} + +/** + * Holt das Profilfoto und gibt es als Data-URL zurueck - so wie ein + * hochgeladenes Foto auch gespeichert wird. + */ +export async function fetchLinkedInPhoto(profileInput) { + const profileUrl = normalizeProfileUrl(profileInput); + + const page = await requestWithTimeout(profileUrl, BROWSER_HEADERS); + if (page.status === 404 || page.status === 410) { + throw new PhotoError('Unter dieser Adresse gibt es kein LinkedIn-Profil.', 404); + } + if (!page.ok) { + // 999 ist LinkedIns Kennzeichen fuer "Anfrage abgewiesen". + throw new PhotoError( + 'LinkedIn hat die Anfrage abgewiesen. Bitte das Foto manuell hochladen.', + 502 + ); + } + + const html = await page.text(); + const imageUrl = extractImageUrl(html); + if (!imageUrl) { + throw new PhotoError( + 'Das Profil gibt kein öffentliches Foto preis. Bitte das Foto manuell hochladen.', + 404 + ); + } + + let parsedImage; + try { + parsedImage = new URL(imageUrl); + } catch { + throw new PhotoError('Die gefundene Bildadresse ist unbrauchbar.', 502); + } + const isTrustedHost = + parsedImage.protocol === 'https:' && + IMAGE_HOSTS.some((domain) => hostMatches(parsedImage.hostname.toLowerCase(), domain)); + if (!isTrustedHost) { + throw new PhotoError('Das gefundene Bild stammt nicht von LinkedIn.', 502); + } + + /* + * Echte Profilfotos liegen unter media.licdn.com/dms/image/... - alles andere + * ist LinkedIns Ersatzbild: die graue Silhouette ('ghost') oder eine feste + * Grafik von static.licdn.com (/aero-v1/sc/h/...). Das bekommt zu sehen, wer + * nicht angemeldet ist und dessen Profilfoto nur fuer Kontakte sichtbar ist - + * von aussen laesst sich das nicht von "gar kein Foto" unterscheiden, deshalb + * nennt die Meldung beide Moeglichkeiten. + */ + const path = parsedImage.pathname.toLowerCase(); + if (path.includes('ghost') || !path.includes('/dms/image/')) { + throw new PhotoError( + 'LinkedIn gibt zu diesem Profil kein Foto heraus: Es ist nur für Kontakte ' + + 'sichtbar oder es gibt keines. Bitte das Foto manuell hochladen.', + 404 + ); + } + + const image = await requestWithTimeout(parsedImage.toString(), { + 'User-Agent': BROWSER_HEADERS['User-Agent'], + 'Accept': 'image/avif,image/webp,image/png,image/jpeg,*/*;q=0.8', + 'Referer': profileUrl, + }); + if (!image.ok) throw new PhotoError('Das Profilfoto konnte nicht geladen werden.', 502); + + const contentType = (image.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); + if (!contentType.startsWith('image/')) { + throw new PhotoError('LinkedIn hat statt eines Bildes etwas anderes geliefert.', 502); + } + + const buffer = Buffer.from(await image.arrayBuffer()); + if (buffer.length === 0) throw new PhotoError('Das Profilfoto ist leer.', 502); + if (buffer.length > MAX_IMAGE_BYTES) { + throw new PhotoError('Das Profilfoto ist zu groß.', 502); + } + + return { + avatar: `data:${contentType};base64,${buffer.toString('base64')}`, + bytes: buffer.length, + source_url: parsedImage.toString(), + profile_url: profileUrl, + }; +} + +export { PhotoError, extractImageUrl, decodeEntities }; diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js new file mode 100644 index 0000000..97e24f4 --- /dev/null +++ b/server/src/middleware/auth.js @@ -0,0 +1,62 @@ +import jwt from 'jsonwebtoken'; +import { pool } from '../db.js'; + +function secret() { + const value = process.env.JWT_SECRET; + if (!value || value.length < 16) { + throw new Error('JWT_SECRET fehlt oder ist zu kurz (min. 16 Zeichen).'); + } + return value; +} + +export function signToken(user) { + return jwt.sign({ sub: user.id, role: user.role }, secret(), { + expiresIn: process.env.JWT_EXPIRES_IN || '12h', + }); +} + +/** + * Prueft das Bearer-Token und laedt den aktuellen Benutzer aus der Datenbank. + * + * Die Spaltenliste ist bewusst vollstaendig aufgezaehlt und kein SELECT *: + * das Passwort-Hash soll nicht am Request haengen. Sie muss aber alles + * enthalten, was publicUser() ausliefert - sonst faellt beim Neuladen z. B. + * die Theme-Wahl auf "nichts gewaehlt" zurueck. + */ +export async function requireAuth(req, res, next) { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) return res.status(401).json({ error: 'Nicht angemeldet.' }); + + let payload; + try { + payload = jwt.verify(token, secret()); + } catch { + return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte neu anmelden.' }); + } + + try { + const [rows] = await pool.query( + `SELECT id, username, email, name, role, color, avatar, theme, theme_mode, + is_active, created_at, updated_at + FROM icon_users WHERE id = ? LIMIT 1`, + [payload.sub] + ); + const user = rows[0]; + if (!user) return res.status(401).json({ error: 'Benutzer existiert nicht mehr.' }); + if (!user.is_active) return res.status(403).json({ error: 'Benutzerkonto ist deaktiviert.' }); + + req.user = { ...user, is_active: Boolean(user.is_active) }; + next(); + } catch (err) { + next(err); + } +} + +/** Nur Rolle admin - schuetzt Einstellungen und Nutzerverwaltung. */ +export function requireAdmin(req, res, next) { + if (req.user?.role !== 'admin') { + return res.status(403).json({ error: 'Diese Aktion ist Administratoren vorbehalten.' }); + } + next(); +} diff --git a/server/src/migrate.js b/server/src/migrate.js new file mode 100644 index 0000000..0db89c5 --- /dev/null +++ b/server/src/migrate.js @@ -0,0 +1,388 @@ +import 'dotenv/config'; +import { pool, initDatabase } from './db.js'; + +/* + * Einmalige Uebernahme der Daten aus dem alten ICON CRM (Supabase, + * icondb.perinco.de) in die neue MariaDB. + * + * Aufruf auf dem Server: + * cd /var/www/icon_CRM/server + * SUPABASE_URL=... SUPABASE_KEY=... node src/migrate.js # Probelauf + * SUPABASE_URL=... SUPABASE_KEY=... node src/migrate.js --write # schreibt + * + * Der Probelauf ist die Voreinstellung: er liest, rechnet die Zuordnung aus + * und zeigt, was passieren wuerde - ohne eine Zeile zu schreiben. + * + * Wiederholbar: Unternehmen und Kontakte werden ueber ihren Namen wiedererkannt, + * ein zweiter Lauf legt sie nicht erneut an. + */ + +const BASE = (process.env.SUPABASE_URL || 'https://icondb.perinco.de').replace(/\/+$/, ''); +const KEY = process.env.SUPABASE_KEY || ''; +const WRITE = process.argv.includes('--write'); + +const TABLES = { + customers: 'icon_customers', + contacts: 'icon_contacts', + experts: 'icon_experts', + tenders: 'icon_tenders', + tenderExperts: 'icon_tender_experts', + projects: 'icon_projects', + users: 'icon_users', +}; + +/** Status des alten Systems auf den neuen abbilden. */ +const STATUS_MAP = { + Interessiert: 'Interessiert', + Interview: 'Interview', + Gebucht: 'Gebucht', + // Der Verfuegbarkeitsstatus ist entfallen - diese Kontakte landen auf + // "Interessiert", der neutralen Ausgangslage. + 'Verfügbar': 'Interessiert', +}; + +const STAGE_MAP = { + Ausschreibung: 'Ausschreibung', + 'Experten sichten': 'Kontakte sichten', + Interview: 'Interview', + 'Kunden Interview': 'Kunden Interview', + Vertragsgestaltung: 'Vertragsgestaltung', + Abschluss: 'Abschluss', + Geschlossen: 'Geschlossen', +}; + +const TENDER_STATUS = new Set(['Aktiv', 'Inaktiv', 'Ausgelaufen', 'Archiviert']); +const PROJECT_STATUS = new Set(['Aktiv', 'Nicht aktiv', 'Abgeschlossen']); + +async function fetchTable(table) { + if (!KEY) throw new Error('SUPABASE_KEY fehlt - ohne Schluessel liefert PostgREST nichts.'); + const response = await fetch(`${BASE}/rest/v1/${table}?select=*`, { + headers: { apikey: KEY, Authorization: `Bearer ${KEY}` }, + }); + if (!response.ok) { + throw new Error(`${table}: HTTP ${response.status} ${await response.text().catch(() => '')}`); + } + return response.json(); +} + +/** "Hans Peter Meier" -> { first_name: 'Hans Peter', last_name: 'Meier' } */ +function splitName(value) { + const parts = String(value ?? '').trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return { first_name: null, last_name: 'Unbenannt' }; + if (parts.length === 1) return { first_name: null, last_name: parts[0] }; + return { first_name: parts.slice(0, -1).join(' '), last_name: parts[parts.length - 1] }; +} + +function clean(value, max) { + const text = String(value ?? '').trim(); + return text ? text.slice(0, max) : null; +} + +function money(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : null; +} + +function isoDate(value) { + const text = String(value ?? '').slice(0, 10); + return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null; +} + +/* + * Bilder aus dem Altbestand koennen alles sein: Data-URL, http-Adresse oder + * Unsinn. Alles, was das Backend spaeter ablehnen wuerde, faellt hier weg - + * ein Kontakt ohne Foto ist besser als ein Datensatz, der nicht mehr + * speicherbar ist. + */ +function image(value) { + const text = String(value ?? '').trim(); + if (!text) return null; + const ok = /^data:image\/[a-z+]+;base64,/i.test(text) || /^https?:\/\//i.test(text); + if (!ok) return null; + return text.length > 400_000 ? null : text; +} + +const report = []; +function note(line) { + report.push(line); + console.log(line); +} + +async function main() { + console.log(`ICON CRM Migration – Quelle ${BASE}`); + console.log(WRITE ? 'Modus: SCHREIBEN\n' : 'Modus: Probelauf (nichts wird geschrieben)\n'); + + await initDatabase(); + + const source = {}; + for (const [key, table] of Object.entries(TABLES)) { + try { + source[key] = await fetchTable(table); + console.log(`gelesen: ${table} – ${source[key].length} Zeile(n)`); + } catch (err) { + source[key] = []; + console.warn(`übersprungen: ${table} – ${err.message}`); + } + } + console.log(''); + + /* ------------------------------------------------------------- Benutzer */ + + const userIds = new Map(); + for (const row of source.users ?? []) { + const username = clean(row.username, 60); + if (!username) continue; + + const [existing] = await pool.query('SELECT id FROM icon_users WHERE username = ? LIMIT 1', [username]); + if (existing[0]) { + userIds.set(row.id, existing[0].id); + continue; + } + if (!WRITE) { + note(`+ Benutzer ${username}`); + continue; + } + + // Der alte Hash ist SHA-256. Er wird uebernommen; beim ersten Login + // stellt das Backend ihn auf bcrypt um (siehe routes/auth.js). + const [result] = await pool.query( + `INSERT INTO icon_users (username, email, name, role, password_hash, is_active) + VALUES (?, ?, ?, ?, ?, 1)`, + [ + username, + clean(row.email, 190), + clean(row.name, 190) || username, + row.role === 'admin' ? 'admin' : 'user', + String(row.password_hash ?? ''), + ] + ); + userIds.set(row.id, result.insertId); + note(`+ Benutzer ${username}`); + } + + /* ---------------------------------------------------------- Unternehmen */ + + const companyIds = new Map(); // alte Kennung -> neue Kennung + const companyByName = new Map(); // Name (klein) -> neue Kennung + + const [presentCompanies] = await pool.query('SELECT id, name FROM icon_companies'); + for (const row of presentCompanies) companyByName.set(row.name.toLowerCase(), row.id); + + for (const row of source.customers ?? []) { + const name = clean(row.company ?? row.name, 190); + if (!name) continue; + + const known = companyByName.get(name.toLowerCase()); + if (known) { + companyIds.set(row.id, known); + continue; + } + if (!WRITE) { + note(`+ Unternehmen ${name} (Kunde)`); + companyIds.set(row.id, -1); + continue; + } + + const [result] = await pool.query( + `INSERT INTO icon_companies (name, website, address, logo, notes, is_customer, customer_state) + VALUES (?, ?, ?, ?, ?, 1, 'aktiv')`, + [name, clean(row.website, 300), clean(row.address, 2000), image(row.logo), clean(row.notes, 60000)] + ); + companyIds.set(row.id, result.insertId); + companyByName.set(name.toLowerCase(), result.insertId); + note(`+ Unternehmen ${name} (Kunde)`); + } + + /** Unternehmen zu einem Freitextnamen finden oder anlegen. */ + async function companyForName(value) { + const name = clean(value, 190); + if (!name) return null; + const known = companyByName.get(name.toLowerCase()); + if (known) return known; + if (!WRITE) { + note(`+ Unternehmen ${name} (aus Freitext)`); + return null; + } + const [result] = await pool.query('INSERT INTO icon_companies (name) VALUES (?)', [name]); + companyByName.set(name.toLowerCase(), result.insertId); + note(`+ Unternehmen ${name} (aus Freitext)`); + return result.insertId; + } + + /* -------------------------------------------------------------- Kontakte */ + + const contactIds = new Map(); + const contactByName = new Map(); + + const [presentContacts] = await pool.query( + "SELECT id, CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts" + ); + for (const row of presentContacts) contactByName.set(String(row.name).toLowerCase(), row.id); + + async function insertContact(row, kind, companyId) { + const { first_name, last_name } = splitName(row.name); + const full = [first_name, last_name].filter(Boolean).join(' '); + + const known = contactByName.get(full.toLowerCase()); + if (known) return known; + if (!WRITE) { + note(`+ Kontakt ${full} (${kind})`); + return null; + } + + const [result] = await pool.query( + `INSERT INTO icon_contacts + (first_name, last_name, position, email, phone, linkedin, avatar, company_id, + kind, status, bio, tags, rate_onsite, rate_remote, cv_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + first_name, + last_name, + clean(row.role, 190), + clean(row.email, 190), + clean(row.phone, 80), + clean(row.linkedin, 300), + image(row.avatar), + companyId, + kind, + STATUS_MAP[row.status] ?? 'Interessiert', + clean(row.bio, 60000), + Array.isArray(row.tags) ? row.tags.join(', ').slice(0, 1000) : clean(row.tags, 1000), + money(row.hourlyRateOnsite ?? row.hourly_rate_onsite), + money(row.hourlyRateRemote ?? row.hourly_rate_remote), + clean(row.cv_url, 500), + ] + ); + + // Der Werdegang beginnt mit dem aktuellen Arbeitgeber - erst dadurch kann + // ein spaeterer Wechsel den Zweig "Verlassen" fuellen. + if (companyId) { + await pool.query( + `INSERT INTO icon_employments (contact_id, company_id, position, is_current) + VALUES (?, ?, ?, 1)`, + [result.insertId, companyId, clean(row.role, 190)] + ); + } + + contactByName.set(full.toLowerCase(), result.insertId); + note(`+ Kontakt ${full} (${kind})`); + return result.insertId; + } + + for (const row of source.experts ?? []) { + const companyId = await companyForName(row.company); + const id = await insertContact(row, 'Experte', companyId); + if (id) contactIds.set(row.id, id); + } + + for (const row of source.contacts ?? []) { + const companyId = companyIds.get(row.customer_id) ?? null; + const id = await insertContact(row, 'Ansprechpartner', companyId && companyId > 0 ? companyId : null); + if (id) contactIds.set(row.id, id); + } + + /* ------------------------------------------------------- Ausschreibungen */ + + const tenderIds = new Map(); + for (const row of source.tenders ?? []) { + const title = clean(row.title, 300); + if (!title) continue; + + const [existing] = await pool.query('SELECT id FROM icon_tenders WHERE title = ? LIMIT 1', [title]); + if (existing[0]) { + tenderIds.set(row.id, existing[0].id); + continue; + } + if (!WRITE) { + note(`+ Ausschreibung ${title}`); + continue; + } + + const [result] = await pool.query( + `INSERT INTO icon_tenders (title, source, link, company_id, status, stage, online, details, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + title, + clean(row.source, 190), + clean(row.link, 600), + companyIds.get(row.customer_id) ?? null, + TENDER_STATUS.has(row.status) ? row.status : 'Aktiv', + STAGE_MAP[row.kanbanStatus ?? row.kanban_status] ?? 'Ausschreibung', + row.online === false ? 0 : 1, + clean(row.details, 60000), + clean(row.notes, 60000), + ] + ); + tenderIds.set(row.id, result.insertId); + note(`+ Ausschreibung ${title}`); + } + + for (const row of source.tenderExperts ?? []) { + const tenderId = tenderIds.get(row.tender_id); + const contactId = contactIds.get(row.expert_id); + if (!tenderId || !contactId) continue; + if (!WRITE) continue; + await pool.query( + 'INSERT IGNORE INTO icon_tender_contacts (tender_id, contact_id) VALUES (?, ?)', + [tenderId, contactId] + ); + } + + /* -------------------------------------------------------------- Projekte */ + + for (const row of source.projects ?? []) { + const title = clean(row.title, 300); + if (!title) continue; + + const [existing] = await pool.query('SELECT id FROM icon_projects WHERE title = ? LIMIT 1', [title]); + if (existing[0]) continue; + if (!WRITE) { + note(`+ Projekt ${title}`); + continue; + } + + // Kunde und Experte standen im alten System als Freitext im Projekt. + const companyId = await companyForName(row.customer); + const expert = clean(row.expert, 190); + const contactId = expert ? (contactByName.get(expert.toLowerCase()) ?? null) : null; + + await pool.query( + `INSERT INTO icon_projects (title, company_id, contact_id, started_on, ended_on, status, notes) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + title, + companyId, + contactId, + isoDate(row.start), + isoDate(row.end), + PROJECT_STATUS.has(row.status) ? row.status : 'Aktiv', + clean(row.notes, 60000), + ] + ); + note(`+ Projekt ${title}`); + } + + /* ---------------------------------------------------------------- Bilanz */ + + console.log(''); + if (WRITE) { + const [[counts]] = await pool.query( + `SELECT (SELECT COUNT(*) FROM icon_companies) AS unternehmen, + (SELECT COUNT(*) FROM icon_contacts) AS kontakte, + (SELECT COUNT(*) FROM icon_tenders) AS ausschreibungen, + (SELECT COUNT(*) FROM icon_projects) AS projekte, + (SELECT COUNT(*) FROM icon_users) AS benutzer` + ); + console.log('Bestand nach der Migration:', counts); + } else { + console.log(`Probelauf beendet – ${report.length} Datensatz/Datensaetze wuerden angelegt.`); + console.log('Zum Schreiben denselben Aufruf mit --write wiederholen.'); + } + + await pool.end(); +} + +main().catch((err) => { + console.error('Migration fehlgeschlagen:', err); + process.exit(1); +}); diff --git a/server/src/routes/ai.js b/server/src/routes/ai.js new file mode 100644 index 0000000..f459d52 --- /dev/null +++ b/server/src/routes/ai.js @@ -0,0 +1,196 @@ +import express from 'express'; +import { requireAuth } from '../middleware/auth.js'; +import { + askModel, + aiConfigured, + aiModel, + aiStatus, + saveAiKey, + saveAiModel, +} from '../ai.js'; +import { requireAdmin } from '../middleware/auth.js'; +import { logHistory } from '../history.js'; + +const router = express.Router(); +router.use(requireAuth); + +/* + * Schreibhilfe fuer die Ausschreibungstexte. + * + * Der Editor schickt den aktuellen Text und eine Aufgabe; zurueck kommt HTML in + * genau dem Umfang, den der Editor auch darstellen kann. + * + * Der Schluessel bleibt auf dem Server - der Browser spricht nie direkt mit + * OpenAI. + */ + +const TASKS = { + write: { + label: 'Text erzeugen', + instruction: + 'Schreibe eine vollständige, sachliche Projektausschreibung auf Deutsch. Gliedere sie in ' + + 'Überschriften: Rolle, Aufgaben, Anforderungen, Rahmenbedingungen.', + }, + improve: { + label: 'Verbessern', + instruction: + 'Überarbeite den Text: klarer, sachlicher, ohne Floskeln und Wiederholungen. Inhalt und ' + + 'Aussage dürfen sich nicht ändern, es darf nichts hinzukommen, was nicht dasteht.', + }, + shorten: { + label: 'Kürzen', + instruction: + 'Kürze den Text auf etwa die Hälfte, ohne eine inhaltliche Angabe zu verlieren. ' + + 'Anforderungen und Rahmenbedingungen bleiben vollständig.', + }, + bullets: { + label: 'Als Stichpunkte', + instruction: + 'Forme den Text zu übersichtlichen Stichpunktlisten um, gruppiert nach Aufgaben und ' + + 'Anforderungen. Keine Angabe weglassen.', + }, + profile: { + label: 'Anforderungsprofil', + instruction: + 'Leite aus dem Text ein knappes Anforderungsprofil ab: welche Fähigkeiten, welche Jahre ' + + 'Erfahrung, welche Branchenkenntnis. Nur, was im Text belegt ist.', + }, +}; + +const SYSTEM = `Du bist Texter einer Personalvermittlung für IT- und Management-Beratung. +Du antwortest ausschließlich mit dem fertigen Text als einfaches HTML. +Erlaubt sind nur:


    1. . +Kein , kein , keine Code-Zäune, keine Erklärung, keine Anrede an den Nutzer. +Sprache: Deutsch, sachlich, keine Werbefloskeln. Nichts erfinden, was nicht in der Vorlage steht.`; + +// GET /api/ai/status - die Oberflaeche blendet die Knoepfe ohne Schluessel aus. +router.get('/status', (req, res) => { + res.json({ configured: aiConfigured(), model: aiModel(), tasks: Object.keys(TASKS) }); +}); + +// POST /api/ai/text { task, current?, prompt? } +router.post('/text', async (req, res, next) => { + const task = String(req.body?.task || 'write'); + if (!TASKS[task]) return res.status(400).json({ error: 'Unbekannte Aufgabe.' }); + + const current = String(req.body?.current || '').slice(0, 40_000); + const prompt = String(req.body?.prompt || '').slice(0, 4_000); + + if (task !== 'write' && !current.trim()) { + return res.status(400).json({ error: 'Es gibt noch keinen Text zum Überarbeiten.' }); + } + if (task === 'write' && !prompt.trim() && !current.trim()) { + return res.status(400).json({ error: 'Bitte kurz beschreiben, worum es geht.' }); + } + + try { + const text = await askModel({ + system: SYSTEM, + user: [ + TASKS[task].instruction, + prompt.trim() ? `\nVorgabe des Nutzers:\n${prompt.trim()}` : '', + current.trim() ? `\nBisheriger Text:\n${current.trim()}` : '', + ] + .filter(Boolean) + .join('\n'), + maxTokens: 6000, + }); + + res.json({ text: stripFences(text), model: aiModel() }); + } catch (err) { + if (err?.status) return res.status(err.status).json({ error: err.message }); + next(err); + } +}); + +/* ------------------------------------------------------------------------- + * Schluessel und Modell verwalten (Einstellungen -> KI, nur Administratoren) + * + * Der Schluessel geht nie zum Browser zurueck - weder hier noch sonst irgendwo. + * Gelesen wird er ausschliesslich verkuerzt; geschrieben wird er einmal und + * liegt danach in der Datenbank. + * ---------------------------------------------------------------------- */ + +// GET /api/ai/config +router.get('/config', requireAdmin, (req, res) => { + res.json(aiStatus()); +}); + +// PATCH /api/ai/config - { key?, model? }; key === '' entfernt ihn wieder. +router.patch('/config', requireAdmin, async (req, res, next) => { + const body = req.body || {}; + const hasKey = Object.prototype.hasOwnProperty.call(body, 'key'); + const hasModel = Object.prototype.hasOwnProperty.call(body, 'model'); + if (!hasKey && !hasModel) { + return res.status(400).json({ error: 'Keine Änderungen übergeben.' }); + } + + const key = hasKey ? String(body.key ?? '').trim() : null; + if (hasKey && key && !/^sk-[A-Za-z0-9_-]{20,}$/.test(key)) { + // Nur die Form pruefen, nicht die Gueltigkeit - die zeigt der Test. + return res.status(400).json({ error: 'Das sieht nicht nach einem OpenAI-Schlüssel aus (beginnt mit "sk-").' }); + } + + try { + if (hasKey) await saveAiKey(key); + if (hasModel) await saveAiModel(body.model); + + /* + * In die Historie kommt, DASS jemand den Schluessel gewechselt hat - nie + * der Wert. Ein stiller Wechsel waere spaeter nicht mehr nachvollziehbar. + */ + await logHistory({ + entityType: 'system', + entityId: null, + entityLabel: 'KI', + action: 'updated', + text: [ + hasKey ? (key ? 'OpenAI-Schlüssel hinterlegt' : 'OpenAI-Schlüssel entfernt') : null, + hasModel ? `Modell: ${aiModel()}` : null, + ] + .filter(Boolean) + .join(', '), + userId: req.user.id, + }); + + res.json(aiStatus()); + } catch (err) { + next(err); + } +}); + +/* + * POST /api/ai/test - ein winziger Aufruf gegen das Modell. + * + * Ohne ihn merkt man einen falschen Schluessel erst, wenn jemand mitten in der + * Arbeit einen Lebenslauf auslesen will. + */ +router.post('/test', requireAdmin, async (req, res) => { + if (!aiConfigured()) { + return res.status(503).json({ error: 'Es ist kein Schlüssel hinterlegt.' }); + } + const started = Date.now(); + try { + const text = await askModel({ + system: 'Antworte mit genau einem Wort.', + user: 'Sag OK.', + maxTokens: 300, + }); + res.json({ ok: true, model: aiModel(), ms: Date.now() - started, answer: text.trim().slice(0, 40) }); + } catch (err) { + res.status(err?.status ?? 502).json({ error: err.message }); + } +}); + +/* + * Modelle setzen die Antwort gern in ```html … ``` - das waere im Editor + * sichtbarer Muell. + */ +function stripFences(text) { + return String(text ?? '') + .replace(/^\s*```(?:html)?\s*/i, '') + .replace(/\s*```\s*$/i, '') + .trim(); +} + +export default router; diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js new file mode 100644 index 0000000..de5cb4a --- /dev/null +++ b/server/src/routes/auth.js @@ -0,0 +1,169 @@ +import express from 'express'; +import { createHash } from 'node:crypto'; +import bcrypt from 'bcryptjs'; +import { pool } from '../db.js'; +import { signToken, requireAuth } from '../middleware/auth.js'; +import { normalizeAvatar, normalizeColor, normalizeTheme, normalizeThemeMode } from '../userFields.js'; + +const router = express.Router(); + +export function publicUser(row) { + /* + * Ein Design, das es nicht mehr gibt, wird als "nicht gesetzt" ausgeliefert. + * + * Die Prüfung beim Schreiben reicht dafür nicht: sie greift erst ab dem + * Zeitpunkt, zu dem ein Wert aus der Liste fliegt. Wer ihn vorher gewählt + * hatte, trägt ihn weiter in der Datenbank - und die Oberfläche bekäme einen + * Namen, zu dem sie keine Beschriftung kennt. + */ + const theme = normalizeTheme(row.theme ?? null); + + return { + id: row.id, + username: row.username, + email: row.email ?? null, + name: row.name, + role: row.role, + color: row.color ?? null, + avatar: row.avatar ?? null, + theme: theme.error ? null : theme.value, + theme_mode: row.theme_mode ?? null, + is_active: Boolean(row.is_active), + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +// POST /api/auth/login { identifier, password } +router.post('/login', async (req, res, next) => { + const identifier = String(req.body?.identifier || '').trim(); + const password = String(req.body?.password || ''); + + if (!identifier || !password) { + return res.status(400).json({ error: 'Benutzername und Passwort sind erforderlich.' }); + } + + try { + const [rows] = await pool.query( + 'SELECT * FROM icon_users WHERE username = ? OR email = ? LIMIT 1', + [identifier, identifier.toLowerCase()] + ); + const user = rows[0]; + + // Bewusst dieselbe Meldung fuer "kein Benutzer" und "falsches Passwort", + // damit sich ueber die API keine Benutzernamen aufzaehlen lassen. + const invalid = () => res.status(401).json({ error: 'Benutzername oder Passwort ist falsch.' }); + if (!user) return invalid(); + + const ok = await verifyPassword(password, user); + if (!ok) return invalid(); + if (!user.is_active) { + return res.status(403).json({ error: 'Dieses Benutzerkonto ist deaktiviert.' }); + } + + res.json({ token: signToken(user), user: publicUser(user) }); + } catch (err) { + next(err); + } +}); + +/* + * Passwortpruefung mit Uebergang aus dem alten CRM. + * + * Dort lagen die Passwoerter als ungesalzenes SHA-256 in Supabase. Solche + * Hashes sind an ihrer Form erkennbar (64 Hexziffern) - ein bcrypt-Hash + * beginnt immer mit $2. Stimmt ein Altpasswort, wird es im selben Zug auf + * bcrypt umgeschrieben: beim naechsten Mal gilt nur noch der starke Hash. + * + * Das ist bewusst kein dauerhafter zweiter Weg, sondern eine Einbahnstrasse - + * neu vergeben wird ausschliesslich bcrypt. + */ +async function verifyPassword(password, user) { + const hash = String(user.password_hash ?? ''); + + if (/^[a-f0-9]{64}$/i.test(hash)) { + const legacy = createHash('sha256').update(password, 'utf8').digest('hex'); + if (legacy.toLowerCase() !== hash.toLowerCase()) return false; + + await pool.query('UPDATE icon_users SET password_hash = ? WHERE id = ?', [ + await bcrypt.hash(password, 12), + user.id, + ]); + console.log(`[ICON CRM] Passwort von "${user.username}" auf bcrypt umgestellt.`); + return true; + } + + return bcrypt.compare(password, hash); +} + +// GET /api/auth/me +router.get('/me', requireAuth, (req, res) => { + res.json({ user: publicUser(req.user) }); +}); + +// PATCH /api/auth/me { color?, avatar?, theme?, theme_mode? } +// Jeder darf seine eigene Farbe, sein Foto und seine Darstellung setzen. +// null bzw. "" setzt das Feld zurueck. +router.patch('/me', requireAuth, async (req, res, next) => { + const fields = []; + const values = []; + const changed = {}; + + for (const [field, normalize] of [ + ['color', normalizeColor], + ['avatar', normalizeAvatar], + ['theme', normalizeTheme], + ['theme_mode', normalizeThemeMode], + ]) { + if (req.body?.[field] === undefined) continue; + const result = normalize(req.body[field]); + if (result.error) return res.status(400).json({ error: result.error }); + fields.push(`${field} = ?`); + values.push(result.value); + changed[field] = result.value; + } + + if (fields.length === 0) { + return res.status(400).json({ error: 'Keine Änderungen übergeben.' }); + } + + try { + await pool.query(`UPDATE icon_users SET ${fields.join(', ')} WHERE id = ?`, [...values, req.user.id]); + res.json({ user: publicUser({ ...req.user, ...changed }) }); + } catch (err) { + next(err); + } +}); + +// POST /api/auth/change-password { current_password, new_password } +router.post('/change-password', requireAuth, async (req, res, next) => { + const current = String(req.body?.current_password || ''); + const next_ = String(req.body?.new_password || ''); + + if (!current || !next_) { + return res.status(400).json({ error: 'Aktuelles und neues Passwort sind erforderlich.' }); + } + if (next_.length < 8) { + return res.status(400).json({ error: 'Das neue Passwort muss mindestens 8 Zeichen haben.' }); + } + + try { + const [rows] = await pool.query('SELECT password_hash FROM icon_users WHERE id = ? LIMIT 1', [ + req.user.id, + ]); + if (!rows[0]) return res.status(404).json({ error: 'Benutzer nicht gefunden.' }); + + const ok = await bcrypt.compare(current, rows[0].password_hash); + if (!ok) return res.status(400).json({ error: 'Das aktuelle Passwort ist falsch.' }); + + await pool.query('UPDATE icon_users SET password_hash = ? WHERE id = ?', [ + await bcrypt.hash(next_, 12), + req.user.id, + ]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/companies.js b/server/src/routes/companies.js new file mode 100644 index 0000000..0be7754 --- /dev/null +++ b/server/src/routes/companies.js @@ -0,0 +1,379 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { logHistory, diffFields, describeChanges, indexMentions } from '../history.js'; + +const router = express.Router(); +router.use(requireAuth); + +/* + * Unternehmen - die fruehere Ansicht "Kunden". + * + * Ein Kunde ist hier kein eigener Datentyp mehr, sondern ein Unternehmen mit + * is_customer = 1. Der Umschalter in der Oberflaeche schickt deshalb nur den + * Parameter `scope` und bekommt dieselbe Struktur zurueck. + */ + +const EDITABLE = [ + 'name', + 'group_name', + 'industry', + 'employees', + 'website', + 'address', + 'logo', + 'rating', + 'is_customer', + 'customer_state', + 'freelancers', + 'notes', + 'owner_id', + 'primary_contact_id', +]; + +function readBody(body) { + const out = {}; + if (body.name !== undefined) out.name = String(body.name).trim().slice(0, 190); + if (body.group_name !== undefined) out.group_name = emptyToNull(body.group_name, 190); + if (body.industry !== undefined) out.industry = emptyToNull(body.industry, 190); + if (body.employees !== undefined) { + const n = Number(body.employees); + out.employees = Number.isFinite(n) && n >= 0 ? Math.round(n) : null; + } + if (body.website !== undefined) out.website = emptyToNull(body.website, 300); + if (body.address !== undefined) out.address = emptyToNull(body.address, 2000); + if (body.logo !== undefined) out.logo = body.logo ? String(body.logo) : null; + if (body.rating !== undefined) { + const n = Number(body.rating); + out.rating = Number.isFinite(n) ? Math.min(5, Math.max(0, Math.round(n))) : 0; + } + if (body.is_customer !== undefined) out.is_customer = body.is_customer ? 1 : 0; + if (body.customer_state !== undefined) { + out.customer_state = body.customer_state === 'alumni' ? 'alumni' : 'aktiv'; + } + if (body.freelancers !== undefined) { + out.freelancers = ['ja', 'nein'].includes(body.freelancers) ? body.freelancers : 'unbekannt'; + } + if (body.notes !== undefined) out.notes = body.notes ? String(body.notes) : null; + if (body.owner_id !== undefined) { + const n = Number(body.owner_id); + out.owner_id = Number.isInteger(n) && n > 0 ? n : null; + } + if (body.primary_contact_id !== undefined) { + const n = Number(body.primary_contact_id); + out.primary_contact_id = Number.isInteger(n) && n > 0 ? n : null; + } + return out; +} + +function emptyToNull(value, max) { + const text = String(value ?? '').trim(); + return text ? text.slice(0, max) : null; +} + +// GET /api/companies?scope=all|customers|alumni&q=&industry=&group=&rating_min= +router.get('/', async (req, res, next) => { + const where = []; + const params = []; + + const scope = String(req.query.scope || 'all'); + if (scope === 'customers') where.push("c.is_customer = 1 AND c.customer_state = 'aktiv'"); + else if (scope === 'alumni') where.push("c.is_customer = 1 AND c.customer_state = 'alumni'"); + else if (scope === 'kunden') where.push('c.is_customer = 1'); + + const q = String(req.query.q || '').trim(); + if (q) { + where.push('(c.name LIKE ? OR c.group_name LIKE ? OR c.industry LIKE ? OR c.address LIKE ?)'); + params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`); + } + if (req.query.industry) { + where.push('c.industry = ?'); + params.push(String(req.query.industry)); + } + if (req.query.group) { + where.push('c.group_name = ?'); + params.push(String(req.query.group)); + } + if (req.query.rating_min) { + where.push('c.rating >= ?'); + params.push(Number(req.query.rating_min) || 0); + } + if (req.query.freelancers) { + where.push('c.freelancers = ?'); + params.push(String(req.query.freelancers)); + } + + try { + const [rows] = await pool.query( + `SELECT c.*, + (SELECT COUNT(*) FROM icon_contacts k + WHERE k.company_id = c.id) AS contact_count, + (SELECT COUNT(*) FROM icon_employments e + WHERE e.company_id = c.id AND e.is_current = 0) AS former_count + FROM icon_companies c + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY c.name`, + params + ); + res.json({ companies: rows.map(normalizeRow) }); + } catch (err) { + next(err); + } +}); + +/** Auswahllisten fuer Branche und Gruppe - gespeist aus dem Bestand. */ +router.get('/options', async (req, res, next) => { + try { + const [industries] = await pool.query( + "SELECT DISTINCT industry FROM icon_companies WHERE industry IS NOT NULL AND industry <> '' ORDER BY industry" + ); + const [groups] = await pool.query( + "SELECT DISTINCT group_name FROM icon_companies WHERE group_name IS NOT NULL AND group_name <> '' ORDER BY group_name" + ); + res.json({ + industries: industries.map((r) => r.industry), + groups: groups.map((r) => r.group_name), + }); + } catch (err) { + next(err); + } +}); + +// GET /api/companies/:id - Karte inklusive Stammbaum, Interviews und Historie +router.get('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' }); + + try { + const [rows] = await pool.query('SELECT * FROM icon_companies WHERE id = ? LIMIT 1', [id]); + if (!rows[0]) return res.status(404).json({ error: 'Unternehmen nicht gefunden.' }); + + // Aktuelle Belegschaft, soweit sie uns bekannt ist. + const [current] = await pool.query( + `SELECT id, first_name, last_name, position, email, phone, avatar, kind, status + FROM icon_contacts WHERE company_id = ? ORDER BY last_name, first_name`, + [id] + ); + + /* + * Der Zweig "Verlassen" des Stammbaums: alle, die hier einmal gearbeitet + * haben und es jetzt nicht mehr tun. Ausgeschlossen wird, wer laut + * icon_contacts aktuell wieder hier ist - sonst stuende derselbe Mensch in + * beiden Zweigen. + */ + const [former] = await pool.query( + `SELECT e.id AS employment_id, e.position, e.started_on, e.ended_on, + k.id, k.first_name, k.last_name, k.avatar, k.kind, k.status, + k.company_id AS now_company_id, n.name AS now_company + FROM icon_employments e + JOIN icon_contacts k ON k.id = e.contact_id + LEFT JOIN icon_companies n ON n.id = k.company_id + WHERE e.company_id = ? AND e.is_current = 0 + AND (k.company_id IS NULL OR k.company_id <> ?) + ORDER BY e.ended_on DESC, k.last_name`, + [id, id] + ); + + const [interviews] = await pool.query( + `SELECT i.*, u.name AS user_name, + CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name + FROM icon_interviews i + LEFT JOIN icon_users u ON u.id = i.user_id + LEFT JOIN icon_contacts k ON k.id = i.contact_id + WHERE i.company_id = ? ORDER BY i.held_on DESC`, + [id] + ); + + const [history] = await pool.query( + `SELECT h.*, u.name AS user_name, u.color AS user_color, u.avatar AS user_avatar + FROM icon_history h + LEFT JOIN icon_users u ON u.id = h.user_id + WHERE h.entity_type = 'company' AND h.entity_id = ? + ORDER BY h.created_at DESC, h.id DESC LIMIT 500`, + [id] + ); + + // Wo wurde dieses Unternehmen ueberall per @ genannt? + const [mentioned] = await pool.query( + `SELECT h.*, u.name AS user_name, u.color AS user_color + FROM icon_mentions m + JOIN icon_history h ON h.id = m.source_id AND m.source_type = 'history' + LEFT JOIN icon_users u ON u.id = h.user_id + WHERE m.target_type = 'company' AND m.target_id = ? + ORDER BY h.created_at DESC LIMIT 200`, + [id] + ); + + res.json({ + company: normalizeRow(rows[0]), + contacts: current, + former, + interviews, + history: history.map(normalizeHistory), + mentioned: mentioned.map(normalizeHistory), + }); + } catch (err) { + next(err); + } +}); + +router.post('/', async (req, res, next) => { + const data = readBody(req.body || {}); + if (!data.name) return res.status(400).json({ error: 'Der Name des Unternehmens ist erforderlich.' }); + + try { + const columns = Object.keys(data); + const [result] = await pool.query( + `INSERT INTO icon_companies (${columns.join(', ')}, created_by) + VALUES (${columns.map(() => '?').join(', ')}, ?)`, + [...columns.map((c) => data[c]), req.user.id] + ); + + await logHistory({ + entityType: 'company', + entityId: result.insertId, + entityLabel: data.name, + action: 'created', + text: `${data.name} neu angelegt`, + userId: req.user.id, + }); + if (data.notes) await indexMentions('company', result.insertId, data.notes); + + const [rows] = await pool.query('SELECT * FROM icon_companies WHERE id = ?', [result.insertId]); + res.status(201).json({ company: normalizeRow(rows[0]) }); + } catch (err) { + next(err); + } +}); + +router.patch('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' }); + + try { + const [rows] = await pool.query('SELECT * FROM icon_companies WHERE id = ? LIMIT 1', [id]); + const before = rows[0]; + if (!before) return res.status(404).json({ error: 'Unternehmen nicht gefunden.' }); + + const data = readBody(req.body || {}); + if (data.name !== undefined && !data.name) { + return res.status(400).json({ error: 'Der Name des Unternehmens ist erforderlich.' }); + } + const columns = Object.keys(data); + if (columns.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' }); + + await pool.query( + `UPDATE icon_companies SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`, + [...columns.map((c) => data[c]), id] + ); + + // Das Logo interessiert in der Historie niemanden als Data-URL - der + // Vergleich wuerde nur Hunderte Kilobyte in die Tabelle schreiben. + const changes = diffFields(before, data, EDITABLE.filter((f) => f !== 'logo')); + if (data.logo !== undefined && (before.logo ?? null) !== (data.logo ?? null)) { + changes.logo = ['', data.logo ? 'gesetzt' : 'entfernt']; + } + + if (Object.keys(changes).length > 0) { + await logHistory({ + entityType: 'company', + entityId: id, + entityLabel: data.name ?? before.name, + action: 'updated', + text: `${data.name ?? before.name}: ${describeChanges(changes)}`, + changes, + userId: req.user.id, + }); + } + if (data.notes !== undefined) await indexMentions('company', id, data.notes); + + const [updated] = await pool.query('SELECT * FROM icon_companies WHERE id = ?', [id]); + res.json({ company: normalizeRow(updated[0]) }); + } catch (err) { + next(err); + } +}); + +router.delete('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' }); + + try { + const [rows] = await pool.query('SELECT name FROM icon_companies WHERE id = ? LIMIT 1', [id]); + if (!rows[0]) return res.status(404).json({ error: 'Unternehmen nicht gefunden.' }); + + await pool.query('DELETE FROM icon_companies WHERE id = ?', [id]); + await logHistory({ + entityType: 'company', + entityId: null, + entityLabel: rows[0].name, + action: 'deleted', + text: `${rows[0].name} gelöscht`, + userId: req.user.id, + }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +// POST /api/companies/:id/interviews +router.post('/:id/interviews', async (req, res, next) => { + const id = Number(req.params.id); + const heldOn = String(req.body?.held_on || '').slice(0, 10); + if (!Number.isInteger(id) || !/^\d{4}-\d{2}-\d{2}$/.test(heldOn)) { + return res.status(400).json({ error: 'Unternehmen und Datum sind erforderlich.' }); + } + + try { + const contactId = Number(req.body?.contact_id) || null; + const userId = Number(req.body?.user_id) || req.user.id; + const notes = req.body?.notes ? String(req.body.notes) : null; + + const [result] = await pool.query( + `INSERT INTO icon_interviews (contact_id, company_id, user_id, held_on, notes, created_by) + VALUES (?, ?, ?, ?, ?, ?)`, + [contactId, id, userId, heldOn, notes, req.user.id] + ); + + const [[company]] = await pool.query('SELECT name FROM icon_companies WHERE id = ?', [id]); + await logHistory({ + entityType: 'company', + entityId: id, + entityLabel: company?.name, + action: 'interview', + text: `Interview am ${heldOn}${notes ? ` – ${notes}` : ''}`, + userId: req.user.id, + }); + + res.status(201).json({ id: result.insertId }); + } catch (err) { + next(err); + } +}); + +function normalizeRow(row) { + return { + ...row, + is_customer: Boolean(row.is_customer), + rating: Number(row.rating ?? 0), + employees: row.employees === null ? null : Number(row.employees), + contact_count: row.contact_count === undefined ? undefined : Number(row.contact_count), + former_count: row.former_count === undefined ? undefined : Number(row.former_count), + }; +} + +function normalizeHistory(row) { + return { ...row, changes: row.changes ? safeParse(row.changes) : null }; +} + +function safeParse(value) { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +export default router; +export { normalizeHistory }; diff --git a/server/src/routes/connections.js b/server/src/routes/connections.js new file mode 100644 index 0000000..8f3dcc6 --- /dev/null +++ b/server/src/routes/connections.js @@ -0,0 +1,102 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { logHistory } from '../history.js'; + +const router = express.Router(); +router.use(requireAuth); + +/* + * Verbindungen zwischen Kontakten ("wer kennt wen"). + * + * Das Paar wird immer aufsteigend gespeichert - damit greift der Unique-Key + * in beide Richtungen und A-B laesst sich nicht zusaetzlich als B-A anlegen. + */ + +router.get('/', async (req, res, next) => { + try { + const [rows] = await pool.query( + `SELECT c.*, + CONCAT_WS(' ', a.first_name, a.last_name) AS a_name, + CONCAT_WS(' ', b.first_name, b.last_name) AS b_name + FROM icon_connections c + JOIN icon_contacts a ON a.id = c.contact_a + JOIN icon_contacts b ON b.id = c.contact_b + ORDER BY c.id DESC` + ); + res.json({ connections: rows }); + } catch (err) { + next(err); + } +}); + +router.post('/', async (req, res, next) => { + const a = Number(req.body?.contact_a); + const b = Number(req.body?.contact_b); + if (!Number.isInteger(a) || !Number.isInteger(b) || a < 1 || b < 1) { + return res.status(400).json({ error: 'Zwei Kontakte sind erforderlich.' }); + } + if (a === b) return res.status(400).json({ error: 'Ein Kontakt kann nicht mit sich selbst verbunden werden.' }); + + const [low, high] = a < b ? [a, b] : [b, a]; + const kind = String(req.body?.kind || 'Kontakt').trim().slice(0, 60) || 'Kontakt'; + + try { + const [result] = await pool.query( + `INSERT INTO icon_connections (contact_a, contact_b, kind, created_by) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE kind = VALUES(kind)`, + [low, high, kind, req.user.id] + ); + + const [names] = await pool.query( + "SELECT id, CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts WHERE id IN (?, ?)", + [low, high] + ); + const nameOf = (id) => names.find((row) => row.id === id)?.name ?? 'Unbekannt'; + + for (const id of [low, high]) { + await logHistory({ + entityType: 'contact', + entityId: id, + entityLabel: nameOf(id), + action: 'connection', + text: `Verbindung zu ${nameOf(id === low ? high : low)} angelegt (${kind})`, + userId: req.user.id, + }); + } + + res.status(201).json({ id: result.insertId || null, contact_a: low, contact_b: high, kind }); + } catch (err) { + next(err); + } +}); + +router.patch('/:id', async (req, res, next) => { + const id = Number(req.params.id); + const kind = String(req.body?.kind || '').trim().slice(0, 60); + if (!Number.isInteger(id) || !kind) return res.status(400).json({ error: 'Art der Verbindung fehlt.' }); + + try { + const [result] = await pool.query('UPDATE icon_connections SET kind = ? WHERE id = ?', [kind, id]); + if (result.affectedRows === 0) return res.status(404).json({ error: 'Verbindung nicht gefunden.' }); + res.json({ ok: true, kind }); + } catch (err) { + next(err); + } +}); + +router.delete('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' }); + + try { + const [result] = await pool.query('DELETE FROM icon_connections WHERE id = ?', [id]); + if (result.affectedRows === 0) return res.status(404).json({ error: 'Verbindung nicht gefunden.' }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +export default router;