From 28763fed6d8d1ccadc7eeaf9031e0bc19b51f683 Mon Sep 17 00:00:00 2001 From: Sascha Date: Mon, 14 Sep 2026 16:55:34 +0200 Subject: [PATCH] Quellcode des ICON CRM (3) --- legacy/types.ts | 62 + metadata.json | 5 + package-lock.json | 3426 ++++++++++++++++++++++++++++ package.json | 29 + public/favicon.ico | Bin 0 -> 6456 bytes public/favicon.png | Bin 0 -> 7539 bytes public/logo.png | Bin 0 -> 63358 bytes public/themes/halflife-header.svg | 40 + public/themes/halflife-sidebar.svg | 26 + public/themes/pixel-header.svg | 31 + public/themes/pixel-sidebar.svg | 33 + server/.env.example | 54 + server/package.json | 23 + server/src/ai.js | 195 ++ server/src/cvPhoto.js | 280 +++ server/src/cvText.js | 83 + server/src/db.js | 200 ++ server/src/history.js | 176 ++ server/src/linkedinPhoto.js | 199 ++ server/src/middleware/auth.js | 62 + server/src/migrate.js | 388 ++++ server/src/routes/ai.js | 196 ++ server/src/routes/auth.js | 169 ++ server/src/routes/companies.js | 379 +++ server/src/routes/connections.js | 102 + 25 files changed, 6158 insertions(+) create mode 100644 legacy/types.ts create mode 100644 metadata.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.ico create mode 100644 public/favicon.png create mode 100644 public/logo.png create mode 100644 public/themes/halflife-header.svg create mode 100644 public/themes/halflife-sidebar.svg create mode 100644 public/themes/pixel-header.svg create mode 100644 public/themes/pixel-sidebar.svg create mode 100644 server/.env.example create mode 100644 server/package.json create mode 100644 server/src/ai.js create mode 100644 server/src/cvPhoto.js create mode 100644 server/src/cvText.js create mode 100644 server/src/db.js create mode 100644 server/src/history.js create mode 100644 server/src/linkedinPhoto.js create mode 100644 server/src/middleware/auth.js create mode 100644 server/src/migrate.js create mode 100644 server/src/routes/ai.js create mode 100644 server/src/routes/auth.js create mode 100644 server/src/routes/companies.js create mode 100644 server/src/routes/connections.js 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 0000000000000000000000000000000000000000..b535f8cc0b06c7f4f7ac970093eaa336a745ac8f GIT binary patch literal 6456 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 D0#X)f literal 0 HcmV?d00001 diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..ab4ef317e8dd660768832906c9259bdeb28b3a45 GIT binary patch literal 7539 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�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 JPDHLkV1mB0wFLkG literal 0 HcmV?d00001 diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0580ed8eea8bf21acd8fa78bbb8c2dedb7f70891 GIT binary patch literal 63358 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 literal 0 HcmV?d00001 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;