From 639d9bec7581256c46535781dc133838f54a0c59 Mon Sep 17 00:00:00 2001 From: rajch_ales Date: Wed, 5 Aug 2026 13:47:45 +0200 Subject: [PATCH] Migrate project to TypeScript and Playwright scraping --- agent.md | 36 +- package-lock.json | 1255 +++++++--- package.json | 23 +- playwright.config.js => playwright.config.ts | 2 +- public/app.js | 2053 ++++++++--------- public/app.ts | 1180 ++++++++++ .../{read-sql-sheet.mjs => read-sql-sheet.ts} | 1 + src/{cli.js => cli.ts} | 9 +- src/{config.js => config.ts} | 1 + .../{mariadb-client.js => mariadb-client.ts} | 3 +- ...{endpoint-client.js => endpoint-client.ts} | 7 +- src/lib/{args.js => args.ts} | 1 + src/lib/{log.js => log.ts} | 1 + src/lib/{sql-readonly.js => sql-readonly.ts} | 1 + ...te-allowlist.js => sql-write-allowlist.ts} | 1 + src/{server.js => server.ts} | 13 +- src/services/browser-adapter.ts | 53 + ...atalog-products.js => catalog-products.ts} | 71 +- src/services/firefox-adapter.js | 82 - src/services/{languages.js => languages.ts} | 5 +- .../{manufacturers.js => manufacturers.ts} | 5 +- .../{catalog-maker.js => catalog-maker.ts} | 1 + .../{catalog-maker.js => catalog-maker.ts} | 3 +- test/{config.test.js => config.test.ts} | 3 +- ...aker.test.js => sql-catalog-maker.test.ts} | 7 +- ...-readonly.test.js => sql-readonly.test.ts} | 3 +- ...ke.spec.js => catalog-maker.smoke.spec.ts} | 1 + ...abase.e2e.spec.js => database.e2e.spec.ts} | 1 + tsconfig.browser.json | 12 + tsconfig.json | 13 + 30 files changed, 3222 insertions(+), 1625 deletions(-) rename playwright.config.js => playwright.config.ts (94%) create mode 100644 public/app.ts rename scripts/{read-sql-sheet.mjs => read-sql-sheet.ts} (99%) rename src/{cli.js => cli.ts} (69%) rename src/{config.js => config.ts} (99%) rename src/db/{mariadb-client.js => mariadb-client.ts} (96%) rename src/endpoint/{endpoint-client.js => endpoint-client.ts} (97%) rename src/lib/{args.js => args.ts} (98%) rename src/lib/{log.js => log.ts} (94%) rename src/lib/{sql-readonly.js => sql-readonly.ts} (97%) rename src/lib/{sql-write-allowlist.js => sql-write-allowlist.ts} (98%) rename src/{server.js => server.ts} (95%) create mode 100644 src/services/browser-adapter.ts rename src/services/{catalog-products.js => catalog-products.ts} (94%) delete mode 100644 src/services/firefox-adapter.js rename src/services/{languages.js => languages.ts} (66%) rename src/services/{manufacturers.js => manufacturers.ts} (65%) rename src/sql/{catalog-maker.js => catalog-maker.ts} (99%) rename src/workflows/{catalog-maker.js => catalog-maker.ts} (96%) rename test/{config.test.js => config.test.ts} (86%) rename test/{sql-catalog-maker.test.js => sql-catalog-maker.test.ts} (94%) rename test/{sql-readonly.test.js => sql-readonly.test.ts} (88%) rename tests/ui/{catalog-maker.smoke.spec.js => catalog-maker.smoke.spec.ts} (99%) rename tests/ui/{database.e2e.spec.js => database.e2e.spec.ts} (98%) create mode 100644 tsconfig.browser.json create mode 100644 tsconfig.json diff --git a/agent.md b/agent.md index 965a787..b96b3ae 100644 --- a/agent.md +++ b/agent.md @@ -38,11 +38,26 @@ This project replaces the Excel/VBA workflow named `Catalog maker - 20` with a N - Whenever a reusable pattern appears, prefer Tailwind component patterns (`@layer components` with `@apply`) over handwritten legacy CSS; keep one-off layout details as utility classes where practical. - Use Playwright to inspect the rendered result after every visual change at desktop and narrow viewport sizes. +## TypeScript workflow + +- All application, service, SQL, workflow, script and test source files must use TypeScript (`.ts`). +- Run the project through `tsx` during development and compile the browser entrypoint from `public/app.ts` to `public/app.js` before serving it. +- Do not add new JavaScript source files. Generated browser JavaScript is build output only. +- Keep database safety rules and existing runtime behavior unchanged during the migration; add types incrementally to migrated modules. + +## Browser scraping workflow + +- Use Playwright for all supplier/manufacturer website scraping and browser page inspection. +- Do not implement new scraping with raw `fetch`, regex-only HTML parsing, Selenium or direct HTTP shortcuts. +- Scrapers must use controlled browser navigation, selectors, explicit waits and bounded request counts. +- Keep scraping slow and observable, preserve source URL/status information, and handle blocked pages or missing selectors as explicit errors. +- Supplier data remains a local draft until the user explicitly confirms an Apply / Save action. + ## Runtime - Start the web app with `npm run dev`. - Open `http://127.0.0.1:3404/`. -- The current server is `src/server.js`. +- The current server is `src/server.ts`, run through `tsx`. - Configuration is loaded from `config/local.json`, merged over `config/local.example.json`. - `.env` exists for local MariaDB notes, but the application does not yet load `.env` automatically. Do not assume `.env` is active until configuration loading is explicitly wired to it. @@ -78,13 +93,14 @@ There are two selectable data sources: ## Important current files -- `src/server.js`: HTTP server, API routes, mode guard and status endpoint. -- `src/config.js`: configuration loading and database settings. -- `src/endpoint/endpoint-client.js`: endpoint queries and MariaDB routing. -- `src/db/mariadb-client.js`: MariaDB pool, read queries, local writes and connection test. -- `src/services/catalog-products.js`: product, combinations, mapping, source and picture workflow. +- `src/server.ts`: HTTP server, API routes, mode guard and status endpoint. +- `src/config.ts`: configuration loading and database settings. +- `src/endpoint/endpoint-client.ts`: endpoint queries and MariaDB routing. +- `src/db/mariadb-client.ts`: MariaDB pool, read queries, local writes and connection test. +- `src/services/catalog-products.ts`: product, combinations, mapping, source and picture workflow. +- `src/services/browser-adapter.ts`: Playwright navigation, scraping and visible browser inspection. - `public/index.html`: compact catalog UI and settings modal. -- `public/app.js`: UI state, API calls, Local/Live mode header, local settings and source workflow. +- `public/app.ts`: typed browser source compiled to `public/app.js`. - `public/styles.css`: compact modern layout. - `config/local.example.json`: safe configuration template. - `config/local.json`: local secrets and machine-specific configuration; never expose it. @@ -124,9 +140,9 @@ Wire the local connection settings into server configuration without exposing Li ## Verification commands ```powershell -node --check src/server.js -node --check src/db/mariadb-client.js -node --check public/app.js +npm run build:types +npm run build:browser +npm run css:build npm test ``` diff --git a/package-lock.json b/package-lock.json index 3b17de6..47e5934 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,24 +8,461 @@ "name": "catalog-maker-by-magic-ai", "version": "0.1.0", "dependencies": { - "geckodriver": "^6.1.1", "mariadb": "^3.5.3", - "selenium-webdriver": "^4.46.0" + "playwright": "^1.62.1" }, "devDependencies": { "@tailwindcss/cli": "^4.3.3", - "playwright": "^1.62.1", - "tailwindcss": "^4.3.3" + "@types/node": "^26.1.2", + "tailwindcss": "^4.3.3", + "tsx": "^4.23.7", + "typescript": "^7.0.2" }, "engines": { "node": ">=20" } }, - "node_modules/@bazel/runfiles": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz", - "integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==", - "license": "Apache-2.0" + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", @@ -677,52 +1114,344 @@ "undici-types": "~8.3.0" } }, - "node_modules/@wdio/logger": { - "version": "9.29.1", - "resolved": "https://registry.npmjs.org/@wdio/logger/-/logger-9.29.1.tgz", - "integrity": "sha512-0ZAEIo6PNyMIJPlOGkIgyOJUjcd0pC8/QHlVAAe1c91/IcjZ1X+k0yidXHaboJdN7dq1XPUacmhRdtua0U5EZg==", - "license": "MIT", - "dependencies": { - "chalk": "^5.1.2", - "loglevel": "^1.6.0", - "loglevel-plugin-prefix": "^0.8.4", - "safe-regex2": "^5.0.0", - "strip-ansi": "^7.1.0" - }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=18.20.0" + "node": ">=16.20.0" } }, - "node_modules/@zip.js/zip.js": { - "version": "2.8.34", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.34.tgz", - "integrity": "sha512-+6a3lyqq69rpseLbvDPiVIWsZ/HdTGAAD6afFtug6ECPDGttb2dHnPC6cJgdPofYkzL9OvXizegq+DQVfL2rnA==", - "license": "BSD-3-Clause", + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "bun": ">=0.7.0", - "deno": ">=1.0.0", - "node": ">=18.0.0" + "node": ">=16.20.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 14" + "node": ">=16.20.0" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" } }, "node_modules/braces": { @@ -738,53 +1467,6 @@ "node": ">=8" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decamelize": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-6.0.1.tgz", - "integrity": "sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -821,6 +1503,48 @@ "node": ">=10.13.0" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -838,7 +1562,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -849,27 +1572,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/geckodriver": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-6.1.1.tgz", - "integrity": "sha512-/AcCyc9o9o6hUbudaSJM2iOtXbxSLqQPOb4GrPvEN40cjraUeaX/j5kH3mSgwiroyMn7qzx2wM61AQ2XY8j3sA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@wdio/logger": "^9.18.0", - "@zip.js/zip.js": "^2.8.11", - "decamelize": "^6.0.1", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "modern-tar": "^0.7.3" - }, - "bin": { - "geckodriver": "bin/geckodriver.js" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -877,32 +1579,6 @@ "dev": true, "license": "ISC" }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -919,18 +1595,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -964,12 +1628,6 @@ "node": ">=0.12.0" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -980,27 +1638,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1272,25 +1909,6 @@ "node": ">=8" } }, - "node_modules/loglevel": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", - "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, - "node_modules/loglevel-plugin-prefix": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/loglevel-plugin-prefix/-/loglevel-plugin-prefix-0.8.4.tgz", - "integrity": "sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==", - "license": "MIT" - }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -1340,15 +1958,6 @@ "node": ">=8.6" } }, - "node_modules/modern-tar": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.7.tgz", - "integrity": "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1359,12 +1968,6 @@ "node": ">=4" } }, - "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==", - "license": "MIT" - }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -1372,12 +1975,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1402,7 +1999,6 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.62.1" @@ -1421,7 +2017,6 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", - "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -1430,101 +2025,12 @@ "node": ">=20" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/safe-regex2": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", - "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ret": "~0.5.0" - }, - "bin": { - "safe-regex2": "bin/safe-regex2.js" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/selenium-webdriver": { - "version": "4.46.0", - "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.46.0.tgz", - "integrity": "sha512-UlTkgnx9y+bf3QxFsrgUyMqC2oy1Yf+qcOs7xIC/XfsUPv/ow7Sicx6SJ7AeOn2/Z+xF1M8SLqyn983wipI82w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/SeleniumHQ" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/selenium" - } - ], - "license": "Apache-2.0", - "dependencies": { - "@bazel/runfiles": "^6.5.0", - "jszip": "^3.10.1", - "tmp": "^0.2.7", - "ws": "^8.21.0" - }, - "engines": { - "node": ">= 20.0.0" - } - }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1535,30 +2041,6 @@ "node": ">=0.10.0" } }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -1580,15 +2062,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1602,38 +2075,80 @@ "node": ">=8.0" } }, + "node_modules/tsx": { + "version": "4.23.7", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.7.tgz", + "integrity": "sha512-3f/u/+UDCNQ7iwUZW9FCMnNGIHzElGJYh0S/yy8IvWSsn5O7fEO/897FaG7FA2W8yryiRyuwXZ1PYLAKYaqSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/ws": { - "version": "8.21.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", - "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index fcb57e6..d3504e0 100644 --- a/package.json +++ b/package.json @@ -5,26 +5,29 @@ "type": "module", "description": "Node.js replacement for the Excel Catalog maker - 20 workflow.", "scripts": { - "start": "node src/cli.js", - "dev": "node src/server.js", - "catalog:dry-run": "node src/cli.js catalog-maker --dry-run", - "test": "node --test", + "start": "tsx src/cli.ts", + "dev": "tsx src/server.ts", + "catalog:dry-run": "tsx src/cli.ts catalog-maker --dry-run", + "test": "tsx --test test/*.test.ts", "test:ui": "playwright test", - "test:e2e": "playwright test tests/ui/database.e2e.spec.js", + "test:e2e": "playwright test tests/ui/database.e2e.spec.ts", "css:build": "tailwindcss -i ./src/tailwind.css -o ./public/styles.css --minify", - "build": "npm run css:build" + "build:types": "tsc --noEmit", + "build:browser": "tsc -p tsconfig.browser.json", + "build": "npm run build:types && npm run build:browser && npm run css:build" }, "engines": { "node": ">=20" }, "dependencies": { - "geckodriver": "^6.1.1", "mariadb": "^3.5.3", - "selenium-webdriver": "^4.46.0" + "playwright": "^1.62.1" }, "devDependencies": { "@tailwindcss/cli": "^4.3.3", - "playwright": "^1.62.1", - "tailwindcss": "^4.3.3" + "@types/node": "^26.1.2", + "tailwindcss": "^4.3.3", + "tsx": "^4.23.7", + "typescript": "^7.0.2" } } diff --git a/playwright.config.js b/playwright.config.ts similarity index 94% rename from playwright.config.js rename to playwright.config.ts index ee68ef3..fb1bf53 100644 --- a/playwright.config.js +++ b/playwright.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ ...devices["Desktop Chrome"], }, webServer: { - command: "node src/server.js", + command: "tsx src/server.ts", url: "http://127.0.0.1:3404/api/status", reuseExistingServer: true, timeout: 30_000, diff --git a/public/app.js b/public/app.js index da8bb6b..5c67de1 100644 --- a/public/app.js +++ b/public/app.js @@ -1,3 +1,4 @@ +// @ts-nocheck const fieldGrid = document.querySelector("#fieldGrid"); const variantHeader = document.querySelector("#variantHeader"); const manufacturerSelect = document.querySelector("#manufacturerSelect"); @@ -38,17 +39,15 @@ const localDbFields = document.querySelector("#localDbFields"); const localDbInputs = document.querySelectorAll("#localDbFields input"); const testLocalDbButton = document.querySelector("#testLocalDbButton"); const localDbTestResult = document.querySelector("#localDbTestResult"); - const nativeFetch = window.fetch.bind(window); window.fetch = (input, init = {}) => { - const url = typeof input === "string" ? input : input.url; - if (!url.startsWith("/api/")) return nativeFetch(input, init); - - const headers = new Headers(init.headers || (typeof input !== "string" ? input.headers : undefined)); - headers.set("X-Database-Mode", localStorage.getItem("catalog-maker:database-mode") || "local"); - return nativeFetch(input, { ...init, headers }); + const url = typeof input === "string" ? input : input.url; + if (!url.startsWith("/api/")) + return nativeFetch(input, init); + const headers = new Headers(init.headers || (typeof input !== "string" ? input.headers : undefined)); + headers.set("X-Database-Mode", localStorage.getItem("catalog-maker:database-mode") || "local"); + return nativeFetch(input, { ...init, headers }); }; - let currentPosition = 1; let totalPositions = 0; let currentProduct = null; @@ -58,70 +57,66 @@ let applicationMode = "dry-run"; let databaseConnectionState = "unknown"; const localDraftKey = "catalog-maker:current-product-draft:v2"; let localDraftProductKey = ""; - const fieldNames = [ - "id_manufacturer", - "code_manufacture", - "voc_discount", - "max_moc_discount", - "supplier_reference", - "catalog_valid_until", - "name", - "price - moc", - "combinations", - "id_attribute_group", - "id_supplier", - "id_category_default", - "9b_category_name", - "manufacturer_name", - "catalog category", - "gender", - "position/total", - "Photos loaded", - "Reference separator", + "id_manufacturer", + "code_manufacture", + "voc_discount", + "max_moc_discount", + "supplier_reference", + "catalog_valid_until", + "name", + "price - moc", + "combinations", + "id_attribute_group", + "id_supplier", + "id_category_default", + "9b_category_name", + "manufacturer_name", + "catalog category", + "gender", + "position/total", + "Photos loaded", + "Reference separator", ]; - const variantColumns = [ - { key: "status", label: "status", width: 92 }, - { key: "color", label: "color", width: 156 }, - { key: "combi", label: "combi", width: 60, align: "center" }, - { key: "refer", label: "refer", width: 64 }, - { key: "ean13", label: "ean13", width: 148, align: "center", problem: true }, - { key: "nineB", label: "9b", width: 58, align: "center" }, - { key: "supplierStock", label: "sup", width: 58, align: "center" }, - { key: "id", label: "id", width: 74, align: "center" }, - { key: "voc", label: "voc", width: 74, align: "center", amount: true }, - { key: "moc", label: "moc", width: 74, align: "center", amount: true }, - { key: "productAttribute", label: "pr.at", width: 70, align: "center" }, - { key: "catalogColor", label: "color", width: 110 }, - { key: "catalogCombi", label: "combi", width: 70, align: "center" }, - { key: "nineBVoc", label: "9b.voc", width: 74, align: "center", amount: true }, - { key: "nineBMoc", label: "9b.moc", width: 74, align: "center", amount: true }, - { key: "eanStatus", label: "", width: 84, align: "center" }, + { key: "status", label: "status", width: 92 }, + { key: "color", label: "color", width: 156 }, + { key: "combi", label: "combi", width: 60, align: "center" }, + { key: "refer", label: "refer", width: 64 }, + { key: "ean13", label: "ean13", width: 148, align: "center", problem: true }, + { key: "nineB", label: "9b", width: 58, align: "center" }, + { key: "supplierStock", label: "sup", width: 58, align: "center" }, + { key: "id", label: "id", width: 74, align: "center" }, + { key: "voc", label: "voc", width: 74, align: "center", amount: true }, + { key: "moc", label: "moc", width: 74, align: "center", amount: true }, + { key: "productAttribute", label: "pr.at", width: 70, align: "center" }, + { key: "catalogColor", label: "color", width: 110 }, + { key: "catalogCombi", label: "combi", width: 70, align: "center" }, + { key: "nineBVoc", label: "9b.voc", width: 74, align: "center", amount: true }, + { key: "nineBMoc", label: "9b.moc", width: 74, align: "center", amount: true }, + { key: "eanStatus", label: "", width: 84, align: "center" }, ]; - const productFieldMap = { - id_manufacturer: "idManufacturer", - code_manufacture: "codeManufacture", - voc_discount: "vocDiscount", - max_moc_discount: "maxMocDiscount", - supplier_reference: "supplierReference", - catalog_valid_until: "catalogValidUntil", - name: "name", - "price - moc": "priceMoc", - combinations: "combinations", - id_attribute_group: "idAttributeGroup", - id_supplier: "idSupplier", - id_category_default: "idCategoryDefault", - "9b_category_name": "category9bName", - manufacturer_name: "manufacturerName", - "catalog category": "catalogCategory", - gender: "gender", - "position/total": "positionTotal", - "Photos loaded": "photosLoaded", - "Reference separator": "referenceSeparator", + id_manufacturer: "idManufacturer", + code_manufacture: "codeManufacture", + voc_discount: "vocDiscount", + max_moc_discount: "maxMocDiscount", + supplier_reference: "supplierReference", + catalog_valid_until: "catalogValidUntil", + name: "name", + "price - moc": "priceMoc", + combinations: "combinations", + id_attribute_group: "idAttributeGroup", + id_supplier: "idSupplier", + id_category_default: "idCategoryDefault", + "9b_category_name": "category9bName", + manufacturer_name: "manufacturerName", + "catalog category": "catalogCategory", + gender: "gender", + "position/total": "positionTotal", + "Photos loaded": "photosLoaded", + "Reference separator": "referenceSeparator", }; - await loadStatus(); loadTheme(); renderFields(); @@ -129,1049 +124,951 @@ renderTableHeader(); setTableWidth(); await loadManufacturers(); await loadLanguages(); - manufacturerSelect.addEventListener("change", () => { - if (manufacturerSelect.value) { - loadMappingSources(); - loadProductForSelectedManufacturer(1); - } else { - renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); - clearProductState("Select manufacturer to load data."); - } + if (manufacturerSelect.value) { + loadMappingSources(); + loadProductForSelectedManufacturer(1); + } + else { + renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); + clearProductState("Select manufacturer to load data."); + } }); - loadProductButton.disabled = true; loadProductButton.title = "Load existing product attribute info."; loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct); findSourcesButton.addEventListener("click", findSourcesForCurrentProduct); getPicturesButton.addEventListener("click", getPicturesForCurrentProduct); parserDrawerToggle.addEventListener("click", toggleParserDrawer); - previousProductButton.addEventListener("click", () => { - if (!manufacturerSelect.value || currentPosition <= 1) return; - loadProductForSelectedManufacturer(currentPosition - 1); + if (!manufacturerSelect.value || currentPosition <= 1) + return; + loadProductForSelectedManufacturer(currentPosition - 1); }); - nextProductButton.addEventListener("click", () => { - if (!manufacturerSelect.value || (totalPositions && currentPosition >= totalPositions)) return; - loadProductForSelectedManufacturer(currentPosition + 1); + if (!manufacturerSelect.value || (totalPositions && currentPosition >= totalPositions)) + return; + loadProductForSelectedManufacturer(currentPosition + 1); }); - scanInput.addEventListener("keydown", (event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - loadProductByScannedEan(); + if (event.key !== "Enter") + return; + event.preventDefault(); + loadProductByScannedEan(); }); - suggestedCloseButton.addEventListener("click", closeSuggestedProducts); suggestedModal.addEventListener("click", (event) => { - if (event.target === suggestedModal) closeSuggestedProducts(); + if (event.target === suggestedModal) + closeSuggestedProducts(); }); sourceCloseButton.addEventListener("click", closeSourceCandidates); sourceModal.addEventListener("click", (event) => { - if (event.target === sourceModal) closeSourceCandidates(); + if (event.target === sourceModal) + closeSourceCandidates(); }); settingsButton.addEventListener("click", openSettings); themeToggle.addEventListener("click", toggleTheme); settingsCloseButton.addEventListener("click", closeSettings); settingsModal.addEventListener("click", (event) => { - if (event.target === settingsModal) closeSettings(); + if (event.target === settingsModal) + closeSettings(); }); -for (const input of databaseModeInputs) input.addEventListener("change", saveDatabaseMode); -for (const input of permissionInputs) input.addEventListener("change", saveSettings); -for (const input of transactionModeInputs) input.addEventListener("change", saveSettings); -for (const input of localDbInputs) input.addEventListener("change", saveLocalDbSettings); +for (const input of databaseModeInputs) + input.addEventListener("change", saveDatabaseMode); +for (const input of permissionInputs) + input.addEventListener("change", saveSettings); +for (const input of transactionModeInputs) + input.addEventListener("change", saveSettings); +for (const input of localDbInputs) + input.addEventListener("change", saveLocalDbSettings); testLocalDbButton.addEventListener("click", testLocalDbConnection); - loadDatabaseMode(); - setTimeout(() => { - if (manufacturerSelect.value) { - loadMappingSources(); - loadProductForSelectedManufacturer(1); - } else { - renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); - updateNavigationState(); - } + if (manufacturerSelect.value) { + loadMappingSources(); + loadProductForSelectedManufacturer(1); + } + else { + renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); + updateNavigationState(); + } }, 0); - async function loadStatus() { - const response = await fetch("/api/status"); - const status = await response.json(); - applicationMode = status.mode || "dry-run"; - if (status.database) { - databaseStatus.dataset.driver = status.database.driver; - const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local"; - databaseConnectionState = selectedMode === "local" && status.database.driver !== "mariadb" - ? "error" - : "ready"; - } + const response = await fetch("/api/status"); + const status = await response.json(); + applicationMode = status.mode || "dry-run"; + if (status.database) { + databaseStatus.dataset.driver = status.database.driver; + const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local"; + databaseConnectionState = selectedMode === "local" && status.database.driver !== "mariadb" + ? "error" + : "ready"; + } } - function renderFields() { - fieldGrid.replaceChildren( - ...fieldNames.flatMap((name) => { - const label = document.createElement("div"); - label.className = "field-label"; - label.textContent = name; - - const value = document.createElement("div"); - value.className = "field-value"; - value.dataset.field = name; - value.textContent = ""; - - return [label, value]; - }), - ); -} - -function renderTableHeader() { - variantHeader.replaceChildren( - ...variantColumns.map((column) => { - const th = document.createElement("th"); - th.textContent = column.label; - th.dataset.column = column.key; - th.style.width = `${column.width}px`; - th.style.textAlign = column.align || ""; - return th; - }), - ); -} - -function setTableWidth() { - const totalWidth = variantColumns.reduce((sum, column) => sum + column.width, 0); - variantTable.style.minWidth = `${Math.max(totalWidth, 820)}px`; -} - -async function loadManufacturers() { - manufacturerSelect.replaceChildren(createOption("", "Select manufacturer")); - try { - const response = await fetch("/api/manufacturers"); - const data = await response.json(); - if (!response.ok) throw new Error(data.message || "Local DB is not configured."); - - for (const manufacturer of data.items || []) { - manufacturerSelect.append(createOption(String(manufacturer.id), manufacturer.name)); - } - if (data.message) manufacturerSelect.append(createOption("", data.message)); - } catch (error) { - manufacturerSelect.append(createOption("", error.message || "Local DB is not configured.")); - } -} - -function createOption(value, label) { - const option = document.createElement("option"); - option.value = value; - option.textContent = label; - return option; -} - -async function loadLanguages() { - languageSelect.replaceChildren(createOption("", "Select language")); - try { - const response = await fetch("/api/languages"); - const data = await response.json(); - if (!response.ok) throw new Error(data.message || "Local DB is not configured."); - - for (const language of data.items || []) { - const label = language.isoCode ? `${language.name} (${language.isoCode})` : language.name; - languageSelect.append(createOption(String(language.id), label)); - } - if (data.message) languageSelect.append(createOption("", data.message)); - } catch (error) { - languageSelect.append(createOption("", error.message || "Local DB is not configured.")); - } -} - -async function loadMappingSources() { - const manufacturerId = manufacturerSelect.value; - if (!manufacturerId) { - renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); - return; - } - - mappingState.textContent = "Loading mapping..."; - mappingState.className = "mapping-state"; - mappingList.replaceChildren(); - - try { - const params = new URLSearchParams({ manufacturerId, check: "1" }); - const response = await fetch(`/api/catalog-maker/mapping-sources?${params.toString()}`); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || "Mapping load failed."); - } - - renderMappingSources(data); - } catch (error) { - renderMappingSources({ - mapped: false, - items: [], - message: error.message || "Mapping load failed.", - }); - } -} - -function renderMappingSources(data) { - const items = data.items || []; - - if (!data.mapped || !items.length) { - mappingState.textContent = data.message || "Mapping missing"; - mappingState.className = "mapping-state is-missing"; - mappingList.replaceChildren(); - mappingDetails.open = true; - return; - } - - const enabledCount = items.filter((item) => item.enabled).length; - const failedCount = items.filter((item) => item.enabled && item.health && item.health !== "ok").length; - const testedCount = items.filter((item) => item.enabled && item.health === "ok").length; - const ready = enabledCount > 0 && failedCount === 0; - mappingState.textContent = ready - ? `Sources OK ✓ (${testedCount}/${enabledCount})` - : data.message || `Sources problem (${testedCount}/${enabledCount})`; - mappingState.className = ready ? "mapping-state is-ready" : "mapping-state is-missing"; - mappingDetails.open = !ready || failedCount > 0; - - mappingList.replaceChildren( - ...items.map((item) => { - const row = document.createElement("div"); - row.className = `mapping-source-row${item.enabled ? "" : " is-disabled"}`; - - const name = document.createElement("span"); - name.className = "mapping-source-name"; - name.textContent = formatValue(item.name || item.key || item.url || "Source"); - - const status = document.createElement("span"); - status.className = `mapping-source-status ${getMappingHealthClass(item)}`; - status.textContent = getMappingHealthText(item); - - row.append(name, status); - return row; - }), - ); -} - -function getMappingHealthText(item) { - if (!item.enabled) return "off"; - if (item.health === "ok") return `${item.httpStatus || 200} OK`; - if (item.health === "missing-url") return "no URL"; - if (item.httpStatus) return `${item.httpStatus}`; - if (item.health) return "error"; - return "netest"; -} - -function getMappingHealthClass(item) { - if (!item.enabled) return "is-muted"; - if (item.health === "ok") return "is-ok"; - return "is-bad"; -} - -async function loadProductForSelectedManufacturer(position = currentPosition) { - const requestId = ++loadRequestId; - const manufacturerId = manufacturerSelect.value; - if (!manufacturerId) { - clearProductState("Select manufacturer to load data."); - return; - } - - setNavigationEnabled(false); - setTableMessage("Loading product data..."); - - try { - const response = await fetch( - `/api/catalog-maker/product?manufacturerId=${encodeURIComponent(manufacturerId)}&position=${encodeURIComponent(position)}`, - ); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || "Product load failed."); - } - - if (!data.product) { - if (requestId !== loadRequestId) return; - clearProductState(data.message || "No product to do for selected manufacturer."); - return; - } - - if (requestId !== loadRequestId) return; - renderProduct(data.product); - currentCombinations = data.combinations; - renderCombinations(currentCombinations); - } catch (error) { - setTableMessage(error.message); - } finally { - updateNavigationState(); - } -} - -async function loadProductByScannedEan() { - const requestId = ++loadRequestId; - const manufacturerId = manufacturerSelect.value; - const ean = normalizeScannedEan(scanInput.value); - - if (!manufacturerId) { - setTableMessage("Select manufacturer before scanning EAN."); - return; - } - - if (!ean) { - setTableMessage("Scan EAN first."); - return; - } - - setNavigationEnabled(false); - scanInput.disabled = true; - setTableMessage(`Searching EAN ${ean}...`); - - try { - const params = new URLSearchParams({ manufacturerId, ean }); - const response = await fetch(`/api/catalog-maker/product-by-ean?${params.toString()}`); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || "EAN search failed."); - } - - if (!data.product) { - if (requestId !== loadRequestId) return; - await showSuggestedProducts({ ean }); - return; - } - - if (requestId !== loadRequestId) return; - renderProduct(data.product); - currentCombinations = data.combinations; - renderCombinations(currentCombinations); - scanInput.value = ""; - - if (!data.product.idProduct) { - await showSuggestedProducts({ - ean, - supplierReference: data.product.supplierReference, - productName: data.product.name, - }); - } - } catch (error) { - setTableMessage(error.message); - } finally { - scanInput.disabled = false; - scanInput.focus(); - updateNavigationState(); - } -} - -async function showSuggestedProducts({ ean, supplierReference = "", productName = "" }) { - const params = new URLSearchParams({ - manufacturerId: manufacturerSelect.value, - supplierReference: supplierReference || currentProduct?.supplierReference || scanInput.value || ean, - productName: productName || currentProduct?.name || "", - }); - const response = await fetch(`/api/catalog-maker/suggested-products?${params.toString()}`); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || "Suggested product lookup failed."); - } - - suggestedTitle.textContent = `Sugested product for ${ean}`; - renderSuggestedProducts(data.items); - suggestedModal.hidden = false; -} - -function renderSuggestedProducts(items) { - if (!items.length) { - const empty = document.createElement("div"); - empty.className = "suggested-row"; - empty.textContent = "No suggested products found."; - suggestedList.replaceChildren(empty); - return; - } - - suggestedList.replaceChildren( - ...items.map((item) => { - const row = document.createElement("div"); - row.className = "suggested-row"; - - const image = document.createElement("img"); - image.alt = item.name || "Product"; - if (item.imageUrl) image.src = item.imageUrl; - - const id = document.createElement("div"); - id.className = "suggested-id"; - id.textContent = formatValue(item.idProduct); - - const name = document.createElement("div"); - name.className = "suggested-name"; - name.textContent = formatValue(item.name); - name.title = name.textContent; - - const reference = document.createElement("div"); - reference.className = "suggested-reference"; - reference.textContent = formatValue(item.supplierReference); - reference.title = reference.textContent; - - const active = document.createElement("div"); - active.className = "suggested-active"; - active.textContent = formatValue(item.active); - - const pick = document.createElement("button"); - pick.className = "suggested-pick"; - pick.type = "button"; - pick.textContent = "Use"; - pick.addEventListener("click", () => selectSuggestedProduct(item)); - - row.append(image, id, name, reference, active, pick); - return row; - }), - ); -} - -function selectSuggestedProduct(item) { - if (!currentProduct) return; - - currentProduct = { - ...currentProduct, - idProduct: item.idProduct, - imageUrl: item.imageUrl || currentProduct.imageUrl, - }; - recordIdElement.textContent = formatRecordId(item.idProduct); - renderPicture(currentProduct.imageUrl); - closeSuggestedProducts(); - updateNavigationState(); -} - -function closeSuggestedProducts() { - suggestedModal.hidden = true; - suggestedList.replaceChildren(); -} - -async function findSourcesForCurrentProduct() { - if (!manufacturerSelect.value || !currentProduct) { - setTableMessage("Select manufacturer and product first."); - return; - } - - const savedDraft = readLocalDraft(); - if (savedDraft?.sources) { - renderSourceCandidates(savedDraft.sources.items || []); - updateFindSourcesButtonState(); - sourceTitle.textContent = "Source candidates (cached)"; - sourceModal.hidden = false; - return; - } - - const supplierColorGroup = getFirstSupplierColorGroup(); - findSourcesButton.disabled = true; - findSourcesButton.textContent = "Finding sources..."; - - try { - const params = new URLSearchParams({ - manufacturerId: manufacturerSelect.value, - productName: currentProduct.name || "", - supplierReference: currentProduct.supplierReference || "", - ean: supplierColorGroup?.supplierEan || currentCombinations[0]?.ean13 || "", - eanGroup: (supplierColorGroup?.supplierRows || []).map((row) => row.ean13).filter(Boolean).join(","), - color: supplierColorGroup?.color || "", - }); - const response = await fetch(`/api/catalog-maker/find-sources?${params.toString()}`); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || "Source search failed."); - } - - saveLocalDraft({ sources: data }); - updateFindSourcesButtonState(); - renderSourceCandidates(data.items || []); - const targetLabel = supplierColorGroup - ? `${supplierColorGroup.color} / EAN ${supplierColorGroup.supplierEan}` - : currentProduct.name || currentProduct.supplierReference; - sourceTitle.textContent = `Source candidates for ${targetLabel}`; - sourceModal.hidden = false; - } catch (error) { - setTableMessage(error.message || "Source search failed."); - } finally { - updateFindSourcesButtonState(); - } -} - -async function getPicturesForCurrentProduct(sourceKey = "") { - if (!manufacturerSelect.value || !currentProduct) { - setTableMessage("Select manufacturer and product first."); - return; - } - - const savedDraft = readLocalDraft(); - const cachedPictures = savedDraft?.picturesBySource?.[sourceKey || "all"]; - if (cachedPictures) { - renderPictureCandidates(cachedPictures.items || []); - sourceTitle.textContent = `Picture candidates (cached${sourceKey ? `: ${sourceKey}` : ""})`; - sourceModal.hidden = false; - return; - } - - const supplierColorGroup = getFirstSupplierColorGroup(); - getPicturesButton.disabled = true; - getPicturesButton.textContent = "Loading pictures..."; - - try { - const params = new URLSearchParams({ - manufacturerId: manufacturerSelect.value, - productName: currentProduct.name || "", - supplierReference: currentProduct.supplierReference || "", - ean: supplierColorGroup?.supplierEan || currentCombinations[0]?.ean13 || "", - eanGroup: (supplierColorGroup?.supplierRows || []).map((row) => row.ean13).filter(Boolean).join(","), - color: supplierColorGroup?.color || "", - }); - if (sourceKey) params.set("sourceKey", sourceKey); - const response = await fetch(`/api/catalog-maker/get-pictures?${params.toString()}`); - const data = await response.json(); - if (!response.ok) throw new Error(data.message || "Picture lookup failed."); - - const picturesBySource = readLocalDraft()?.picturesBySource || {}; - picturesBySource[sourceKey || "all"] = data; - saveLocalDraft({ picturesBySource }); - - renderPictureCandidates(data.items || []); - sourceTitle.textContent = `Picture candidates${sourceKey ? `: ${sourceKey}` : ""}`; - sourceModal.hidden = false; - } catch (error) { - setTableMessage(error.message || "Picture lookup failed."); - } finally { - getPicturesButton.disabled = false; - getPicturesButton.textContent = "Get pictures"; - updateNavigationState(); - } -} - -function getFirstSupplierColorGroup() { - const groups = new Map(); - - for (const combination of currentCombinations) { - const color = formatValue(combination.color) || "Unknown color"; - const group = groups.get(color) || { - color, - rows: [], - supplierRows: [], - supplierEan: "", - }; - - group.rows.push(combination); - if (Number(combination.supplierStock) === 1) { - group.supplierRows.push(combination); - if (!group.supplierEan) group.supplierEan = combination.ean13; - } - groups.set(color, group); - } - - return [...groups.values()].find((group) => group.supplierRows.length > 0) || null; -} - -function renderSourceCandidates(items) { - if (!items.length) { - const empty = document.createElement("div"); - empty.className = "source-row"; - empty.textContent = "No source mapping found."; - sourceList.replaceChildren(empty); - return; - } - - sourceList.replaceChildren( - ...items.map((item) => { - const section = document.createElement("section"); - section.className = "source-section"; - - const header = document.createElement("div"); - header.className = "source-section-header"; - - const name = document.createElement("strong"); - name.textContent = formatValue(item.source?.name || item.source?.key || "Source"); - - const status = document.createElement("span"); - status.className = `source-status ${item.status === "found" ? "is-ok" : "is-muted"}`; - status.textContent = item.status === "found" ? `${item.candidates.length} found` : item.status; - - const actions = document.createElement("div"); - actions.className = "source-section-actions"; - const pictures = document.createElement("button"); - pictures.type = "button"; - pictures.className = "source-picture-action"; - pictures.textContent = "Get pictures"; - pictures.addEventListener("click", () => - getPicturesForCurrentProduct(item.source?.key || ""), - ); - actions.append(status, pictures); - - header.append(name, actions); - - const rows = item.candidates.length - ? item.candidates.map(renderSourceCandidateRow) - : [renderManualSearchRow(item.searchUrl, item.error || "Open search")]; - - section.append(header, ...rows); - return section; - }), - ); -} - -function renderPictureCandidates(items) { - const rows = []; - for (const item of items) { - for (const candidate of item.candidates || []) { - for (const picture of candidate.pictures || []) { - const row = document.createElement("a"); - row.className = "picture-candidate"; - row.href = picture; - row.target = "_blank"; - row.rel = "noreferrer"; - - const image = document.createElement("img"); - image.src = picture; - image.alt = candidate.rawTitle || item.source?.name || "Product picture"; - image.loading = "lazy"; - - const label = document.createElement("span"); - label.textContent = item.source?.name || item.source?.key || "Source"; - row.append(image, label); - rows.push(row); - } - } - } - - if (!rows.length) { - const empty = document.createElement("div"); - empty.className = "source-row"; - empty.textContent = "No pictures found. Open the source manually to inspect it."; - sourceList.replaceChildren(empty); - return; - } - sourceList.replaceChildren(...rows); -} - -function collectPictureUrls(items) { - return items.flatMap((item) => - (item.candidates || []).flatMap((candidate) => candidate.pictures || []), - ).filter((url, index, all) => all.indexOf(url) === index); -} - -function saveLocalDraft(patch) { - if (!localDraftProductKey) return; - let draft = {}; - try { - draft = JSON.parse(localStorage.getItem(localDraftKey) || "{}"); - } catch { - draft = {}; - } - localStorage.setItem(localDraftKey, JSON.stringify({ - ...draft, - productKey: localDraftProductKey, - ...patch, - })); -} - -function readLocalDraft() { - if (!localDraftProductKey) return null; - try { - const draft = JSON.parse(localStorage.getItem(localDraftKey) || "null"); - return draft?.productKey === localDraftProductKey ? draft : null; - } catch { - return null; - } -} - -function clearLocalDraft() { - localStorage.removeItem(localDraftKey); - localDraftProductKey = ""; - updateFindSourcesButtonState(); -} - -function updateFindSourcesButtonState() { - const hasSources = Boolean(readLocalDraft()?.sources); - findSourcesButton.textContent = hasSources ? "Find sources ✓" : "Find sources"; - findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct; -} - -function renderSourceCandidateRow(candidate) { - const row = document.createElement("a"); - row.className = "source-row"; - row.href = candidate.url; - row.target = "_blank"; - row.rel = "noreferrer"; - - const title = document.createElement("span"); - title.className = "source-row-title"; - title.textContent = formatValue(candidate.title || candidate.url); - title.title = formatValue(candidate.rawTitle || candidate.title || candidate.url); - - const action = document.createElement("span"); - action.className = "source-row-action"; - action.textContent = "Open"; - - row.append(title, action); - return row; -} - -function renderManualSearchRow(url, label) { - return renderSourceCandidateRow({ - title: label, - url, - }); -} - -function closeSourceCandidates() { - sourceModal.hidden = true; - sourceList.replaceChildren(); -} - -function openSettings() { - settingsModal.hidden = false; -} - -function toggleParserDrawer() { - const isOpen = !parserDrawer.hidden; - parserDrawer.hidden = isOpen; - parserDrawerToggle.setAttribute("aria-expanded", String(!isOpen)); - parserDrawerToggle.querySelector(".drawer-chevron").textContent = isOpen ? "⌄" : "⌃"; -} - -function loadTheme() { - const isDark = localStorage.getItem("catalog-maker:theme") === "dark"; - document.documentElement.classList.toggle("dark", isDark); - themeToggle.querySelector("span").textContent = isDark ? "Light theme" : "Dark theme"; - themeToggle.setAttribute("aria-pressed", String(isDark)); -} - -function toggleTheme() { - const isDark = !document.documentElement.classList.contains("dark"); - localStorage.setItem("catalog-maker:theme", isDark ? "dark" : "light"); - loadTheme(); -} - -function closeSettings() { - settingsModal.hidden = true; -} - -function loadDatabaseMode() { - const mode = localStorage.getItem("catalog-maker:database-mode") || "local"; - const input = document.querySelector(`input[name="databaseMode"][value="${mode}"]`); - if (input) input.checked = true; - const permissions = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}"); - for (const input of permissionInputs) input.checked = permissions[input.id] === true; - const transactionMode = localStorage.getItem("catalog-maker:transaction-mode") || "transaction"; - const transactionInput = document.querySelector(`input[name="transactionMode"][value="${transactionMode}"]`); - if (transactionInput) transactionInput.checked = true; - const localDbSettings = JSON.parse(localStorage.getItem("catalog-maker:local-db-settings") || "{}"); - for (const input of localDbInputs) { - if (Object.prototype.hasOwnProperty.call(localDbSettings, input.id)) input.value = localDbSettings[input.id]; - } - syncDatabasePermissions(mode); - syncLocalDbFields(mode); - renderDatabaseModeNote(mode); - refreshDatabaseStatusLabel(); -} - -function saveDatabaseMode(event) { - const mode = event.target.value === "live" ? "live" : "local"; - localStorage.setItem("catalog-maker:database-mode", mode); - syncDatabasePermissions(mode); - syncLocalDbFields(mode); - saveSettings(); - renderDatabaseModeNote(mode); - refreshDatabaseStatusLabel(); -} - -function saveLocalDbSettings() { - const settings = {}; - for (const input of localDbInputs) settings[input.id] = input.value; - localStorage.setItem("catalog-maker:local-db-settings", JSON.stringify(settings)); -} - -async function testLocalDbConnection() { - saveLocalDbSettings(); - testLocalDbButton.disabled = true; - localDbTestResult.textContent = "Testing connection..."; - localDbTestResult.className = "settings-note"; - const payload = Object.fromEntries([...localDbInputs].map((input) => [ - input.id.replace("localDb", "").toLowerCase(), - input.value, - ])); - - try { - const response = await fetch("/api/database/test", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload), - }); - const data = await response.json(); - if (!response.ok || !data.connected) throw new Error(data.message || "Connection failed."); - localDbTestResult.textContent = `Connected: ${data.version}`; - localDbTestResult.className = "settings-note connection-ok"; - } catch (error) { - localDbTestResult.textContent = `Connection error: ${error.message}`; - localDbTestResult.className = "settings-note connection-error"; - } finally { - testLocalDbButton.disabled = false; - } -} - -function syncLocalDbFields(mode) { - localDbFields.hidden = mode !== "local"; -} - -function syncDatabasePermissions(mode) { - const isLive = mode === "live"; - for (const input of permissionInputs) { - input.disabled = isLive; - if (isLive) input.checked = false; - } -} - -function saveSettings() { - const permissions = {}; - for (const input of permissionInputs) permissions[input.id] = input.checked; - localStorage.setItem("catalog-maker:db-permissions", JSON.stringify(permissions)); - const transactionMode = [...transactionModeInputs].find((input) => input.checked)?.value || "transaction"; - localStorage.setItem("catalog-maker:transaction-mode", transactionMode); - refreshDatabaseStatusLabel(); -} - -function refreshDatabaseStatusLabel() { - const mode = localStorage.getItem("catalog-maker:database-mode") || "local"; - const transactionMode = localStorage.getItem("catalog-maker:transaction-mode") || "transaction"; - const permissions = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}"); - const writesEnabled = mode === "local" && ( - permissions.allowInsertToggle || permissions.allowUpdateToggle || permissions.allowDeleteToggle - ); - const dbLabel = mode === "live" ? "Live DB" : "Local DB"; - const configuredDriver = databaseStatus.dataset.driver; - const connectionError = (mode === "local" && configuredDriver !== "mariadb") - || (mode === "live" && configuredDriver !== "endpoint"); - const connectionLabel = connectionError ? "connection error" : "connected"; - const accessLabel = writesEnabled ? "writes enabled" : "read-only"; - const executionLabel = applicationMode === "dry-run" - ? "dry-run" - : (transactionMode === "dry-run" ? "dry-run" : "transaction"); - databaseStatus.textContent = `${dbLabel} / ${connectionLabel} / ${accessLabel} / ${executionLabel}`; - databaseStatus.classList.toggle("database-error", connectionError); -} - -function renderDatabaseModeNote(mode) { - databaseModeNote.textContent = mode === "live" - ? "Live DB selected for preparation. The current project still stays read-only." - : "Local test database selected."; -} - -function renderProduct(product) { - const productKey = `${manufacturerSelect.value}:${product.idProductCatalog || product.supplierReference || product.name || ""}`; - if (localDraftProductKey && localDraftProductKey !== productKey) clearLocalDraft(); - localDraftProductKey = productKey; - currentProduct = product; - recordIdElement.textContent = formatRecordId(product.idProduct); - currentPosition = Number(product.position) || currentPosition; - totalPositions = Number(product.total) || 0; - const enrichedProduct = { - ...product, - positionTotal: - product.position && product.total ? `${product.position}/${product.total}` : "", - }; - - for (const [label, key] of Object.entries(productFieldMap)) { - const valueElement = document.querySelector(`[data-field="${CSS.escape(label)}"]`); - if (!valueElement) continue; - valueElement.textContent = formatValue(enrichedProduct[key]); - } - renderPicture(product.imageUrl); - updateFindSourcesButtonState(); - updateNavigationState(); -} - -function renderCombinations(combinations) { - if (!combinations.length) { - setTableMessage("No combinations found for selected product."); - return; - } - - tableBody.replaceChildren( - ...combinations.map((item) => { - const row = document.createElement("tr"); - - row.append( - ...variantColumns.map((column) => { - const value = item[column.key]; - const text = formatTableValue(value, column); - const cell = document.createElement("td"); - cell.textContent = text; - cell.title = text; - cell.dataset.column = column.key; - cell.style.width = `${column.width}px`; - cell.style.textAlign = column.align || ""; - if ((column.problem && item.hasProblem) || (column.key === "eanStatus" && text)) { - cell.className = "problem-ean"; - } - return cell; - }), - ); - return row; - }), - ); -} - -function clearProductState(message) { - clearLocalDraft(); - recordIdElement.textContent = ""; - currentProduct = null; - currentCombinations = []; - currentPosition = 1; - totalPositions = 0; - for (const valueElement of document.querySelectorAll(".field-value")) { - valueElement.textContent = ""; - } - renderPicture(); - setTableMessage(message); - updateNavigationState(); -} - -function setTableMessage(message) { - const row = document.createElement("tr"); - const cell = document.createElement("td"); - cell.className = "empty-row"; - cell.colSpan = variantColumns.length; - cell.textContent = message; - row.append(cell); - tableBody.replaceChildren(row); -} - -function formatValue(value) { - if (value === null || value === undefined) return ""; - if (typeof value === "number") return String(value); - - const text = String(value).trim(); - if (/^\d{4}-\d{2}-\d{2}T/.test(text)) return formatDate(text); - if (/^-?\d+\.0+$/.test(text)) return String(Number.parseInt(text, 10)); - if (/^-?\d+\.\d+$/.test(text)) return trimDecimal(text); - return text; -} - -function formatTableValue(value, column) { - if (value === null || value === undefined || value === "") return ""; - - if (column.amount) { - const amount = Number(value); - if (Number.isFinite(amount)) return String(Math.round(amount)); - } - - return formatValue(value); -} - -function formatRecordId(value) { - const text = formatValue(value); - return text || ""; -} - -function formatDate(value) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat("cs-CZ", { - day: "2-digit", - month: "2-digit", - year: "numeric", - timeZone: "UTC", - }) - .format(date) - .replaceAll(" ", ""); -} - -function trimDecimal(value) { - const asNumber = Number(value); - if (!Number.isFinite(asNumber)) return value; - return String(asNumber); -} - -function setNavigationEnabled(enabled) { - previousProductButton.disabled = !enabled; - nextProductButton.disabled = !enabled; -} - -function updateNavigationState() { - previousProductButton.disabled = !manufacturerSelect.value || currentPosition <= 1; - nextProductButton.disabled = - !manufacturerSelect.value || (totalPositions > 0 && currentPosition >= totalPositions); - loadProductButton.disabled = !currentProduct?.idProduct; - findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct; - getPicturesButton.disabled = !manufacturerSelect.value || !currentProduct; -} - -function normalizeScannedEan(value) { - const czechKeyboardDigits = new Map([ - ["+", "1"], - ["ě", "2"], - ["š", "3"], - ["č", "4"], - ["ř", "5"], - ["ž", "6"], - ["ý", "7"], - ["á", "8"], - ["í", "9"], - ["é", "0"], - ]); - - return String(value ?? "") - .trim() - .toLowerCase() - .split("") - .map((char) => czechKeyboardDigits.get(char) || char) - .join("") - .replace(/\D/g, ""); -} - -function renderPicture(imageUrl) { - if (!imageUrl) { - pictureSlot.replaceChildren(Object.assign(document.createElement("span"), { textContent: "No picture" })); - return; - } - - const image = document.createElement("img"); - image.src = imageUrl; - image.alt = currentProduct?.name || "Product preview"; - image.loading = "lazy"; - pictureSlot.replaceChildren(image); -} - -async function loadProductInfoForCurrentProduct() { - if (!currentProduct?.idProduct || !manufacturerSelect.value || !currentProduct.supplierReference) { - return; - } - - loadProductButton.disabled = true; - loadProductButton.textContent = "Loading info..."; - - try { - const params = new URLSearchParams({ - productId: currentProduct.idProduct, - manufacturerId: manufacturerSelect.value, - supplierReference: currentProduct.supplierReference, - }); - const response = await fetch(`/api/catalog-maker/product-info?${params.toString()}`); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || "Product info load failed."); - } - - const infoByCatalogId = new Map( - data.items.map((item) => [String(item.idProductCatalog), item]), - ); - currentCombinations = currentCombinations.map((combination) => ({ - ...combination, - ...(infoByCatalogId.get(String(combination.id)) || {}), + fieldGrid.replaceChildren(...fieldNames.flatMap((name) => { + const label = document.createElement("div"); + label.className = "field-label"; + label.textContent = name; + const value = document.createElement("div"); + value.className = "field-value"; + value.dataset.field = name; + value.textContent = ""; + return [label, value]; })); - renderCombinations(currentCombinations); - } catch (error) { - setTableMessage(error.message); - } finally { - loadProductButton.textContent = "Get product info - 00"; - updateNavigationState(); - } } +function renderTableHeader() { + variantHeader.replaceChildren(...variantColumns.map((column) => { + const th = document.createElement("th"); + th.textContent = column.label; + th.dataset.column = column.key; + th.style.width = `${column.width}px`; + th.style.textAlign = column.align || ""; + return th; + })); +} +function setTableWidth() { + const totalWidth = variantColumns.reduce((sum, column) => sum + column.width, 0); + variantTable.style.minWidth = `${Math.max(totalWidth, 820)}px`; +} +async function loadManufacturers() { + manufacturerSelect.replaceChildren(createOption("", "Select manufacturer")); + try { + const response = await fetch("/api/manufacturers"); + const data = await response.json(); + if (!response.ok) + throw new Error(data.message || "Local DB is not configured."); + for (const manufacturer of data.items || []) { + manufacturerSelect.append(createOption(String(manufacturer.id), manufacturer.name)); + } + if (data.message) + manufacturerSelect.append(createOption("", data.message)); + } + catch (error) { + manufacturerSelect.append(createOption("", error.message || "Local DB is not configured.")); + } +} +function createOption(value, label) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + return option; +} +async function loadLanguages() { + languageSelect.replaceChildren(createOption("", "Select language")); + try { + const response = await fetch("/api/languages"); + const data = await response.json(); + if (!response.ok) + throw new Error(data.message || "Local DB is not configured."); + for (const language of data.items || []) { + const label = language.isoCode ? `${language.name} (${language.isoCode})` : language.name; + languageSelect.append(createOption(String(language.id), label)); + } + if (data.message) + languageSelect.append(createOption("", data.message)); + } + catch (error) { + languageSelect.append(createOption("", error.message || "Local DB is not configured.")); + } +} +async function loadMappingSources() { + const manufacturerId = manufacturerSelect.value; + if (!manufacturerId) { + renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); + return; + } + mappingState.textContent = "Loading mapping..."; + mappingState.className = "mapping-state"; + mappingList.replaceChildren(); + try { + const params = new URLSearchParams({ manufacturerId, check: "1" }); + const response = await fetch(`/api/catalog-maker/mapping-sources?${params.toString()}`); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "Mapping load failed."); + } + renderMappingSources(data); + } + catch (error) { + renderMappingSources({ + mapped: false, + items: [], + message: error.message || "Mapping load failed.", + }); + } +} +function renderMappingSources(data) { + const items = data.items || []; + if (!data.mapped || !items.length) { + mappingState.textContent = data.message || "Mapping missing"; + mappingState.className = "mapping-state is-missing"; + mappingList.replaceChildren(); + mappingDetails.open = true; + return; + } + const enabledCount = items.filter((item) => item.enabled).length; + const failedCount = items.filter((item) => item.enabled && item.health && item.health !== "ok").length; + const testedCount = items.filter((item) => item.enabled && item.health === "ok").length; + const ready = enabledCount > 0 && failedCount === 0; + mappingState.textContent = ready + ? `Sources OK ✓ (${testedCount}/${enabledCount})` + : data.message || `Sources problem (${testedCount}/${enabledCount})`; + mappingState.className = ready ? "mapping-state is-ready" : "mapping-state is-missing"; + mappingDetails.open = !ready || failedCount > 0; + mappingList.replaceChildren(...items.map((item) => { + const row = document.createElement("div"); + row.className = `mapping-source-row${item.enabled ? "" : " is-disabled"}`; + const name = document.createElement("span"); + name.className = "mapping-source-name"; + name.textContent = formatValue(item.name || item.key || item.url || "Source"); + const status = document.createElement("span"); + status.className = `mapping-source-status ${getMappingHealthClass(item)}`; + status.textContent = getMappingHealthText(item); + row.append(name, status); + return row; + })); +} +function getMappingHealthText(item) { + if (!item.enabled) + return "off"; + if (item.health === "ok") + return `${item.httpStatus || 200} OK`; + if (item.health === "missing-url") + return "no URL"; + if (item.httpStatus) + return `${item.httpStatus}`; + if (item.health) + return "error"; + return "netest"; +} +function getMappingHealthClass(item) { + if (!item.enabled) + return "is-muted"; + if (item.health === "ok") + return "is-ok"; + return "is-bad"; +} +async function loadProductForSelectedManufacturer(position = currentPosition) { + const requestId = ++loadRequestId; + const manufacturerId = manufacturerSelect.value; + if (!manufacturerId) { + clearProductState("Select manufacturer to load data."); + return; + } + setNavigationEnabled(false); + setTableMessage("Loading product data..."); + try { + const response = await fetch(`/api/catalog-maker/product?manufacturerId=${encodeURIComponent(manufacturerId)}&position=${encodeURIComponent(position)}`); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "Product load failed."); + } + if (!data.product) { + if (requestId !== loadRequestId) + return; + clearProductState(data.message || "No product to do for selected manufacturer."); + return; + } + if (requestId !== loadRequestId) + return; + renderProduct(data.product); + currentCombinations = data.combinations; + renderCombinations(currentCombinations); + } + catch (error) { + setTableMessage(error.message); + } + finally { + updateNavigationState(); + } +} +async function loadProductByScannedEan() { + const requestId = ++loadRequestId; + const manufacturerId = manufacturerSelect.value; + const ean = normalizeScannedEan(scanInput.value); + if (!manufacturerId) { + setTableMessage("Select manufacturer before scanning EAN."); + return; + } + if (!ean) { + setTableMessage("Scan EAN first."); + return; + } + setNavigationEnabled(false); + scanInput.disabled = true; + setTableMessage(`Searching EAN ${ean}...`); + try { + const params = new URLSearchParams({ manufacturerId, ean }); + const response = await fetch(`/api/catalog-maker/product-by-ean?${params.toString()}`); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "EAN search failed."); + } + if (!data.product) { + if (requestId !== loadRequestId) + return; + await showSuggestedProducts({ ean }); + return; + } + if (requestId !== loadRequestId) + return; + renderProduct(data.product); + currentCombinations = data.combinations; + renderCombinations(currentCombinations); + scanInput.value = ""; + if (!data.product.idProduct) { + await showSuggestedProducts({ + ean, + supplierReference: data.product.supplierReference, + productName: data.product.name, + }); + } + } + catch (error) { + setTableMessage(error.message); + } + finally { + scanInput.disabled = false; + scanInput.focus(); + updateNavigationState(); + } +} +async function showSuggestedProducts({ ean, supplierReference = "", productName = "" }) { + const params = new URLSearchParams({ + manufacturerId: manufacturerSelect.value, + supplierReference: supplierReference || currentProduct?.supplierReference || scanInput.value || ean, + productName: productName || currentProduct?.name || "", + }); + const response = await fetch(`/api/catalog-maker/suggested-products?${params.toString()}`); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "Suggested product lookup failed."); + } + suggestedTitle.textContent = `Sugested product for ${ean}`; + renderSuggestedProducts(data.items); + suggestedModal.hidden = false; +} +function renderSuggestedProducts(items) { + if (!items.length) { + const empty = document.createElement("div"); + empty.className = "suggested-row"; + empty.textContent = "No suggested products found."; + suggestedList.replaceChildren(empty); + return; + } + suggestedList.replaceChildren(...items.map((item) => { + const row = document.createElement("div"); + row.className = "suggested-row"; + const image = document.createElement("img"); + image.alt = item.name || "Product"; + if (item.imageUrl) + image.src = item.imageUrl; + const id = document.createElement("div"); + id.className = "suggested-id"; + id.textContent = formatValue(item.idProduct); + const name = document.createElement("div"); + name.className = "suggested-name"; + name.textContent = formatValue(item.name); + name.title = name.textContent; + const reference = document.createElement("div"); + reference.className = "suggested-reference"; + reference.textContent = formatValue(item.supplierReference); + reference.title = reference.textContent; + const active = document.createElement("div"); + active.className = "suggested-active"; + active.textContent = formatValue(item.active); + const pick = document.createElement("button"); + pick.className = "suggested-pick"; + pick.type = "button"; + pick.textContent = "Use"; + pick.addEventListener("click", () => selectSuggestedProduct(item)); + row.append(image, id, name, reference, active, pick); + return row; + })); +} +function selectSuggestedProduct(item) { + if (!currentProduct) + return; + currentProduct = { + ...currentProduct, + idProduct: item.idProduct, + imageUrl: item.imageUrl || currentProduct.imageUrl, + }; + recordIdElement.textContent = formatRecordId(item.idProduct); + renderPicture(currentProduct.imageUrl); + closeSuggestedProducts(); + updateNavigationState(); +} +function closeSuggestedProducts() { + suggestedModal.hidden = true; + suggestedList.replaceChildren(); +} +async function findSourcesForCurrentProduct() { + if (!manufacturerSelect.value || !currentProduct) { + setTableMessage("Select manufacturer and product first."); + return; + } + const savedDraft = readLocalDraft(); + if (savedDraft?.sources) { + renderSourceCandidates(savedDraft.sources.items || []); + updateFindSourcesButtonState(); + sourceTitle.textContent = "Source candidates (cached)"; + sourceModal.hidden = false; + return; + } + const supplierColorGroup = getFirstSupplierColorGroup(); + findSourcesButton.disabled = true; + findSourcesButton.textContent = "Finding sources..."; + try { + const params = new URLSearchParams({ + manufacturerId: manufacturerSelect.value, + productName: currentProduct.name || "", + supplierReference: currentProduct.supplierReference || "", + ean: supplierColorGroup?.supplierEan || currentCombinations[0]?.ean13 || "", + eanGroup: (supplierColorGroup?.supplierRows || []).map((row) => row.ean13).filter(Boolean).join(","), + color: supplierColorGroup?.color || "", + }); + const response = await fetch(`/api/catalog-maker/find-sources?${params.toString()}`); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "Source search failed."); + } + saveLocalDraft({ sources: data }); + updateFindSourcesButtonState(); + renderSourceCandidates(data.items || []); + const targetLabel = supplierColorGroup + ? `${supplierColorGroup.color} / EAN ${supplierColorGroup.supplierEan}` + : currentProduct.name || currentProduct.supplierReference; + sourceTitle.textContent = `Source candidates for ${targetLabel}`; + sourceModal.hidden = false; + } + catch (error) { + setTableMessage(error.message || "Source search failed."); + } + finally { + updateFindSourcesButtonState(); + } +} +async function getPicturesForCurrentProduct(sourceKey = "") { + if (!manufacturerSelect.value || !currentProduct) { + setTableMessage("Select manufacturer and product first."); + return; + } + const savedDraft = readLocalDraft(); + const cachedPictures = savedDraft?.picturesBySource?.[sourceKey || "all"]; + if (cachedPictures) { + renderPictureCandidates(cachedPictures.items || []); + sourceTitle.textContent = `Picture candidates (cached${sourceKey ? `: ${sourceKey}` : ""})`; + sourceModal.hidden = false; + return; + } + const supplierColorGroup = getFirstSupplierColorGroup(); + getPicturesButton.disabled = true; + getPicturesButton.textContent = "Loading pictures..."; + try { + const params = new URLSearchParams({ + manufacturerId: manufacturerSelect.value, + productName: currentProduct.name || "", + supplierReference: currentProduct.supplierReference || "", + ean: supplierColorGroup?.supplierEan || currentCombinations[0]?.ean13 || "", + eanGroup: (supplierColorGroup?.supplierRows || []).map((row) => row.ean13).filter(Boolean).join(","), + color: supplierColorGroup?.color || "", + }); + if (sourceKey) + params.set("sourceKey", sourceKey); + const response = await fetch(`/api/catalog-maker/get-pictures?${params.toString()}`); + const data = await response.json(); + if (!response.ok) + throw new Error(data.message || "Picture lookup failed."); + const picturesBySource = readLocalDraft()?.picturesBySource || {}; + picturesBySource[sourceKey || "all"] = data; + saveLocalDraft({ picturesBySource }); + renderPictureCandidates(data.items || []); + sourceTitle.textContent = `Picture candidates${sourceKey ? `: ${sourceKey}` : ""}`; + sourceModal.hidden = false; + } + catch (error) { + setTableMessage(error.message || "Picture lookup failed."); + } + finally { + getPicturesButton.disabled = false; + getPicturesButton.textContent = "Get pictures"; + updateNavigationState(); + } +} +function getFirstSupplierColorGroup() { + const groups = new Map(); + for (const combination of currentCombinations) { + const color = formatValue(combination.color) || "Unknown color"; + const group = groups.get(color) || { + color, + rows: [], + supplierRows: [], + supplierEan: "", + }; + group.rows.push(combination); + if (Number(combination.supplierStock) === 1) { + group.supplierRows.push(combination); + if (!group.supplierEan) + group.supplierEan = combination.ean13; + } + groups.set(color, group); + } + return [...groups.values()].find((group) => group.supplierRows.length > 0) || null; +} +function renderSourceCandidates(items) { + if (!items.length) { + const empty = document.createElement("div"); + empty.className = "source-row"; + empty.textContent = "No source mapping found."; + sourceList.replaceChildren(empty); + return; + } + sourceList.replaceChildren(...items.map((item) => { + const section = document.createElement("section"); + section.className = "source-section"; + const header = document.createElement("div"); + header.className = "source-section-header"; + const name = document.createElement("strong"); + name.textContent = formatValue(item.source?.name || item.source?.key || "Source"); + const status = document.createElement("span"); + status.className = `source-status ${item.status === "found" ? "is-ok" : "is-muted"}`; + status.textContent = item.status === "found" ? `${item.candidates.length} found` : item.status; + const actions = document.createElement("div"); + actions.className = "source-section-actions"; + const pictures = document.createElement("button"); + pictures.type = "button"; + pictures.className = "source-picture-action"; + pictures.textContent = "Get pictures"; + pictures.addEventListener("click", () => getPicturesForCurrentProduct(item.source?.key || "")); + actions.append(status, pictures); + header.append(name, actions); + const rows = item.candidates.length + ? item.candidates.map(renderSourceCandidateRow) + : [renderManualSearchRow(item.searchUrl, item.error || "Open search")]; + section.append(header, ...rows); + return section; + })); +} +function renderPictureCandidates(items) { + const rows = []; + for (const item of items) { + for (const candidate of item.candidates || []) { + for (const picture of candidate.pictures || []) { + const row = document.createElement("a"); + row.className = "picture-candidate"; + row.href = picture; + row.target = "_blank"; + row.rel = "noreferrer"; + const image = document.createElement("img"); + image.src = picture; + image.alt = candidate.rawTitle || item.source?.name || "Product picture"; + image.loading = "lazy"; + const label = document.createElement("span"); + label.textContent = item.source?.name || item.source?.key || "Source"; + row.append(image, label); + rows.push(row); + } + } + } + if (!rows.length) { + const empty = document.createElement("div"); + empty.className = "source-row"; + empty.textContent = "No pictures found. Open the source manually to inspect it."; + sourceList.replaceChildren(empty); + return; + } + sourceList.replaceChildren(...rows); +} +function collectPictureUrls(items) { + return items.flatMap((item) => (item.candidates || []).flatMap((candidate) => candidate.pictures || [])).filter((url, index, all) => all.indexOf(url) === index); +} +function saveLocalDraft(patch) { + if (!localDraftProductKey) + return; + let draft = {}; + try { + draft = JSON.parse(localStorage.getItem(localDraftKey) || "{}"); + } + catch { + draft = {}; + } + localStorage.setItem(localDraftKey, JSON.stringify({ + ...draft, + productKey: localDraftProductKey, + ...patch, + })); +} +function readLocalDraft() { + if (!localDraftProductKey) + return null; + try { + const draft = JSON.parse(localStorage.getItem(localDraftKey) || "null"); + return draft?.productKey === localDraftProductKey ? draft : null; + } + catch { + return null; + } +} +function clearLocalDraft() { + localStorage.removeItem(localDraftKey); + localDraftProductKey = ""; + updateFindSourcesButtonState(); +} +function updateFindSourcesButtonState() { + const hasSources = Boolean(readLocalDraft()?.sources); + findSourcesButton.textContent = hasSources ? "Find sources ✓" : "Find sources"; + findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct; +} +function renderSourceCandidateRow(candidate) { + const row = document.createElement("a"); + row.className = "source-row"; + row.href = candidate.url; + row.target = "_blank"; + row.rel = "noreferrer"; + const title = document.createElement("span"); + title.className = "source-row-title"; + title.textContent = formatValue(candidate.title || candidate.url); + title.title = formatValue(candidate.rawTitle || candidate.title || candidate.url); + const action = document.createElement("span"); + action.className = "source-row-action"; + action.textContent = "Open"; + row.append(title, action); + return row; +} +function renderManualSearchRow(url, label) { + return renderSourceCandidateRow({ + title: label, + url, + }); +} +function closeSourceCandidates() { + sourceModal.hidden = true; + sourceList.replaceChildren(); +} +function openSettings() { + settingsModal.hidden = false; +} +function toggleParserDrawer() { + const isOpen = !parserDrawer.hidden; + parserDrawer.hidden = isOpen; + parserDrawerToggle.setAttribute("aria-expanded", String(!isOpen)); + parserDrawerToggle.querySelector(".drawer-chevron").textContent = isOpen ? "⌄" : "⌃"; +} +function loadTheme() { + const isDark = localStorage.getItem("catalog-maker:theme") === "dark"; + document.documentElement.classList.toggle("dark", isDark); + themeToggle.querySelector("span").textContent = isDark ? "Light theme" : "Dark theme"; + themeToggle.setAttribute("aria-pressed", String(isDark)); +} +function toggleTheme() { + const isDark = !document.documentElement.classList.contains("dark"); + localStorage.setItem("catalog-maker:theme", isDark ? "dark" : "light"); + loadTheme(); +} +function closeSettings() { + settingsModal.hidden = true; +} +function loadDatabaseMode() { + const mode = localStorage.getItem("catalog-maker:database-mode") || "local"; + const input = document.querySelector(`input[name="databaseMode"][value="${mode}"]`); + if (input) + input.checked = true; + const permissions = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}"); + for (const input of permissionInputs) + input.checked = permissions[input.id] === true; + const transactionMode = localStorage.getItem("catalog-maker:transaction-mode") || "transaction"; + const transactionInput = document.querySelector(`input[name="transactionMode"][value="${transactionMode}"]`); + if (transactionInput) + transactionInput.checked = true; + const localDbSettings = JSON.parse(localStorage.getItem("catalog-maker:local-db-settings") || "{}"); + for (const input of localDbInputs) { + if (Object.prototype.hasOwnProperty.call(localDbSettings, input.id)) + input.value = localDbSettings[input.id]; + } + syncDatabasePermissions(mode); + syncLocalDbFields(mode); + renderDatabaseModeNote(mode); + refreshDatabaseStatusLabel(); +} +function saveDatabaseMode(event) { + const mode = event.target.value === "live" ? "live" : "local"; + localStorage.setItem("catalog-maker:database-mode", mode); + syncDatabasePermissions(mode); + syncLocalDbFields(mode); + saveSettings(); + renderDatabaseModeNote(mode); + refreshDatabaseStatusLabel(); +} +function saveLocalDbSettings() { + const settings = {}; + for (const input of localDbInputs) + settings[input.id] = input.value; + localStorage.setItem("catalog-maker:local-db-settings", JSON.stringify(settings)); +} +async function testLocalDbConnection() { + saveLocalDbSettings(); + testLocalDbButton.disabled = true; + localDbTestResult.textContent = "Testing connection..."; + localDbTestResult.className = "settings-note"; + const payload = Object.fromEntries([...localDbInputs].map((input) => [ + input.id.replace("localDb", "").toLowerCase(), + input.value, + ])); + try { + const response = await fetch("/api/database/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok || !data.connected) + throw new Error(data.message || "Connection failed."); + localDbTestResult.textContent = `Connected: ${data.version}`; + localDbTestResult.className = "settings-note connection-ok"; + } + catch (error) { + localDbTestResult.textContent = `Connection error: ${error.message}`; + localDbTestResult.className = "settings-note connection-error"; + } + finally { + testLocalDbButton.disabled = false; + } +} +function syncLocalDbFields(mode) { + localDbFields.hidden = mode !== "local"; +} +function syncDatabasePermissions(mode) { + const isLive = mode === "live"; + for (const input of permissionInputs) { + input.disabled = isLive; + if (isLive) + input.checked = false; + } +} +function saveSettings() { + const permissions = {}; + for (const input of permissionInputs) + permissions[input.id] = input.checked; + localStorage.setItem("catalog-maker:db-permissions", JSON.stringify(permissions)); + const transactionMode = [...transactionModeInputs].find((input) => input.checked)?.value || "transaction"; + localStorage.setItem("catalog-maker:transaction-mode", transactionMode); + refreshDatabaseStatusLabel(); +} +function refreshDatabaseStatusLabel() { + const mode = localStorage.getItem("catalog-maker:database-mode") || "local"; + const transactionMode = localStorage.getItem("catalog-maker:transaction-mode") || "transaction"; + const permissions = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}"); + const writesEnabled = mode === "local" && (permissions.allowInsertToggle || permissions.allowUpdateToggle || permissions.allowDeleteToggle); + const dbLabel = mode === "live" ? "Live DB" : "Local DB"; + const configuredDriver = databaseStatus.dataset.driver; + const connectionError = (mode === "local" && configuredDriver !== "mariadb") + || (mode === "live" && configuredDriver !== "endpoint"); + const connectionLabel = connectionError ? "connection error" : "connected"; + const accessLabel = writesEnabled ? "writes enabled" : "read-only"; + const executionLabel = applicationMode === "dry-run" + ? "dry-run" + : (transactionMode === "dry-run" ? "dry-run" : "transaction"); + databaseStatus.textContent = `${dbLabel} / ${connectionLabel} / ${accessLabel} / ${executionLabel}`; + databaseStatus.classList.toggle("database-error", connectionError); +} +function renderDatabaseModeNote(mode) { + databaseModeNote.textContent = mode === "live" + ? "Live DB selected for preparation. The current project still stays read-only." + : "Local test database selected."; +} +function renderProduct(product) { + const productKey = `${manufacturerSelect.value}:${product.idProductCatalog || product.supplierReference || product.name || ""}`; + if (localDraftProductKey && localDraftProductKey !== productKey) + clearLocalDraft(); + localDraftProductKey = productKey; + currentProduct = product; + recordIdElement.textContent = formatRecordId(product.idProduct); + currentPosition = Number(product.position) || currentPosition; + totalPositions = Number(product.total) || 0; + const enrichedProduct = { + ...product, + positionTotal: product.position && product.total ? `${product.position}/${product.total}` : "", + }; + for (const [label, key] of Object.entries(productFieldMap)) { + const valueElement = document.querySelector(`[data-field="${CSS.escape(label)}"]`); + if (!valueElement) + continue; + valueElement.textContent = formatValue(enrichedProduct[key]); + } + renderPicture(product.imageUrl); + updateFindSourcesButtonState(); + updateNavigationState(); +} +function renderCombinations(combinations) { + if (!combinations.length) { + setTableMessage("No combinations found for selected product."); + return; + } + tableBody.replaceChildren(...combinations.map((item) => { + const row = document.createElement("tr"); + row.append(...variantColumns.map((column) => { + const value = item[column.key]; + const text = formatTableValue(value, column); + const cell = document.createElement("td"); + cell.textContent = text; + cell.title = text; + cell.dataset.column = column.key; + cell.style.width = `${column.width}px`; + cell.style.textAlign = column.align || ""; + if ((column.problem && item.hasProblem) || (column.key === "eanStatus" && text)) { + cell.className = "problem-ean"; + } + return cell; + })); + return row; + })); +} +function clearProductState(message) { + clearLocalDraft(); + recordIdElement.textContent = ""; + currentProduct = null; + currentCombinations = []; + currentPosition = 1; + totalPositions = 0; + for (const valueElement of document.querySelectorAll(".field-value")) { + valueElement.textContent = ""; + } + renderPicture(); + setTableMessage(message); + updateNavigationState(); +} +function setTableMessage(message) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + cell.className = "empty-row"; + cell.colSpan = variantColumns.length; + cell.textContent = message; + row.append(cell); + tableBody.replaceChildren(row); +} +function formatValue(value) { + if (value === null || value === undefined) + return ""; + if (typeof value === "number") + return String(value); + const text = String(value).trim(); + if (/^\d{4}-\d{2}-\d{2}T/.test(text)) + return formatDate(text); + if (/^-?\d+\.0+$/.test(text)) + return String(Number.parseInt(text, 10)); + if (/^-?\d+\.\d+$/.test(text)) + return trimDecimal(text); + return text; +} +function formatTableValue(value, column) { + if (value === null || value === undefined || value === "") + return ""; + if (column.amount) { + const amount = Number(value); + if (Number.isFinite(amount)) + return String(Math.round(amount)); + } + return formatValue(value); +} +function formatRecordId(value) { + const text = formatValue(value); + return text || ""; +} +function formatDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) + return value; + return new Intl.DateTimeFormat("cs-CZ", { + day: "2-digit", + month: "2-digit", + year: "numeric", + timeZone: "UTC", + }) + .format(date) + .replaceAll(" ", ""); +} +function trimDecimal(value) { + const asNumber = Number(value); + if (!Number.isFinite(asNumber)) + return value; + return String(asNumber); +} +function setNavigationEnabled(enabled) { + previousProductButton.disabled = !enabled; + nextProductButton.disabled = !enabled; +} +function updateNavigationState() { + previousProductButton.disabled = !manufacturerSelect.value || currentPosition <= 1; + nextProductButton.disabled = + !manufacturerSelect.value || (totalPositions > 0 && currentPosition >= totalPositions); + loadProductButton.disabled = !currentProduct?.idProduct; + findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct; + getPicturesButton.disabled = !manufacturerSelect.value || !currentProduct; +} +function normalizeScannedEan(value) { + const czechKeyboardDigits = new Map([ + ["+", "1"], + ["ě", "2"], + ["š", "3"], + ["č", "4"], + ["ř", "5"], + ["ž", "6"], + ["ý", "7"], + ["á", "8"], + ["í", "9"], + ["é", "0"], + ]); + return String(value ?? "") + .trim() + .toLowerCase() + .split("") + .map((char) => czechKeyboardDigits.get(char) || char) + .join("") + .replace(/\D/g, ""); +} +function renderPicture(imageUrl) { + if (!imageUrl) { + pictureSlot.replaceChildren(Object.assign(document.createElement("span"), { textContent: "No picture" })); + return; + } + const image = document.createElement("img"); + image.src = imageUrl; + image.alt = currentProduct?.name || "Product preview"; + image.loading = "lazy"; + pictureSlot.replaceChildren(image); +} +async function loadProductInfoForCurrentProduct() { + if (!currentProduct?.idProduct || !manufacturerSelect.value || !currentProduct.supplierReference) { + return; + } + loadProductButton.disabled = true; + loadProductButton.textContent = "Loading info..."; + try { + const params = new URLSearchParams({ + productId: currentProduct.idProduct, + manufacturerId: manufacturerSelect.value, + supplierReference: currentProduct.supplierReference, + }); + const response = await fetch(`/api/catalog-maker/product-info?${params.toString()}`); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "Product info load failed."); + } + const infoByCatalogId = new Map(data.items.map((item) => [String(item.idProductCatalog), item])); + currentCombinations = currentCombinations.map((combination) => ({ + ...combination, + ...(infoByCatalogId.get(String(combination.id)) || {}), + })); + renderCombinations(currentCombinations); + } + catch (error) { + setTableMessage(error.message); + } + finally { + loadProductButton.textContent = "Get product info - 00"; + updateNavigationState(); + } +} +export {}; diff --git a/public/app.ts b/public/app.ts new file mode 100644 index 0000000..a25448f --- /dev/null +++ b/public/app.ts @@ -0,0 +1,1180 @@ +// @ts-nocheck +const fieldGrid = document.querySelector("#fieldGrid"); +const variantHeader = document.querySelector("#variantHeader"); +const manufacturerSelect = document.querySelector("#manufacturerSelect"); +const languageSelect = document.querySelector("#languageSelect"); +const recordIdElement = document.querySelector("#recordId"); +const loadProductButton = document.querySelector("#loadProductButton"); +const previousProductButton = document.querySelector("#previousProductButton"); +const nextProductButton = document.querySelector("#nextProductButton"); +const tableBody = document.querySelector(".variant-table tbody"); +const variantTable = document.querySelector(".variant-table"); +const pictureSlot = document.querySelector("#pictureSlot"); +const scanInput = document.querySelector("#scanInput"); +const suggestedModal = document.querySelector("#suggestedModal"); +const suggestedTitle = document.querySelector("#suggestedTitle"); +const suggestedList = document.querySelector("#suggestedList"); +const suggestedCloseButton = document.querySelector("#suggestedCloseButton"); +const mappingState = document.querySelector("#mappingState"); +const mappingList = document.querySelector("#mappingList"); +const mappingDetails = document.querySelector("#mappingDetails"); +const findSourcesButton = document.querySelector("#findSourcesButton"); +const getPicturesButton = document.querySelector("#getPicturesButton"); +const parserDrawerToggle = document.querySelector("#parserDrawerToggle"); +const parserDrawer = document.querySelector("#parserDrawer"); +const sourceModal = document.querySelector("#sourceModal"); +const sourceTitle = document.querySelector("#sourceTitle"); +const sourceList = document.querySelector("#sourceList"); +const sourceCloseButton = document.querySelector("#sourceCloseButton"); +const settingsButton = document.querySelector("#settingsButton"); +const themeToggle = document.querySelector("#themeToggle"); +const settingsModal = document.querySelector("#settingsModal"); +const settingsCloseButton = document.querySelector("#settingsCloseButton"); +const databaseModeNote = document.querySelector("#databaseModeNote"); +const databaseModeInputs = document.querySelectorAll('input[name="databaseMode"]'); +const permissionInputs = document.querySelectorAll("#allowInsertToggle, #allowUpdateToggle, #allowDeleteToggle"); +const transactionModeInputs = document.querySelectorAll('input[name="transactionMode"]'); +const databaseStatus = document.querySelector("#databaseStatus"); +const localDbFields = document.querySelector("#localDbFields"); +const localDbInputs = document.querySelectorAll("#localDbFields input"); +const testLocalDbButton = document.querySelector("#testLocalDbButton"); +const localDbTestResult = document.querySelector("#localDbTestResult"); + +const nativeFetch = window.fetch.bind(window); +window.fetch = (input, init = {}) => { + const url = typeof input === "string" ? input : input.url; + if (!url.startsWith("/api/")) return nativeFetch(input, init); + + const headers = new Headers(init.headers || (typeof input !== "string" ? input.headers : undefined)); + headers.set("X-Database-Mode", localStorage.getItem("catalog-maker:database-mode") || "local"); + return nativeFetch(input, { ...init, headers }); +}; + +let currentPosition = 1; +let totalPositions = 0; +let currentProduct = null; +let currentCombinations = []; +let loadRequestId = 0; +let applicationMode = "dry-run"; +let databaseConnectionState = "unknown"; +const localDraftKey = "catalog-maker:current-product-draft:v2"; +let localDraftProductKey = ""; + +const fieldNames = [ + "id_manufacturer", + "code_manufacture", + "voc_discount", + "max_moc_discount", + "supplier_reference", + "catalog_valid_until", + "name", + "price - moc", + "combinations", + "id_attribute_group", + "id_supplier", + "id_category_default", + "9b_category_name", + "manufacturer_name", + "catalog category", + "gender", + "position/total", + "Photos loaded", + "Reference separator", +]; + +const variantColumns = [ + { key: "status", label: "status", width: 92 }, + { key: "color", label: "color", width: 156 }, + { key: "combi", label: "combi", width: 60, align: "center" }, + { key: "refer", label: "refer", width: 64 }, + { key: "ean13", label: "ean13", width: 148, align: "center", problem: true }, + { key: "nineB", label: "9b", width: 58, align: "center" }, + { key: "supplierStock", label: "sup", width: 58, align: "center" }, + { key: "id", label: "id", width: 74, align: "center" }, + { key: "voc", label: "voc", width: 74, align: "center", amount: true }, + { key: "moc", label: "moc", width: 74, align: "center", amount: true }, + { key: "productAttribute", label: "pr.at", width: 70, align: "center" }, + { key: "catalogColor", label: "color", width: 110 }, + { key: "catalogCombi", label: "combi", width: 70, align: "center" }, + { key: "nineBVoc", label: "9b.voc", width: 74, align: "center", amount: true }, + { key: "nineBMoc", label: "9b.moc", width: 74, align: "center", amount: true }, + { key: "eanStatus", label: "", width: 84, align: "center" }, +]; + +const productFieldMap = { + id_manufacturer: "idManufacturer", + code_manufacture: "codeManufacture", + voc_discount: "vocDiscount", + max_moc_discount: "maxMocDiscount", + supplier_reference: "supplierReference", + catalog_valid_until: "catalogValidUntil", + name: "name", + "price - moc": "priceMoc", + combinations: "combinations", + id_attribute_group: "idAttributeGroup", + id_supplier: "idSupplier", + id_category_default: "idCategoryDefault", + "9b_category_name": "category9bName", + manufacturer_name: "manufacturerName", + "catalog category": "catalogCategory", + gender: "gender", + "position/total": "positionTotal", + "Photos loaded": "photosLoaded", + "Reference separator": "referenceSeparator", +}; + +await loadStatus(); +loadTheme(); +renderFields(); +renderTableHeader(); +setTableWidth(); +await loadManufacturers(); +await loadLanguages(); + +manufacturerSelect.addEventListener("change", () => { + if (manufacturerSelect.value) { + loadMappingSources(); + loadProductForSelectedManufacturer(1); + } else { + renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); + clearProductState("Select manufacturer to load data."); + } +}); + +loadProductButton.disabled = true; +loadProductButton.title = "Load existing product attribute info."; +loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct); +findSourcesButton.addEventListener("click", findSourcesForCurrentProduct); +getPicturesButton.addEventListener("click", getPicturesForCurrentProduct); +parserDrawerToggle.addEventListener("click", toggleParserDrawer); + +previousProductButton.addEventListener("click", () => { + if (!manufacturerSelect.value || currentPosition <= 1) return; + loadProductForSelectedManufacturer(currentPosition - 1); +}); + +nextProductButton.addEventListener("click", () => { + if (!manufacturerSelect.value || (totalPositions && currentPosition >= totalPositions)) return; + loadProductForSelectedManufacturer(currentPosition + 1); +}); + +scanInput.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + loadProductByScannedEan(); +}); + +suggestedCloseButton.addEventListener("click", closeSuggestedProducts); +suggestedModal.addEventListener("click", (event) => { + if (event.target === suggestedModal) closeSuggestedProducts(); +}); +sourceCloseButton.addEventListener("click", closeSourceCandidates); +sourceModal.addEventListener("click", (event) => { + if (event.target === sourceModal) closeSourceCandidates(); +}); +settingsButton.addEventListener("click", openSettings); +themeToggle.addEventListener("click", toggleTheme); +settingsCloseButton.addEventListener("click", closeSettings); +settingsModal.addEventListener("click", (event) => { + if (event.target === settingsModal) closeSettings(); +}); +for (const input of databaseModeInputs) input.addEventListener("change", saveDatabaseMode); +for (const input of permissionInputs) input.addEventListener("change", saveSettings); +for (const input of transactionModeInputs) input.addEventListener("change", saveSettings); +for (const input of localDbInputs) input.addEventListener("change", saveLocalDbSettings); +testLocalDbButton.addEventListener("click", testLocalDbConnection); + +loadDatabaseMode(); + +setTimeout(() => { + if (manufacturerSelect.value) { + loadMappingSources(); + loadProductForSelectedManufacturer(1); + } else { + renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); + updateNavigationState(); + } +}, 0); + +async function loadStatus() { + const response = await fetch("/api/status"); + const status = await response.json(); + applicationMode = status.mode || "dry-run"; + if (status.database) { + databaseStatus.dataset.driver = status.database.driver; + const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local"; + databaseConnectionState = selectedMode === "local" && status.database.driver !== "mariadb" + ? "error" + : "ready"; + } +} + +function renderFields() { + fieldGrid.replaceChildren( + ...fieldNames.flatMap((name) => { + const label = document.createElement("div"); + label.className = "field-label"; + label.textContent = name; + + const value = document.createElement("div"); + value.className = "field-value"; + value.dataset.field = name; + value.textContent = ""; + + return [label, value]; + }), + ); +} + +function renderTableHeader() { + variantHeader.replaceChildren( + ...variantColumns.map((column) => { + const th = document.createElement("th"); + th.textContent = column.label; + th.dataset.column = column.key; + th.style.width = `${column.width}px`; + th.style.textAlign = column.align || ""; + return th; + }), + ); +} + +function setTableWidth() { + const totalWidth = variantColumns.reduce((sum, column) => sum + column.width, 0); + variantTable.style.minWidth = `${Math.max(totalWidth, 820)}px`; +} + +async function loadManufacturers() { + manufacturerSelect.replaceChildren(createOption("", "Select manufacturer")); + try { + const response = await fetch("/api/manufacturers"); + const data = await response.json(); + if (!response.ok) throw new Error(data.message || "Local DB is not configured."); + + for (const manufacturer of data.items || []) { + manufacturerSelect.append(createOption(String(manufacturer.id), manufacturer.name)); + } + if (data.message) manufacturerSelect.append(createOption("", data.message)); + } catch (error) { + manufacturerSelect.append(createOption("", error.message || "Local DB is not configured.")); + } +} + +function createOption(value, label) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + return option; +} + +async function loadLanguages() { + languageSelect.replaceChildren(createOption("", "Select language")); + try { + const response = await fetch("/api/languages"); + const data = await response.json(); + if (!response.ok) throw new Error(data.message || "Local DB is not configured."); + + for (const language of data.items || []) { + const label = language.isoCode ? `${language.name} (${language.isoCode})` : language.name; + languageSelect.append(createOption(String(language.id), label)); + } + if (data.message) languageSelect.append(createOption("", data.message)); + } catch (error) { + languageSelect.append(createOption("", error.message || "Local DB is not configured.")); + } +} + +async function loadMappingSources() { + const manufacturerId = manufacturerSelect.value; + if (!manufacturerId) { + renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); + return; + } + + mappingState.textContent = "Loading mapping..."; + mappingState.className = "mapping-state"; + mappingList.replaceChildren(); + + try { + const params = new URLSearchParams({ manufacturerId, check: "1" }); + const response = await fetch(`/api/catalog-maker/mapping-sources?${params.toString()}`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Mapping load failed."); + } + + renderMappingSources(data); + } catch (error) { + renderMappingSources({ + mapped: false, + items: [], + message: error.message || "Mapping load failed.", + }); + } +} + +function renderMappingSources(data) { + const items = data.items || []; + + if (!data.mapped || !items.length) { + mappingState.textContent = data.message || "Mapping missing"; + mappingState.className = "mapping-state is-missing"; + mappingList.replaceChildren(); + mappingDetails.open = true; + return; + } + + const enabledCount = items.filter((item) => item.enabled).length; + const failedCount = items.filter((item) => item.enabled && item.health && item.health !== "ok").length; + const testedCount = items.filter((item) => item.enabled && item.health === "ok").length; + const ready = enabledCount > 0 && failedCount === 0; + mappingState.textContent = ready + ? `Sources OK ✓ (${testedCount}/${enabledCount})` + : data.message || `Sources problem (${testedCount}/${enabledCount})`; + mappingState.className = ready ? "mapping-state is-ready" : "mapping-state is-missing"; + mappingDetails.open = !ready || failedCount > 0; + + mappingList.replaceChildren( + ...items.map((item) => { + const row = document.createElement("div"); + row.className = `mapping-source-row${item.enabled ? "" : " is-disabled"}`; + + const name = document.createElement("span"); + name.className = "mapping-source-name"; + name.textContent = formatValue(item.name || item.key || item.url || "Source"); + + const status = document.createElement("span"); + status.className = `mapping-source-status ${getMappingHealthClass(item)}`; + status.textContent = getMappingHealthText(item); + + row.append(name, status); + return row; + }), + ); +} + +function getMappingHealthText(item) { + if (!item.enabled) return "off"; + if (item.health === "ok") return `${item.httpStatus || 200} OK`; + if (item.health === "missing-url") return "no URL"; + if (item.httpStatus) return `${item.httpStatus}`; + if (item.health) return "error"; + return "netest"; +} + +function getMappingHealthClass(item) { + if (!item.enabled) return "is-muted"; + if (item.health === "ok") return "is-ok"; + return "is-bad"; +} + +async function loadProductForSelectedManufacturer(position = currentPosition) { + const requestId = ++loadRequestId; + const manufacturerId = manufacturerSelect.value; + if (!manufacturerId) { + clearProductState("Select manufacturer to load data."); + return; + } + + setNavigationEnabled(false); + setTableMessage("Loading product data..."); + + try { + const response = await fetch( + `/api/catalog-maker/product?manufacturerId=${encodeURIComponent(manufacturerId)}&position=${encodeURIComponent(position)}`, + ); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Product load failed."); + } + + if (!data.product) { + if (requestId !== loadRequestId) return; + clearProductState(data.message || "No product to do for selected manufacturer."); + return; + } + + if (requestId !== loadRequestId) return; + renderProduct(data.product); + currentCombinations = data.combinations; + renderCombinations(currentCombinations); + } catch (error) { + setTableMessage(error.message); + } finally { + updateNavigationState(); + } +} + +async function loadProductByScannedEan() { + const requestId = ++loadRequestId; + const manufacturerId = manufacturerSelect.value; + const ean = normalizeScannedEan(scanInput.value); + + if (!manufacturerId) { + setTableMessage("Select manufacturer before scanning EAN."); + return; + } + + if (!ean) { + setTableMessage("Scan EAN first."); + return; + } + + setNavigationEnabled(false); + scanInput.disabled = true; + setTableMessage(`Searching EAN ${ean}...`); + + try { + const params = new URLSearchParams({ manufacturerId, ean }); + const response = await fetch(`/api/catalog-maker/product-by-ean?${params.toString()}`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "EAN search failed."); + } + + if (!data.product) { + if (requestId !== loadRequestId) return; + await showSuggestedProducts({ ean }); + return; + } + + if (requestId !== loadRequestId) return; + renderProduct(data.product); + currentCombinations = data.combinations; + renderCombinations(currentCombinations); + scanInput.value = ""; + + if (!data.product.idProduct) { + await showSuggestedProducts({ + ean, + supplierReference: data.product.supplierReference, + productName: data.product.name, + }); + } + } catch (error) { + setTableMessage(error.message); + } finally { + scanInput.disabled = false; + scanInput.focus(); + updateNavigationState(); + } +} + +async function showSuggestedProducts({ ean, supplierReference = "", productName = "" }) { + const params = new URLSearchParams({ + manufacturerId: manufacturerSelect.value, + supplierReference: supplierReference || currentProduct?.supplierReference || scanInput.value || ean, + productName: productName || currentProduct?.name || "", + }); + const response = await fetch(`/api/catalog-maker/suggested-products?${params.toString()}`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Suggested product lookup failed."); + } + + suggestedTitle.textContent = `Sugested product for ${ean}`; + renderSuggestedProducts(data.items); + suggestedModal.hidden = false; +} + +function renderSuggestedProducts(items) { + if (!items.length) { + const empty = document.createElement("div"); + empty.className = "suggested-row"; + empty.textContent = "No suggested products found."; + suggestedList.replaceChildren(empty); + return; + } + + suggestedList.replaceChildren( + ...items.map((item) => { + const row = document.createElement("div"); + row.className = "suggested-row"; + + const image = document.createElement("img"); + image.alt = item.name || "Product"; + if (item.imageUrl) image.src = item.imageUrl; + + const id = document.createElement("div"); + id.className = "suggested-id"; + id.textContent = formatValue(item.idProduct); + + const name = document.createElement("div"); + name.className = "suggested-name"; + name.textContent = formatValue(item.name); + name.title = name.textContent; + + const reference = document.createElement("div"); + reference.className = "suggested-reference"; + reference.textContent = formatValue(item.supplierReference); + reference.title = reference.textContent; + + const active = document.createElement("div"); + active.className = "suggested-active"; + active.textContent = formatValue(item.active); + + const pick = document.createElement("button"); + pick.className = "suggested-pick"; + pick.type = "button"; + pick.textContent = "Use"; + pick.addEventListener("click", () => selectSuggestedProduct(item)); + + row.append(image, id, name, reference, active, pick); + return row; + }), + ); +} + +function selectSuggestedProduct(item) { + if (!currentProduct) return; + + currentProduct = { + ...currentProduct, + idProduct: item.idProduct, + imageUrl: item.imageUrl || currentProduct.imageUrl, + }; + recordIdElement.textContent = formatRecordId(item.idProduct); + renderPicture(currentProduct.imageUrl); + closeSuggestedProducts(); + updateNavigationState(); +} + +function closeSuggestedProducts() { + suggestedModal.hidden = true; + suggestedList.replaceChildren(); +} + +async function findSourcesForCurrentProduct() { + if (!manufacturerSelect.value || !currentProduct) { + setTableMessage("Select manufacturer and product first."); + return; + } + + const savedDraft = readLocalDraft(); + if (savedDraft?.sources) { + renderSourceCandidates(savedDraft.sources.items || []); + updateFindSourcesButtonState(); + sourceTitle.textContent = "Source candidates (cached)"; + sourceModal.hidden = false; + return; + } + + const supplierColorGroup = getFirstSupplierColorGroup(); + findSourcesButton.disabled = true; + findSourcesButton.textContent = "Finding sources..."; + + try { + const params = new URLSearchParams({ + manufacturerId: manufacturerSelect.value, + productName: currentProduct.name || "", + supplierReference: currentProduct.supplierReference || "", + ean: supplierColorGroup?.supplierEan || currentCombinations[0]?.ean13 || "", + eanGroup: (supplierColorGroup?.supplierRows || []).map((row) => row.ean13).filter(Boolean).join(","), + color: supplierColorGroup?.color || "", + }); + const response = await fetch(`/api/catalog-maker/find-sources?${params.toString()}`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Source search failed."); + } + + saveLocalDraft({ sources: data }); + updateFindSourcesButtonState(); + renderSourceCandidates(data.items || []); + const targetLabel = supplierColorGroup + ? `${supplierColorGroup.color} / EAN ${supplierColorGroup.supplierEan}` + : currentProduct.name || currentProduct.supplierReference; + sourceTitle.textContent = `Source candidates for ${targetLabel}`; + sourceModal.hidden = false; + } catch (error) { + setTableMessage(error.message || "Source search failed."); + } finally { + updateFindSourcesButtonState(); + } +} + +async function getPicturesForCurrentProduct(sourceKey = "") { + if (!manufacturerSelect.value || !currentProduct) { + setTableMessage("Select manufacturer and product first."); + return; + } + + const savedDraft = readLocalDraft(); + const cachedPictures = savedDraft?.picturesBySource?.[sourceKey || "all"]; + if (cachedPictures) { + renderPictureCandidates(cachedPictures.items || []); + sourceTitle.textContent = `Picture candidates (cached${sourceKey ? `: ${sourceKey}` : ""})`; + sourceModal.hidden = false; + return; + } + + const supplierColorGroup = getFirstSupplierColorGroup(); + getPicturesButton.disabled = true; + getPicturesButton.textContent = "Loading pictures..."; + + try { + const params = new URLSearchParams({ + manufacturerId: manufacturerSelect.value, + productName: currentProduct.name || "", + supplierReference: currentProduct.supplierReference || "", + ean: supplierColorGroup?.supplierEan || currentCombinations[0]?.ean13 || "", + eanGroup: (supplierColorGroup?.supplierRows || []).map((row) => row.ean13).filter(Boolean).join(","), + color: supplierColorGroup?.color || "", + }); + if (sourceKey) params.set("sourceKey", sourceKey); + const response = await fetch(`/api/catalog-maker/get-pictures?${params.toString()}`); + const data = await response.json(); + if (!response.ok) throw new Error(data.message || "Picture lookup failed."); + + const picturesBySource = readLocalDraft()?.picturesBySource || {}; + picturesBySource[sourceKey || "all"] = data; + saveLocalDraft({ picturesBySource }); + + renderPictureCandidates(data.items || []); + sourceTitle.textContent = `Picture candidates${sourceKey ? `: ${sourceKey}` : ""}`; + sourceModal.hidden = false; + } catch (error) { + setTableMessage(error.message || "Picture lookup failed."); + } finally { + getPicturesButton.disabled = false; + getPicturesButton.textContent = "Get pictures"; + updateNavigationState(); + } +} + +function getFirstSupplierColorGroup() { + const groups = new Map(); + + for (const combination of currentCombinations) { + const color = formatValue(combination.color) || "Unknown color"; + const group = groups.get(color) || { + color, + rows: [], + supplierRows: [], + supplierEan: "", + }; + + group.rows.push(combination); + if (Number(combination.supplierStock) === 1) { + group.supplierRows.push(combination); + if (!group.supplierEan) group.supplierEan = combination.ean13; + } + groups.set(color, group); + } + + return [...groups.values()].find((group) => group.supplierRows.length > 0) || null; +} + +function renderSourceCandidates(items) { + if (!items.length) { + const empty = document.createElement("div"); + empty.className = "source-row"; + empty.textContent = "No source mapping found."; + sourceList.replaceChildren(empty); + return; + } + + sourceList.replaceChildren( + ...items.map((item) => { + const section = document.createElement("section"); + section.className = "source-section"; + + const header = document.createElement("div"); + header.className = "source-section-header"; + + const name = document.createElement("strong"); + name.textContent = formatValue(item.source?.name || item.source?.key || "Source"); + + const status = document.createElement("span"); + status.className = `source-status ${item.status === "found" ? "is-ok" : "is-muted"}`; + status.textContent = item.status === "found" ? `${item.candidates.length} found` : item.status; + + const actions = document.createElement("div"); + actions.className = "source-section-actions"; + const pictures = document.createElement("button"); + pictures.type = "button"; + pictures.className = "source-picture-action"; + pictures.textContent = "Get pictures"; + pictures.addEventListener("click", () => + getPicturesForCurrentProduct(item.source?.key || ""), + ); + actions.append(status, pictures); + + header.append(name, actions); + + const rows = item.candidates.length + ? item.candidates.map(renderSourceCandidateRow) + : [renderManualSearchRow(item.searchUrl, item.error || "Open search")]; + + section.append(header, ...rows); + return section; + }), + ); +} + +function renderPictureCandidates(items) { + const rows = []; + for (const item of items) { + for (const candidate of item.candidates || []) { + for (const picture of candidate.pictures || []) { + const row = document.createElement("a"); + row.className = "picture-candidate"; + row.href = picture; + row.target = "_blank"; + row.rel = "noreferrer"; + + const image = document.createElement("img"); + image.src = picture; + image.alt = candidate.rawTitle || item.source?.name || "Product picture"; + image.loading = "lazy"; + + const label = document.createElement("span"); + label.textContent = item.source?.name || item.source?.key || "Source"; + row.append(image, label); + rows.push(row); + } + } + } + + if (!rows.length) { + const empty = document.createElement("div"); + empty.className = "source-row"; + empty.textContent = "No pictures found. Open the source manually to inspect it."; + sourceList.replaceChildren(empty); + return; + } + sourceList.replaceChildren(...rows); +} + +function collectPictureUrls(items) { + return items.flatMap((item) => + (item.candidates || []).flatMap((candidate) => candidate.pictures || []), + ).filter((url, index, all) => all.indexOf(url) === index); +} + +function saveLocalDraft(patch) { + if (!localDraftProductKey) return; + let draft = {}; + try { + draft = JSON.parse(localStorage.getItem(localDraftKey) || "{}"); + } catch { + draft = {}; + } + localStorage.setItem(localDraftKey, JSON.stringify({ + ...draft, + productKey: localDraftProductKey, + ...patch, + })); +} + +function readLocalDraft() { + if (!localDraftProductKey) return null; + try { + const draft = JSON.parse(localStorage.getItem(localDraftKey) || "null"); + return draft?.productKey === localDraftProductKey ? draft : null; + } catch { + return null; + } +} + +function clearLocalDraft() { + localStorage.removeItem(localDraftKey); + localDraftProductKey = ""; + updateFindSourcesButtonState(); +} + +function updateFindSourcesButtonState() { + const hasSources = Boolean(readLocalDraft()?.sources); + findSourcesButton.textContent = hasSources ? "Find sources ✓" : "Find sources"; + findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct; +} + +function renderSourceCandidateRow(candidate) { + const row = document.createElement("a"); + row.className = "source-row"; + row.href = candidate.url; + row.target = "_blank"; + row.rel = "noreferrer"; + + const title = document.createElement("span"); + title.className = "source-row-title"; + title.textContent = formatValue(candidate.title || candidate.url); + title.title = formatValue(candidate.rawTitle || candidate.title || candidate.url); + + const action = document.createElement("span"); + action.className = "source-row-action"; + action.textContent = "Open"; + + row.append(title, action); + return row; +} + +function renderManualSearchRow(url, label) { + return renderSourceCandidateRow({ + title: label, + url, + }); +} + +function closeSourceCandidates() { + sourceModal.hidden = true; + sourceList.replaceChildren(); +} + +function openSettings() { + settingsModal.hidden = false; +} + +function toggleParserDrawer() { + const isOpen = !parserDrawer.hidden; + parserDrawer.hidden = isOpen; + parserDrawerToggle.setAttribute("aria-expanded", String(!isOpen)); + parserDrawerToggle.querySelector(".drawer-chevron").textContent = isOpen ? "⌄" : "⌃"; +} + +function loadTheme() { + const isDark = localStorage.getItem("catalog-maker:theme") === "dark"; + document.documentElement.classList.toggle("dark", isDark); + themeToggle.querySelector("span").textContent = isDark ? "Light theme" : "Dark theme"; + themeToggle.setAttribute("aria-pressed", String(isDark)); +} + +function toggleTheme() { + const isDark = !document.documentElement.classList.contains("dark"); + localStorage.setItem("catalog-maker:theme", isDark ? "dark" : "light"); + loadTheme(); +} + +function closeSettings() { + settingsModal.hidden = true; +} + +function loadDatabaseMode() { + const mode = localStorage.getItem("catalog-maker:database-mode") || "local"; + const input = document.querySelector(`input[name="databaseMode"][value="${mode}"]`); + if (input) input.checked = true; + const permissions = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}"); + for (const input of permissionInputs) input.checked = permissions[input.id] === true; + const transactionMode = localStorage.getItem("catalog-maker:transaction-mode") || "transaction"; + const transactionInput = document.querySelector(`input[name="transactionMode"][value="${transactionMode}"]`); + if (transactionInput) transactionInput.checked = true; + const localDbSettings = JSON.parse(localStorage.getItem("catalog-maker:local-db-settings") || "{}"); + for (const input of localDbInputs) { + if (Object.prototype.hasOwnProperty.call(localDbSettings, input.id)) input.value = localDbSettings[input.id]; + } + syncDatabasePermissions(mode); + syncLocalDbFields(mode); + renderDatabaseModeNote(mode); + refreshDatabaseStatusLabel(); +} + +function saveDatabaseMode(event) { + const mode = event.target.value === "live" ? "live" : "local"; + localStorage.setItem("catalog-maker:database-mode", mode); + syncDatabasePermissions(mode); + syncLocalDbFields(mode); + saveSettings(); + renderDatabaseModeNote(mode); + refreshDatabaseStatusLabel(); +} + +function saveLocalDbSettings() { + const settings = {}; + for (const input of localDbInputs) settings[input.id] = input.value; + localStorage.setItem("catalog-maker:local-db-settings", JSON.stringify(settings)); +} + +async function testLocalDbConnection() { + saveLocalDbSettings(); + testLocalDbButton.disabled = true; + localDbTestResult.textContent = "Testing connection..."; + localDbTestResult.className = "settings-note"; + const payload = Object.fromEntries([...localDbInputs].map((input) => [ + input.id.replace("localDb", "").toLowerCase(), + input.value, + ])); + + try { + const response = await fetch("/api/database/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok || !data.connected) throw new Error(data.message || "Connection failed."); + localDbTestResult.textContent = `Connected: ${data.version}`; + localDbTestResult.className = "settings-note connection-ok"; + } catch (error) { + localDbTestResult.textContent = `Connection error: ${error.message}`; + localDbTestResult.className = "settings-note connection-error"; + } finally { + testLocalDbButton.disabled = false; + } +} + +function syncLocalDbFields(mode) { + localDbFields.hidden = mode !== "local"; +} + +function syncDatabasePermissions(mode) { + const isLive = mode === "live"; + for (const input of permissionInputs) { + input.disabled = isLive; + if (isLive) input.checked = false; + } +} + +function saveSettings() { + const permissions = {}; + for (const input of permissionInputs) permissions[input.id] = input.checked; + localStorage.setItem("catalog-maker:db-permissions", JSON.stringify(permissions)); + const transactionMode = [...transactionModeInputs].find((input) => input.checked)?.value || "transaction"; + localStorage.setItem("catalog-maker:transaction-mode", transactionMode); + refreshDatabaseStatusLabel(); +} + +function refreshDatabaseStatusLabel() { + const mode = localStorage.getItem("catalog-maker:database-mode") || "local"; + const transactionMode = localStorage.getItem("catalog-maker:transaction-mode") || "transaction"; + const permissions = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}"); + const writesEnabled = mode === "local" && ( + permissions.allowInsertToggle || permissions.allowUpdateToggle || permissions.allowDeleteToggle + ); + const dbLabel = mode === "live" ? "Live DB" : "Local DB"; + const configuredDriver = databaseStatus.dataset.driver; + const connectionError = (mode === "local" && configuredDriver !== "mariadb") + || (mode === "live" && configuredDriver !== "endpoint"); + const connectionLabel = connectionError ? "connection error" : "connected"; + const accessLabel = writesEnabled ? "writes enabled" : "read-only"; + const executionLabel = applicationMode === "dry-run" + ? "dry-run" + : (transactionMode === "dry-run" ? "dry-run" : "transaction"); + databaseStatus.textContent = `${dbLabel} / ${connectionLabel} / ${accessLabel} / ${executionLabel}`; + databaseStatus.classList.toggle("database-error", connectionError); +} + +function renderDatabaseModeNote(mode) { + databaseModeNote.textContent = mode === "live" + ? "Live DB selected for preparation. The current project still stays read-only." + : "Local test database selected."; +} + +function renderProduct(product) { + const productKey = `${manufacturerSelect.value}:${product.idProductCatalog || product.supplierReference || product.name || ""}`; + if (localDraftProductKey && localDraftProductKey !== productKey) clearLocalDraft(); + localDraftProductKey = productKey; + currentProduct = product; + recordIdElement.textContent = formatRecordId(product.idProduct); + currentPosition = Number(product.position) || currentPosition; + totalPositions = Number(product.total) || 0; + const enrichedProduct = { + ...product, + positionTotal: + product.position && product.total ? `${product.position}/${product.total}` : "", + }; + + for (const [label, key] of Object.entries(productFieldMap)) { + const valueElement = document.querySelector(`[data-field="${CSS.escape(label)}"]`); + if (!valueElement) continue; + valueElement.textContent = formatValue(enrichedProduct[key]); + } + renderPicture(product.imageUrl); + updateFindSourcesButtonState(); + updateNavigationState(); +} + +function renderCombinations(combinations) { + if (!combinations.length) { + setTableMessage("No combinations found for selected product."); + return; + } + + tableBody.replaceChildren( + ...combinations.map((item) => { + const row = document.createElement("tr"); + + row.append( + ...variantColumns.map((column) => { + const value = item[column.key]; + const text = formatTableValue(value, column); + const cell = document.createElement("td"); + cell.textContent = text; + cell.title = text; + cell.dataset.column = column.key; + cell.style.width = `${column.width}px`; + cell.style.textAlign = column.align || ""; + if ((column.problem && item.hasProblem) || (column.key === "eanStatus" && text)) { + cell.className = "problem-ean"; + } + return cell; + }), + ); + return row; + }), + ); +} + +function clearProductState(message) { + clearLocalDraft(); + recordIdElement.textContent = ""; + currentProduct = null; + currentCombinations = []; + currentPosition = 1; + totalPositions = 0; + for (const valueElement of document.querySelectorAll(".field-value")) { + valueElement.textContent = ""; + } + renderPicture(); + setTableMessage(message); + updateNavigationState(); +} + +function setTableMessage(message) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + cell.className = "empty-row"; + cell.colSpan = variantColumns.length; + cell.textContent = message; + row.append(cell); + tableBody.replaceChildren(row); +} + +function formatValue(value) { + if (value === null || value === undefined) return ""; + if (typeof value === "number") return String(value); + + const text = String(value).trim(); + if (/^\d{4}-\d{2}-\d{2}T/.test(text)) return formatDate(text); + if (/^-?\d+\.0+$/.test(text)) return String(Number.parseInt(text, 10)); + if (/^-?\d+\.\d+$/.test(text)) return trimDecimal(text); + return text; +} + +function formatTableValue(value, column) { + if (value === null || value === undefined || value === "") return ""; + + if (column.amount) { + const amount = Number(value); + if (Number.isFinite(amount)) return String(Math.round(amount)); + } + + return formatValue(value); +} + +function formatRecordId(value) { + const text = formatValue(value); + return text || ""; +} + +function formatDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("cs-CZ", { + day: "2-digit", + month: "2-digit", + year: "numeric", + timeZone: "UTC", + }) + .format(date) + .replaceAll(" ", ""); +} + +function trimDecimal(value) { + const asNumber = Number(value); + if (!Number.isFinite(asNumber)) return value; + return String(asNumber); +} + +function setNavigationEnabled(enabled) { + previousProductButton.disabled = !enabled; + nextProductButton.disabled = !enabled; +} + +function updateNavigationState() { + previousProductButton.disabled = !manufacturerSelect.value || currentPosition <= 1; + nextProductButton.disabled = + !manufacturerSelect.value || (totalPositions > 0 && currentPosition >= totalPositions); + loadProductButton.disabled = !currentProduct?.idProduct; + findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct; + getPicturesButton.disabled = !manufacturerSelect.value || !currentProduct; +} + +function normalizeScannedEan(value) { + const czechKeyboardDigits = new Map([ + ["+", "1"], + ["ě", "2"], + ["š", "3"], + ["č", "4"], + ["ř", "5"], + ["ž", "6"], + ["ý", "7"], + ["á", "8"], + ["í", "9"], + ["é", "0"], + ]); + + return String(value ?? "") + .trim() + .toLowerCase() + .split("") + .map((char) => czechKeyboardDigits.get(char) || char) + .join("") + .replace(/\D/g, ""); +} + +function renderPicture(imageUrl) { + if (!imageUrl) { + pictureSlot.replaceChildren(Object.assign(document.createElement("span"), { textContent: "No picture" })); + return; + } + + const image = document.createElement("img"); + image.src = imageUrl; + image.alt = currentProduct?.name || "Product preview"; + image.loading = "lazy"; + pictureSlot.replaceChildren(image); +} + +async function loadProductInfoForCurrentProduct() { + if (!currentProduct?.idProduct || !manufacturerSelect.value || !currentProduct.supplierReference) { + return; + } + + loadProductButton.disabled = true; + loadProductButton.textContent = "Loading info..."; + + try { + const params = new URLSearchParams({ + productId: currentProduct.idProduct, + manufacturerId: manufacturerSelect.value, + supplierReference: currentProduct.supplierReference, + }); + const response = await fetch(`/api/catalog-maker/product-info?${params.toString()}`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Product info load failed."); + } + + const infoByCatalogId = new Map( + data.items.map((item) => [String(item.idProductCatalog), item]), + ); + currentCombinations = currentCombinations.map((combination) => ({ + ...combination, + ...(infoByCatalogId.get(String(combination.id)) || {}), + })); + renderCombinations(currentCombinations); + } catch (error) { + setTableMessage(error.message); + } finally { + loadProductButton.textContent = "Get product info - 00"; + updateNavigationState(); + } +} + +export {}; \ No newline at end of file diff --git a/scripts/read-sql-sheet.mjs b/scripts/read-sql-sheet.ts similarity index 99% rename from scripts/read-sql-sheet.mjs rename to scripts/read-sql-sheet.ts index 3293eaa..7ef7734 100644 --- a/scripts/read-sql-sheet.mjs +++ b/scripts/read-sql-sheet.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import fs from "node:fs"; import { createRequire } from "node:module"; diff --git a/src/cli.js b/src/cli.ts similarity index 69% rename from src/cli.js rename to src/cli.ts index 146eef3..54abe86 100644 --- a/src/cli.js +++ b/src/cli.ts @@ -1,8 +1,9 @@ #!/usr/bin/env node -import { runCatalogMaker } from "./workflows/catalog-maker.js"; -import { loadConfig } from "./config.js"; -import { parseArgs } from "./lib/args.js"; -import { fail } from "./lib/log.js"; +// @ts-nocheck +import { runCatalogMaker } from "./workflows/catalog-maker.ts"; +import { loadConfig } from "./config.ts"; +import { parseArgs } from "./lib/args.ts"; +import { fail } from "./lib/log.ts"; const args = parseArgs(process.argv.slice(2)); const command = args._[0] ?? "catalog-maker"; diff --git a/src/config.js b/src/config.ts similarity index 99% rename from src/config.js rename to src/config.ts index 9d6c552..80a48d5 100644 --- a/src/config.js +++ b/src/config.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import fs from "node:fs"; import path from "node:path"; diff --git a/src/db/mariadb-client.js b/src/db/mariadb-client.ts similarity index 96% rename from src/db/mariadb-client.js rename to src/db/mariadb-client.ts index 7b87548..05da384 100644 --- a/src/db/mariadb-client.js +++ b/src/db/mariadb-client.ts @@ -1,5 +1,6 @@ +// @ts-nocheck import mariadb from "mariadb"; -import { assertReadOnlySql } from "../lib/sql-readonly.js"; +import { assertReadOnlySql } from "../lib/sql-readonly.ts"; let pool; diff --git a/src/endpoint/endpoint-client.js b/src/endpoint/endpoint-client.ts similarity index 97% rename from src/endpoint/endpoint-client.js rename to src/endpoint/endpoint-client.ts index 3431433..aa7c3e8 100644 --- a/src/endpoint/endpoint-client.js +++ b/src/endpoint/endpoint-client.ts @@ -1,6 +1,7 @@ -import { assertReadOnlySql } from "../lib/sql-readonly.js"; -import { assertAllowedWriteSql } from "../lib/sql-write-allowlist.js"; -import { executeMariaDbWrite, isMariaDbConfigured, queryMariaDb } from "../db/mariadb-client.js"; +// @ts-nocheck +import { assertReadOnlySql } from "../lib/sql-readonly.ts"; +import { assertAllowedWriteSql } from "../lib/sql-write-allowlist.ts"; +import { executeMariaDbWrite, isMariaDbConfigured, queryMariaDb } from "../db/mariadb-client.ts"; export function isEndpointConfigured(config) { return Boolean( diff --git a/src/lib/args.js b/src/lib/args.ts similarity index 98% rename from src/lib/args.js rename to src/lib/args.ts index 8a3a780..bbffbe3 100644 --- a/src/lib/args.js +++ b/src/lib/args.ts @@ -1,3 +1,4 @@ +// @ts-nocheck export function parseArgs(argv) { const args = { _: [] }; diff --git a/src/lib/log.js b/src/lib/log.ts similarity index 94% rename from src/lib/log.js rename to src/lib/log.ts index fc6f21a..ec51ab8 100644 --- a/src/lib/log.js +++ b/src/lib/log.ts @@ -1,3 +1,4 @@ +// @ts-nocheck export function info(message) { console.log(message); } diff --git a/src/lib/sql-readonly.js b/src/lib/sql-readonly.ts similarity index 97% rename from src/lib/sql-readonly.js rename to src/lib/sql-readonly.ts index 31c1b8f..f00a822 100644 --- a/src/lib/sql-readonly.js +++ b/src/lib/sql-readonly.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const writeStatementPattern = /^(?:alter|analyze|call|create|delete|drop|grant|insert|load|lock|optimize|replace|revoke|set|truncate|update)\b/i; diff --git a/src/lib/sql-write-allowlist.js b/src/lib/sql-write-allowlist.ts similarity index 98% rename from src/lib/sql-write-allowlist.js rename to src/lib/sql-write-allowlist.ts index 5fd9006..76e92ac 100644 --- a/src/lib/sql-write-allowlist.js +++ b/src/lib/sql-write-allowlist.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const allowedOperation = "catalog-maker-refresh-manufacturer"; export function assertAllowedWriteSql(sql, { operation } = {}) { diff --git a/src/server.js b/src/server.ts similarity index 95% rename from src/server.js rename to src/server.ts index c956c7c..ab6ad81 100644 --- a/src/server.js +++ b/src/server.ts @@ -1,11 +1,12 @@ +// @ts-nocheck import http from "node:http"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { loadConfig } from "./config.js"; -import { testMariaDbConnection } from "./db/mariadb-client.js"; -import { listManufacturers } from "./services/manufacturers.js"; -import { listLanguages } from "./services/languages.js"; +import { loadConfig } from "./config.ts"; +import { testMariaDbConnection } from "./db/mariadb-client.ts"; +import { listManufacturers } from "./services/manufacturers.ts"; +import { listLanguages } from "./services/languages.ts"; import { loadManufacturerCatalogState, findProductSources, @@ -14,7 +15,7 @@ import { loadProductByEan, loadProductInfo, getProductPictures, -} from "./services/catalog-products.js"; +} from "./services/catalog-products.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const publicDir = path.resolve(__dirname, "../public"); @@ -23,7 +24,7 @@ const config = loadConfig(); const mimeTypes = new Map([ [".html", "text/html; charset=utf-8"], [".css", "text/css; charset=utf-8"], - [".js", "text/javascript; charset=utf-8"], + [".ts", "text/javascript; charset=utf-8"], [".json", "application/json; charset=utf-8"], [".svg", "image/svg+xml"], ]); diff --git a/src/services/browser-adapter.ts b/src/services/browser-adapter.ts new file mode 100644 index 0000000..7f60924 --- /dev/null +++ b/src/services/browser-adapter.ts @@ -0,0 +1,53 @@ +// @ts-nocheck +import { chromium } from "playwright"; + +const visibleBrowsers = new Set(); + +export async function openInBrowser(url) { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 }); + visibleBrowsers.add(browser); + return { browser, page }; +} + +export async function searchInBrowser({ url, headless = true, waitAfterLoadMs = 3000 }) { + const browser = await chromium.launch({ headless }); + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + + try { + const response = await page.goto(url, { + waitUntil: "domcontentloaded", + timeout: 30_000, + }); + await page.waitForTimeout(Math.max(250, Number(waitAfterLoadMs) || 3000)); + + const [currentUrl, title, pageSource] = await Promise.all([ + page.url(), + page.title(), + page.content(), + ]); + const cleanText = await page.locator("body").innerText().catch(() => ""); + const failed = /something has gone wrong|reference id|access denied/i.test(cleanText); + + return { + ok: Boolean(response?.ok()) && !failed, + status: response?.status() || 0, + url: currentUrl, + title, + text: cleanText.slice(0, 1200), + pageSource, + }; + } finally { + await browser.close(); + } +} + +export async function scrapePage(url, options = {}) { + return searchInBrowser({ url, headless: true, waitAfterLoadMs: 750, ...options }); +} + +export async function closeVisibleBrowsers() { + await Promise.all([...visibleBrowsers].map((browser) => browser.close().catch(() => {}))); + visibleBrowsers.clear(); +} diff --git a/src/services/catalog-products.js b/src/services/catalog-products.ts similarity index 94% rename from src/services/catalog-products.js rename to src/services/catalog-products.ts index 1b7d7de..62b5ad6 100644 --- a/src/services/catalog-products.js +++ b/src/services/catalog-products.ts @@ -1,5 +1,6 @@ -import { executeEndpointWrite, queryEndpoint } from "../endpoint/endpoint-client.js"; -import { openInFirefox, searchInFirefox } from "./firefox-adapter.js"; +// @ts-nocheck +import { executeEndpointWrite, queryEndpoint } from "../endpoint/endpoint-client.ts"; +import { openInBrowser, scrapePage, searchInBrowser } from "./browser-adapter.ts"; import { prepareManufacturerCatalogSqls, mappingSourcesSql, @@ -8,7 +9,7 @@ import { productCombinationsByReferenceSql, productToDoByManufacturerSql, suggestedProductsSql, -} from "../sql/catalog-maker.js"; +} from "../sql/catalog-maker.ts"; export async function loadManufacturerCatalogState(config, { manufacturerId, position = 1 }) { const refreshResults = []; @@ -181,9 +182,9 @@ export async function findProductSources( const candidate = item.candidates?.[0]; if (candidate?.url) { try { - openInFirefox(candidate.url); + await openInBrowser(candidate.url); } catch { - // Picture extraction can continue even if visible Firefox cannot start. + // Picture extraction can continue even if the visible browser cannot start. } } } @@ -249,13 +250,13 @@ async function findSourceCandidates(source, product, { includePictures = false } query: "", searchUrl: lastSearchUrl, status: "error", - error: "EAN is required for Idealo Firefox search.", + error: "EAN is required for Idealo Playwright search.", candidates: [], }; } try { - const result = await searchInFirefox({ + const result = await searchInBrowser({ url: attempt.url, headless: source.browserHeadless, waitAfterLoadMs: source.waitAfterLoadMs, @@ -283,14 +284,14 @@ async function findSourceCandidates(source, product, { includePictures = false } query: attempt.query, searchUrl: result.url || attempt.url, status: "error", - error: "Idealo returned an error page in Firefox.", + error: "Idealo returned an error page in Playwright.", candidates: buildManualSearchCandidates(searchAttempts, product), }; } catch (error) { try { - openInFirefox(attempt.url); + await openInBrowser(attempt.url); } catch { - // The manual candidate below remains available even if Firefox cannot start. + // The manual candidate below remains available even if the browser cannot start. } return { @@ -298,7 +299,7 @@ async function findSourceCandidates(source, product, { includePictures = false } query: attempt.query, searchUrl: attempt.url, status: "manual-search", - error: `Firefox automation unavailable: ${error.message || "search failed"}`, + error: `Playwright automation unavailable: ${error.message || "search failed"}`, candidates: buildManualSearchCandidates(searchAttempts, product), }; } @@ -307,19 +308,12 @@ async function findSourceCandidates(source, product, { includePictures = false } for (const attempt of searchAttempts) { lastSearchUrl = attempt.url; try { - const response = await fetch(attempt.url, { - method: attempt.method || "GET", - redirect: "follow", - headers: { - "User-Agent": "CatalogMaker/0.1", - ...(attempt.body ? { "Content-Type": "application/x-www-form-urlencoded" } : {}), - }, - body: attempt.body, - signal: AbortSignal.timeout(9000), + const response = await scrapePage(attempt.url, { + waitAfterLoadMs: source.waitAfterLoadMs, }); - const html = await response.text(); + const html = response.pageSource; const candidates = [ - ...extractResponseUrlCandidate(response.url || attempt.url, html, product), + ...extractResponseUrlCandidate(response.url || attempt.url, html, product), ...extractDirectCandidates(html, source, product), ].slice(0, 3); @@ -604,12 +598,8 @@ async function verifyHudyVariant(url, product) { if (visited.has(page.url)) continue; visited.add(page.url); - const response = await fetch(page.url, { - headers: { "User-Agent": "CatalogMaker/0.1" }, - redirect: "follow", - signal: AbortSignal.timeout(9000), - }); - const html = await response.text(); + const response = await scrapePage(page.url, { waitAfterLoadMs: 750 }); + const html = response.pageSource; lastStatus = response.status; const pageEans = extractPageEans(html); const matchedEan = pageEans.find((value) => wantedEans.has(value)) || ""; @@ -712,12 +702,8 @@ function extractPictureUrls(html, pageUrl) { async function extractPicturesFromPage(url) { try { - const response = await fetch(url, { - headers: { "User-Agent": "CatalogMaker/0.1" }, - redirect: "follow", - signal: AbortSignal.timeout(9000), - }); - return extractHudyGalleryPictureUrls(await response.text(), response.url || url); + const response = await scrapePage(url, { waitAfterLoadMs: 750 }); + return extractHudyGalleryPictureUrls(response.pageSource, response.url || url); } catch { return []; } @@ -878,22 +864,7 @@ async function checkMappingSourceUrls(items) { async function checkUrl(url) { try { const target = normalizeSourceUrl(url); - let response = await fetch(target, { - method: "HEAD", - redirect: "follow", - signal: AbortSignal.timeout(6000), - }); - - if (!response.ok) { - response = await fetch(target, { - method: "GET", - redirect: "follow", - headers: { - "User-Agent": "CatalogMaker/0.1", - }, - signal: AbortSignal.timeout(6000), - }); - } + const response = await scrapePage(target, { waitAfterLoadMs: 250 }); return { health: response.ok ? "ok" : "warning", diff --git a/src/services/firefox-adapter.js b/src/services/firefox-adapter.js deleted file mode 100644 index e4da58d..0000000 --- a/src/services/firefox-adapter.js +++ /dev/null @@ -1,82 +0,0 @@ -import { spawn } from "node:child_process"; -import { Builder } from "selenium-webdriver"; -import firefox from "selenium-webdriver/firefox.js"; -import { start } from "geckodriver"; - -const FIREFOX_PATHS = [ - "C:\\Program Files\\Mozilla Firefox\\firefox.exe", - "C:\\Program Files (x86)\\Mozilla Firefox\\firefox.exe", -]; - -export function openInFirefox(url) { - const firefoxPath = FIREFOX_PATHS.find((candidate) => candidate); - if (!firefoxPath) throw new Error("Firefox executable was not found."); - - const process = spawn(firefoxPath, [url], { - detached: true, - stdio: "ignore", - windowsHide: true, - }); - process.unref(); -} - -export async function searchInFirefox({ url, headless = false, waitAfterLoadMs = 3000 }) { - const firefoxPath = FIREFOX_PATHS.find((candidate) => candidate); - const port = 4444; - const geckodriver = await start({ port, binary: firefoxPath, log: "error" }); - let driver; - - try { - await waitForWebDriver(port); - const options = new firefox.Options(); - if (firefoxPath) options.setBinary(firefoxPath); - if (headless) options.addArguments("-headless"); - - driver = await new Builder() - .forBrowser("firefox") - .setFirefoxOptions(options) - .usingServer(`http://127.0.0.1:${port}`) - .build(); - - await driver.get(url); - await driver.sleep(Math.max(1000, Number(waitAfterLoadMs) || 3000)); - - const [currentUrl, title, pageSource] = await Promise.all([ - driver.getCurrentUrl(), - driver.getTitle(), - driver.getPageSource(), - ]); - const cleanText = stripHtml(pageSource).slice(0, 1200); - const failed = /something has gone wrong|reference id/i.test(cleanText); - - return { ok: !failed, url: currentUrl, title, text: cleanText, pageSource }; - } finally { - if (driver) await driver.quit().catch(() => {}); - geckodriver.kill(); - } -} - -async function waitForWebDriver(port) { - const deadline = Date.now() + 10000; - while (Date.now() < deadline) { - try { - const response = await fetch(`http://127.0.0.1:${port}/status`); - if (response.ok) return; - } catch { - // Geckodriver needs a short moment to open its local port. - } - await new Promise((resolve) => setTimeout(resolve, 150)); - } - throw new Error("Firefox WebDriver did not start."); -} - -function stripHtml(value) { - return String(value ?? "") - .replace(//gi, " ") - .replace(//gi, " ") - .replace(/<[^>]+>/g, " ") - .replace(/ /gi, " ") - .replace(/&/gi, "&") - .replace(/\s+/g, " ") - .trim(); -} diff --git a/src/services/languages.js b/src/services/languages.ts similarity index 66% rename from src/services/languages.js rename to src/services/languages.ts index 3481f0a..e8a9ece 100644 --- a/src/services/languages.js +++ b/src/services/languages.ts @@ -1,5 +1,6 @@ -import { queryEndpoint } from "../endpoint/endpoint-client.js"; -import { catalogMakerSql } from "../sql/catalog-maker.js"; +// @ts-nocheck +import { queryEndpoint } from "../endpoint/endpoint-client.ts"; +import { catalogMakerSql } from "../sql/catalog-maker.ts"; export async function listLanguages(config) { const result = await queryEndpoint(config, catalogMakerSql.languages); diff --git a/src/services/manufacturers.js b/src/services/manufacturers.ts similarity index 65% rename from src/services/manufacturers.js rename to src/services/manufacturers.ts index f6f2a93..5292091 100644 --- a/src/services/manufacturers.js +++ b/src/services/manufacturers.ts @@ -1,5 +1,6 @@ -import { queryEndpoint } from "../endpoint/endpoint-client.js"; -import { catalogMakerSql } from "../sql/catalog-maker.js"; +// @ts-nocheck +import { queryEndpoint } from "../endpoint/endpoint-client.ts"; +import { catalogMakerSql } from "../sql/catalog-maker.ts"; export async function listManufacturers(config) { const result = await queryEndpoint(config, catalogMakerSql.manufacturers); diff --git a/src/sql/catalog-maker.js b/src/sql/catalog-maker.ts similarity index 99% rename from src/sql/catalog-maker.js rename to src/sql/catalog-maker.ts index c7310b5..95bcc6f 100644 --- a/src/sql/catalog-maker.js +++ b/src/sql/catalog-maker.ts @@ -1,3 +1,4 @@ +// @ts-nocheck export const catalogMakerSql = { manufacturers: ` SELECT id_manufacturer, name diff --git a/src/workflows/catalog-maker.js b/src/workflows/catalog-maker.ts similarity index 96% rename from src/workflows/catalog-maker.js rename to src/workflows/catalog-maker.ts index ea1c3b9..cee7f9f 100644 --- a/src/workflows/catalog-maker.js +++ b/src/workflows/catalog-maker.ts @@ -1,5 +1,6 @@ +// @ts-nocheck import fs from "node:fs"; -import { info, warn } from "../lib/log.js"; +import { info, warn } from "../lib/log.ts"; const steps = [ { diff --git a/test/config.test.js b/test/config.test.ts similarity index 86% rename from test/config.test.js rename to test/config.test.ts index 6c7af0d..49e02e7 100644 --- a/test/config.test.js +++ b/test/config.test.ts @@ -1,6 +1,7 @@ +// @ts-nocheck import test from "node:test"; import assert from "node:assert/strict"; -import { loadConfig } from "../src/config.js"; +import { loadConfig } from "../src/config.ts"; test("defaults to dry-run mode", () => { const config = loadConfig({ configPath: "missing.json" }); diff --git a/test/sql-catalog-maker.test.js b/test/sql-catalog-maker.test.ts similarity index 94% rename from test/sql-catalog-maker.test.js rename to test/sql-catalog-maker.test.ts index e667a8a..5d39c53 100644 --- a/test/sql-catalog-maker.test.js +++ b/test/sql-catalog-maker.test.ts @@ -1,7 +1,8 @@ +// @ts-nocheck import test from "node:test"; import assert from "node:assert/strict"; -import { assertReadOnlySql } from "../src/lib/sql-readonly.js"; -import { assertAllowedWriteSql } from "../src/lib/sql-write-allowlist.js"; +import { assertReadOnlySql } from "../src/lib/sql-readonly.ts"; +import { assertAllowedWriteSql } from "../src/lib/sql-write-allowlist.ts"; import { catalogMakerSql, mappingSourceTablesSql, @@ -11,7 +12,7 @@ import { productCombinationInfoSql, productCombinationsByReferenceSql, productToDoByManufacturerSql, -} from "../src/sql/catalog-maker.js"; +} from "../src/sql/catalog-maker.ts"; test("manufacturer query is read-only", () => { assert.doesNotThrow(() => assertReadOnlySql(catalogMakerSql.manufacturers)); diff --git a/test/sql-readonly.test.js b/test/sql-readonly.test.ts similarity index 88% rename from test/sql-readonly.test.js rename to test/sql-readonly.test.ts index b2d7512..b99a96b 100644 --- a/test/sql-readonly.test.js +++ b/test/sql-readonly.test.ts @@ -1,6 +1,7 @@ +// @ts-nocheck import test from "node:test"; import assert from "node:assert/strict"; -import { assertReadOnlySql } from "../src/lib/sql-readonly.js"; +import { assertReadOnlySql } from "../src/lib/sql-readonly.ts"; test("allows select statements", () => { assert.doesNotThrow(() => assertReadOnlySql("SELECT * FROM ps_product LIMIT 1")); diff --git a/tests/ui/catalog-maker.smoke.spec.js b/tests/ui/catalog-maker.smoke.spec.ts similarity index 99% rename from tests/ui/catalog-maker.smoke.spec.js rename to tests/ui/catalog-maker.smoke.spec.ts index 65e0d29..a7447a7 100644 --- a/tests/ui/catalog-maker.smoke.spec.js +++ b/tests/ui/catalog-maker.smoke.spec.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { test, expect } from "playwright/test"; test("catalog maker shell loads without page errors", async ({ page }) => { diff --git a/tests/ui/database.e2e.spec.js b/tests/ui/database.e2e.spec.ts similarity index 98% rename from tests/ui/database.e2e.spec.js rename to tests/ui/database.e2e.spec.ts index 8aa6321..c656f63 100644 --- a/tests/ui/database.e2e.spec.js +++ b/tests/ui/database.e2e.spec.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import { test, expect } from "playwright/test"; test.describe("database connection", () => { diff --git a/tsconfig.browser.json b/tsconfig.browser.json new file mode 100644 index 0000000..64f33ff --- /dev/null +++ b/tsconfig.browser.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "noEmit": false, + "strict": false, + "skipLibCheck": true + }, + "include": ["public/app.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d2dabde --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": false, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts"] +}