commit 2e7d83b679cda0a48e75098429d858d0345b66fe Author: rajch_ales Date: Wed Aug 5 12:25:23 2026 +0200 Initial catalog maker baseline diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90dd227 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +node_modules/ +.env +.env.* +!.env.example +config/local.json +outputs/ +logs/ +tmp/ +*.log +*.sql +*.sql.gz + +# Keep source Excel files local unless we explicitly decide otherwise. +*.xlsm +*.xlsx +*.xls diff --git a/README.md b/README.md new file mode 100644 index 0000000..f85971c --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Catalog Maker by Magic AI + +Node.js replacement for the Excel macro workflow behind the `Catalog maker - 20` button. + +## Current Goal + +Replace the Excel click workflow with a controlled Node.js pipeline: + +1. Read local configuration from `config/local.json`. +2. Load or identify the source workbook. +3. Run catalog-maker steps in a safe order. +4. Default to dry-run mode. +5. Read from the database only. Database writes are forbidden. + +## First Run + +```powershell +copy config/local.example.json config/local.json +npm run catalog:dry-run +``` + +## Safety Rules + +- Local secrets stay in `config/local.json`. +- `config/local.json` and Excel files are ignored by git. +- The app starts in dry-run mode. +- Rule #1: the database is read-only for this project. +- SQL guards must reject write statements before execution. + +## Excel Entry Point Found + +- Sheet: `endpoint` +- Button caption: `Catalog maker - 20` +- Internal control name: `CommandButton20` +- Approximate location: `A21:C22` +- ActiveX relation: `xl/activeX/activeX72.xml` + +The exact VBA click handler still needs to be mapped before we implement real catalog generation logic. diff --git a/agent.md b/agent.md new file mode 100644 index 0000000..31cbaa1 --- /dev/null +++ b/agent.md @@ -0,0 +1,116 @@ +# Catalog Maker Project Notes + +## Purpose + +This project replaces the Excel/VBA workflow named `Catalog maker - 20` with a Node.js local web application. The UI is a compact product catalog editor for loading manufacturer products, combinations, source mappings and parsed supplier data. + +## Non-negotiable rules + +- Live DB is never written to. Live DB access is read-only through the existing endpoint. +- Local DB may later allow controlled writes, but writes must remain explicit, logged and protected by transaction/dry-run settings. +- Never print, commit or share endpoint tokens, usernames or passwords. +- Never use Live DB as a fallback when Local DB is selected. If Local DB is unavailable, show an error and keep the UI running. +- Keep the development server on port `3404` unless the user explicitly requests another port. +- Do not delete or revert user files or unrelated changes. + +## Git workflow + +- After every completed implementation step, run the relevant checks, create a focused commit and push it to the configured remote repository. +- Use short, descriptive commit messages that state the completed change. +- Never commit `.env`, `config/local.json`, passwords, tokens, the Excel workbook or database dumps. +- Before the first push, initialize Git if needed, configure the project remote and create an initial baseline commit from safe files only. +- If a push fails, do not expose credentials in the terminal output or in chat; report the failure without printing secrets. + +## Runtime + +- Start the web app with `npm run dev`. +- Open `http://127.0.0.1:3404/`. +- The current server is `src/server.js`. +- 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. + +## Database architecture + +There are two selectable data sources: + +### Live DB + +- Uses the existing 9b-plus API endpoint configured in `config/local.json`. +- Credentials are secret and must stay server-side. +- Must remain read-only. + +### Local DB + +- MariaDB is installed locally as service `MariaDB`. +- Host: `localhost` / `127.0.0.1`. +- Port: `3306`. +- Database: `9bplus`. +- A full dump from `9bplus (1).sql.gz` was imported locally. +- Verified local contents: about 266 tables, 74 manufacturers, 8,888 products and 48,206 product combinations. +- The import was local only and did not contact Live DB. +- The project currently needs to be switched to `database.driver = "mariadb"` and supplied with local credentials before the UI can read this database. + +## Database safety behavior + +- The UI has `Local DB` and `Live DB` settings. +- The UI sends the selected mode using the `X-Database-Mode` request header. +- Backend data API routes reject a Local DB request when MariaDB is not configured instead of silently using the endpoint. +- Backend data API routes reject a Live DB request when the project is configured only for MariaDB. +- Static HTML/CSS/JS files must remain available even when a database is not configured. +- The top status shows the selected source, connection state, permissions and execution mode. + +## 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. +- `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/styles.css`: compact modern layout. +- `config/local.example.json`: safe configuration template. +- `config/local.json`: local secrets and machine-specific configuration; never expose it. + +## Current UI behavior + +- Manufacturer and language selects load from the selected database. +- Product data loads when manufacturer changes or navigation arrows are used. +- `Get product info` remains a separate action. +- EAN scan input searches on Enter. +- `Find sources` searches once and caches candidates for the current product; a second click reopens cached results. +- Source candidates show how they were found, including EAN/reference/name and color context. +- Each source row has its own `Get pictures` action. +- Product source cache is cleared when a new product loads. +- Hudy picture parsing uses the correct product color variant, checks EANs and extracts large gallery images. +- Idealo uses the browser adapter and is intended mainly as a picture source. +- Image preview is square. +- Settings is at the bottom of the left panel; database status is directly above it. + +## Settings + +The settings modal contains: + +- Local DB / Live DB selection. +- Local DB connection fields: host, port, database, user and password. +- `Test connection`, which performs a read-only `SELECT VERSION()` against local MariaDB only. +- Local insert/update/delete permission switches. +- Transaction confirmation or dry-run preview mode. +- Live DB permissions are always read-only. + +Local UI settings are stored in browser localStorage. They are not yet a replacement for server-side `config/local.json` credentials. + +## Next safe step + +Wire the local connection settings into server configuration without exposing Live credentials, then set the active local database driver to MariaDB and test manufacturer loading from the imported `9bplus` database. After that, verify that switching to Live uses only read-only endpoint queries. + +## Verification commands + +```powershell +node --check src/server.js +node --check src/db/mariadb-client.js +node --check public/app.js +npm test +``` + +For a local MariaDB check, use the installed client against `127.0.0.1:3306` and never the Live endpoint. diff --git a/config/local.example.json b/config/local.example.json new file mode 100644 index 0000000..63647af --- /dev/null +++ b/config/local.example.json @@ -0,0 +1,27 @@ +{ + "appMode": "dry-run", + "sourceWorkbook": "./xxx_endpoint_bo_2026-08-04_v1.xlsm", + "outputDir": "./outputs", + "server": { + "host": "127.0.0.1", + "port": 3400 + }, + "endpoint": { + "url": "https://www.9b-plus.com/apiv1/endpoint/query", + "loginUrl": "https://www.9b-plus.com/apiv1/auth/login", + "token": "", + "username": "", + "password": "" + }, + "database": { + "driver": "endpoint", + "mode": "local", + "allowWrites": false, + "host": "127.0.0.1", + "port": 3306, + "name": "catalog_maker_test", + "user": "catalog_maker", + "password": "", + "connectionLimit": 5 + } +} diff --git a/docs/excel-map.md b/docs/excel-map.md new file mode 100644 index 0000000..cb5bdd1 --- /dev/null +++ b/docs/excel-map.md @@ -0,0 +1,78 @@ +# Excel Map + +Source workbook: + +`xxx_endpoint_bo_2026-08-04_v1.xlsm` + +## Sheets + +| Sheet | Role hypothesis | +| --- | --- | +| `dashboard` | Cron/task overview and links | +| `endpoint` | Main working screen, includes `Catalog maker - 20` | +| `pictures` | Product image layout/status | +| `picmagic` | Product variants or image-combination helper | +| `sql` | Stored SQL statements and descriptions | +| `cronjob` | Scheduled task metadata | +| `config` | Local configuration and sensitive values | + +## Catalog Maker Button + +The entry point is an ActiveX button: + +- Caption: `Catalog maker - 20` +- Internal name: `CommandButton20` +- Sheet: `endpoint` +- Approximate anchor: `A21:C22` +- Relationship target: `../activeX/activeX72.xml` + +## Migration Notes + +- The workbook contains VBA macros. +- It has an external link to an older local `.xlsm`. +- Many defined names look like product attributes but currently resolve to `#REF!`. +- The `sql` sheet appears to hold most data-access behavior. +- We should migrate one behavior at a time and keep all write operations disabled until verified. + +## Manufacturer SQL + +Excel rows found on the `sql` sheet and confirmed from the VBA strings: + +- Row 133: `catalog maker / ComboBox2000 / load all manufacturer` +- Row 134: `catalog maker / ComboBox2000 / get id of selected manufacture` + +Original Excel flow: + +1. Load manufacturer names into `ComboBox2000`. +2. After selecting a manufacturer, resolve `id_manufacturer`. +3. Store/use the selected id for the next catalog-maker queries. + +The web endpoint follows the same connector flow, but improves the select by loading ids with names: + +```sql +SELECT id_manufacturer, name +FROM ps_manufacturer +ORDER BY name ASC +``` + +After a user selects a manufacturer, the next step resolves its id with row 134: + +```sql +SELECT id_manufacturer +FROM ps_manufacturer +WHERE name = ? +``` + +## Languages SQL + +Excel row found on the `sql` sheet: + +- Row 173: `catalog maker / get available lang for translations` + +The web endpoint loads language ids, names, and ISO codes: + +```sql +SELECT id_lang, name, iso_code +FROM ps_lang +ORDER BY name ASC +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..18ee201 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,473 @@ +{ + "name": "catalog-maker-by-magic-ai", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "catalog-maker-by-magic-ai", + "version": "0.1.0", + "dependencies": { + "geckodriver": "^6.1.1", + "mariadb": "^3.5.3", + "selenium-webdriver": "^4.46.0" + }, + "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/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "license": "MIT", + "dependencies": { + "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" + }, + "engines": { + "node": ">=18.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", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.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", + "engines": { + "node": ">= 14" + } + }, + "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", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "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", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "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/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", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "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/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/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/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", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mariadb": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.3.tgz", + "integrity": "sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==", + "license": "LGPL-2.1-or-later", + "dependencies": { + "@types/geojson": "^7946.0.16", + "@types/node": ">=20", + "denque": "^2.1.0", + "iconv-lite": "^0.7.2", + "lru-cache": "^11.5.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "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/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/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/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/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/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/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 new file mode 100644 index 0000000..a0cab58 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "catalog-maker-by-magic-ai", + "version": "0.1.0", + "private": true, + "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" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "geckodriver": "^6.1.1", + "mariadb": "^3.5.3", + "selenium-webdriver": "^4.46.0" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..abf37a4 --- /dev/null +++ b/public/app.js @@ -0,0 +1,1151 @@ +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 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 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(); +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); + +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); +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 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(); + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..e1fbbe7 --- /dev/null +++ b/public/index.html @@ -0,0 +1,190 @@ + + + + + + Catalog Maker + + + + +
+ + +
+
+
+ +
+
+ + + + + + + + + +
Select manufacturer to load data.
+
+
+
+ + + + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..01c524b --- /dev/null +++ b/public/styles.css @@ -0,0 +1,852 @@ +:root { + color-scheme: light; + --surface: #f4f6f8; + --panel: #22272e; + --panel-soft: #2d333b; + --panel-line: #3e4651; + --detail-label: #e2e6ea; + --detail-value: #eefdf9; + --detail-border: #c8d3d9; + --table-head: #d9dee4; + --table-row: #f5f6f7; + --table-row-alt: #eceff2; + --table-line: #cfd6dd; + --text: #15181c; + --muted: #68717d; + --accent: #7ee34d; + --accent-text: #10220b; + --safe: #0f7b4d; + --danger: #e02424; + --focus: #2f6fed; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, + sans-serif; + background: var(--surface); +} + +button, +input, +select { + font: inherit; +} + +.catalog-screen { + min-height: 100vh; + display: grid; + grid-template-columns: 172px 292px minmax(720px, 1fr); +} + +.control-panel { + min-height: 100vh; + max-height: 100vh; + padding: 6px; + background: var(--panel); + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; +} + +.control-panel > #settingsButton { + order: 99; +} + +.app-status { + order: 98; + margin-top: auto; + display: flex; + flex-wrap: wrap; + gap: 3px; + padding: 5px; + border: 1px solid var(--panel-line); + border-radius: 6px; + background: var(--panel-soft); + color: #f3f6f8; + font-size: 11px; + line-height: 1.2; +} + +.record-id { + height: 39px; + display: grid; + place-items: center; + border: 1px solid rgba(126, 227, 77, 0.42); + border-radius: 6px; + background: linear-gradient(180deg, #9aff60, var(--accent)); + color: var(--accent-text); + font-size: 26px; + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.arrow-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px; +} + +.arrow-button, +.action-button, +.input-like { + width: 100%; + border: 1px solid var(--panel-line); + border-radius: 5px; + background: #f8fafc; + color: #111827; +} + +.arrow-button, +.action-button { + cursor: pointer; +} + +.arrow-button { + height: 31px; + position: relative; + display: grid; + place-items: center; +} + +.arrow-button::before { + content: ""; + width: 0; + height: 0; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; +} + +.arrow-button-prev::before { + border-right: 11px solid #111827; +} + +.arrow-button-next::before { + border-left: 11px solid #111827; +} + +.arrow-button:disabled::before { + opacity: 0.55; +} + +.parser-actions { + display: grid; + gap: 4px; +} + +.parser-actions .action-button { + min-height: 28px; + font-size: 11px; +} + +.action-button { + min-height: 32px; + padding: 0 8px; + font-size: 12px; + display: grid; + place-items: center; + text-align: center; +} + +.action-button.primary { + min-height: 34px; + font-size: 14px; + font-weight: 700; +} + +.arrow-button:hover:not(:disabled), +.action-button:hover:not(:disabled) { + border-color: #7a8492; + background: #ffffff; +} + +.arrow-button:disabled, +.action-button:disabled { + cursor: default; + opacity: 0.45; +} + +.input-like { + height: 28px; + padding: 3px 7px; + font-size: 12px; + outline: none; +} + +select.input-like { + text-align: center; + text-align-last: center; +} + +#scanInput { + background: #e5e8ec; + text-align: center; +} + +.input-like:focus { + border-color: var(--focus); + box-shadow: 0 0 0 2px rgba(47, 111, 237, 0.18); +} + +.input-like::placeholder { + color: #8b95a1; +} + +.picture-slot { + aspect-ratio: 1 / 1; + width: 100%; + min-height: 0; + display: grid; + place-items: center; + border: 1px dashed #6d7784; + border-radius: 5px; + background: var(--panel-soft); + color: #b8c0ca; + font-size: 12px; + overflow: hidden; +} + +.picture-slot img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} + +.mapping-box { + display: grid; + gap: 3px; + font-size: 11px; +} + +.mapping-state { + height: 28px; + display: flex; + align-items: center; + min-width: 0; + padding: 3px 7px; + border: 1px solid var(--panel-line); + border-right: 0; + border-radius: 5px 0 0 5px; + background: #f8fafc; + color: #111827; + font-weight: 400; + line-height: 1.15; +} + +.mapping-state.is-ready { + background: #f8fafc; + color: #111827; +} + +.mapping-state.is-missing { + background: #fff0f0; + color: #b42318; + font-weight: 700; +} + +.mapping-list { + display: grid; + gap: 3px; +} + +.mapping-details { + min-width: 0; +} + +.mapping-details summary { + display: grid; + grid-template-columns: minmax(0, 1fr) 22px; + align-items: center; + cursor: pointer; + list-style: none; +} + +.mapping-details summary::-webkit-details-marker { + display: none; +} + +.mapping-details summary:hover { + filter: brightness(1.04); +} + +.mapping-toggle { + width: 22px; + height: 28px; + display: grid; + place-items: center; + border: 1px solid var(--panel-line); + border-left: 0; + border-radius: 0 5px 5px 0; + background: #f8fafc; +} + +.mapping-toggle::before { + content: ">"; + color: #111827; + font-size: 11px; + font-weight: 900; + line-height: 1; +} + +.mapping-details[open] .mapping-toggle::before { + transform: rotate(90deg); +} + +.mapping-source-row { + min-width: 0; + max-width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 4px; + padding: 3px 4px; + border: 1px solid #46505c; + border-radius: 5px; + background: #1b2026; + color: #f3f6f8; +} + +.mapping-source-row.is-disabled { + opacity: 0.55; +} + +.mapping-source-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 800; +} + +.mapping-source-status { + flex: 0 0 auto; + padding: 1px 4px; + border-radius: 4px; + font-size: 9px; + font-weight: 400; +} + +.mapping-source-status.is-ok { + background: #e8edf3; + color: #303944; +} + +.mapping-source-status.is-bad { + background: #ffe0dc; + color: #b42318; + font-weight: 700; +} + +.mapping-source-status.is-muted { + background: #4b5563; + color: #e5e7eb; +} + +.product-panel { + min-width: 0; + padding: 22px 0 8px; + background: #d3d8de; + border-right: 1px solid #bdc6cf; +} + +.field-grid { + display: grid; + grid-template-columns: 132px minmax(0, 168px); +} + +.field-label, +.field-value { + min-height: 22px; + padding: 4px 8px; + border-bottom: 1px solid var(--detail-border); + font-size: 12px; + line-height: 1.15; +} + +.field-label { + color: #26313d; + background: var(--detail-label); +} + +.field-value { + background: var(--detail-value); + text-align: right; + overflow-wrap: anywhere; + font-variant-numeric: tabular-nums; +} + +.field-value[data-field="manufacturer_name"] { + color: #b42318; + font-weight: 700; +} + +.data-panel { + min-width: 0; + background: var(--surface); +} + +.safe-mode, +#modePill { + padding: 2px 6px; + border-radius: 5px; + background: #e8f7ef; + color: var(--safe); + font-weight: 700; +} + +#databaseStatus.database-error { + background: #fde8e7; + color: #b42318; +} + +#modePill { + background: #edf1f5; + color: #4d5966; + font-weight: 600; +} + +.table-wrap { + height: 100vh; + overflow: auto; +} + +.variant-table { + width: 100%; + min-width: 820px; + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; + font-size: 12px; + background: var(--table-row); +} + +.variant-table th, +.variant-table td { + height: 22px; + border-right: 1px solid var(--table-line); + border-bottom: 1px solid var(--table-line); + padding: 2px 7px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} + +.variant-table th { + position: sticky; + top: 0; + z-index: 2; + background: var(--table-head); + color: #303944; + font-weight: 700; + text-align: center; +} + +.variant-table td { + background: var(--table-row); +} + +.variant-table tr:nth-child(even) td { + background: var(--table-row-alt); +} + +.variant-table tr:hover td { + background: #fff7df; +} + +.empty-row { + color: var(--muted); + text-align: center; + font-style: italic; +} + +.variant-table .problem-ean { + background: var(--danger) !important; + color: #ffffff; + font-weight: 700; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + padding: 24px; + background: rgba(17, 24, 39, 0.38); +} + +.modal-backdrop[hidden] { + display: none; +} + +.suggested-dialog { + width: min(900px, 94vw); + max-height: min(620px, 88vh); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + border: 1px solid #9aa5b1; + border-radius: 8px; + overflow: hidden; + background: #f8fafc; + box-shadow: 0 18px 60px rgba(15, 23, 42, 0.34); +} + +.source-dialog { + width: min(820px, 94vw); + max-height: min(620px, 88vh); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + border: 1px solid #9aa5b1; + border-radius: 8px; + overflow: hidden; + background: #f8fafc; + box-shadow: 0 18px 60px rgba(15, 23, 42, 0.34); +} + +.settings-dialog { + width: min(520px, 94vw); + max-height: 92vh; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + border: 1px solid #9aa5b1; + border-radius: 8px; + overflow: hidden; + background: #f8fafc; + box-shadow: 0 18px 60px rgba(15, 23, 42, 0.34); +} + +.settings-content { + display: grid; + gap: 8px; + padding: 10px; + overflow: visible; +} + +.settings-group { + display: grid; + gap: 6px; + margin: 0; + padding: 8px; + border: 1px solid var(--table-line); + border-radius: 6px; + background: #ffffff; +} + +.settings-group legend { + padding: 0 5px; + font-size: 12px; + font-weight: 700; +} + +.settings-option { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.local-db-fields { + display: grid; + gap: 6px; + padding: 8px; + border: 1px solid var(--table-line); + border-radius: 5px; + background: #f7f9fb; +} + +.local-db-fields[hidden] { + display: none; +} + +.settings-field { + display: grid; + grid-template-columns: 82px minmax(0, 1fr); + align-items: center; + gap: 8px; + font-size: 11px; +} + +.settings-field input { + min-width: 0; + padding: 4px 6px; + border: 1px solid #cbd3dc; + border-radius: 4px; + font: inherit; +} + +.settings-test-button { + width: 100%; + padding: 5px 8px; + border: 1px solid #b8c3ce; + border-radius: 4px; + background: #eef2f5; + color: #26323d; + cursor: pointer; +} + +.settings-test-button:disabled { + opacity: 0.6; + cursor: wait; +} + +.connection-ok { + color: #18794e; +} + +.connection-error { + color: #b42318; + font-weight: 600; +} + +.settings-option input { + margin: 0; +} + +.settings-option input:disabled + span { + color: var(--muted); +} + +.settings-note, +.settings-permissions { + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.35; +} + +.settings-permissions { + display: grid; + gap: 3px; + padding-top: 3px; +} + +.suggested-header { + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-bottom: 1px solid var(--table-line); + background: #e8edf3; +} + +.suggested-header h2 { + margin: 0; + font-size: 14px; +} + +.modal-close { + border: 1px solid #b6c0ca; + border-radius: 5px; + padding: 5px 10px; + background: #ffffff; + cursor: pointer; + font-size: 12px; +} + +.suggested-list { + overflow: auto; +} + +.suggested-row { + display: grid; + grid-template-columns: 58px 92px 1fr 160px 64px 84px; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-bottom: 1px solid var(--table-line); + font-size: 12px; +} + +.suggested-row:nth-child(even) { + background: #eef2f6; +} + +.suggested-row img { + width: 46px; + height: 46px; + object-fit: contain; + border: 1px solid #d3dae2; + background: #ffffff; +} + +.suggested-id, +.suggested-active { + text-align: center; + font-variant-numeric: tabular-nums; +} + +.suggested-name, +.suggested-reference { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.suggested-pick { + border: 1px solid #2f6fed; + border-radius: 5px; + padding: 5px 8px; + background: #2f6fed; + color: #ffffff; + cursor: pointer; + font-size: 12px; +} + +.source-list { + display: grid; + gap: 8px; + padding: 10px; + overflow: auto; +} + +.source-section { + display: grid; + gap: 4px; + border: 1px solid var(--table-line); + border-radius: 6px; + overflow: hidden; + background: #ffffff; +} + +.source-section-header, +.source-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 32px; + padding: 6px 8px; + font-size: 12px; +} + +.source-section-header { + background: #e8edf3; +} + +.source-section-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.source-picture-action { + border: 1px solid #9aa8b7; + border-radius: 4px; + padding: 3px 6px; + background: #ffffff; + color: #172033; + cursor: pointer; + font-size: 10px; +} + +.source-picture-action:hover { + background: #e9f2ff; + border-color: #2f6fed; +} + +.source-row { + color: #172033; + text-decoration: none; + border-top: 1px solid var(--table-line); +} + +.source-row:hover { + background: #fff7df; +} + +.source-row-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-status, +.source-row-action { + padding: 2px 6px; + border-radius: 5px; + font-size: 10px; + font-weight: 800; +} + +.picture-candidate { + display: grid; + grid-template-rows: 132px auto; + gap: 6px; + color: inherit; + text-decoration: none; + font-size: 11px; + min-width: 132px; +} + +.picture-candidate img { + width: 132px; + height: 132px; + object-fit: contain; + background: #fff; + border: 1px solid #d5dce5; +} + +.picture-candidate span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-status.is-ok { + background: #dff7e7; + color: #0f7b4d; +} + +.source-status.is-muted, +.source-row-action { + background: #edf1f5; + color: #4d5966; +} + +@media (max-width: 980px) { + .catalog-screen { + grid-template-columns: 1fr; + } + + .control-panel { + min-height: auto; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .record-id, + .arrow-row, + .parser-actions, + .app-status, + .mapping-box, + .picture-slot { + grid-column: span 2; + } + + .product-panel { + padding: 0; + } + + .field-grid { + grid-template-columns: 160px minmax(0, 1fr); + } + + .table-wrap { + height: auto; + max-height: 70vh; + } +} diff --git a/scripts/read-sql-sheet.mjs b/scripts/read-sql-sheet.mjs new file mode 100644 index 0000000..3293eaa --- /dev/null +++ b/scripts/read-sql-sheet.mjs @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const JSZip = require("jszip"); + +const workbookPath = process.argv[2] || "xxx_endpoint_bo_2026-08-04_v1.xlsm"; +const fromRow = Number.parseInt(process.argv[3] || "1", 10); +const toRow = Number.parseInt(process.argv[4] || String(fromRow), 10); + +const zip = await JSZip.loadAsync(fs.readFileSync(workbookPath)); +const sharedStringsXml = await zip.file("xl/sharedStrings.xml").async("string"); +const sharedStrings = [...sharedStringsXml.matchAll(//g)].map((match) => + decodeXml([...match[0].matchAll(/]*>([\s\S]*?)<\/t>/g)].map((part) => part[1]).join("")), +); + +const sqlSheetXml = await zip.file("xl/worksheets/sheet5.xml").async("string"); + +for (const rowMatch of sqlSheetXml.matchAll(/]* r="(\d+)"[\s\S]*?<\/row>/g)) { + const rowNumber = Number(rowMatch[1]); + if (rowNumber < fromRow || rowNumber > toRow) continue; + + const cells = {}; + for (const cellMatch of rowMatch[0].matchAll( + /]* r="([A-Z]+)\d+"([^>]*)>([\s\S]*?)<\/c>/g, + )) { + const [, column, attributes, body] = cellMatch; + const inlineString = body.match(/[\s\S]*?]*>([\s\S]*?)<\/t>[\s\S]*?<\/is>/); + const rawValue = body.match(/([\s\S]*?)<\/v>/)?.[1] ?? inlineString?.[1] ?? ""; + cells[column] = attributes.includes('t="s"') + ? sharedStrings[Number(rawValue)] || "" + : decodeXml(rawValue); + } + + console.log(JSON.stringify({ row: rowNumber, cells }, null, 2)); +} + +function decodeXml(value) { + return String(value ?? "") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&") + .replaceAll(""", '"') + .replaceAll("'", "'"); +} diff --git a/scripts/vbaProject.bin b/scripts/vbaProject.bin new file mode 100644 index 0000000..5497cc2 Binary files /dev/null and b/scripts/vbaProject.bin differ diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..146eef3 --- /dev/null +++ b/src/cli.js @@ -0,0 +1,24 @@ +#!/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"; + +const args = parseArgs(process.argv.slice(2)); +const command = args._[0] ?? "catalog-maker"; + +try { + const config = loadConfig({ + configPath: args.config ?? "config/local.json", + dryRun: args["dry-run"] ?? undefined, + }); + + if (command === "catalog-maker") { + await runCatalogMaker({ config }); + } else { + throw new Error(`Unknown command: ${command}`); + } +} catch (error) { + fail(error); + process.exitCode = 1; +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..79b4a7d --- /dev/null +++ b/src/config.js @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function loadConfig({ configPath = "config/local.json", dryRun } = {}) { + const localConfig = readJsonFile(configPath); + const exampleConfig = readJsonFile("config/local.example.json"); + const config = mergeConfig(exampleConfig, localConfig); + + const appMode = String(config.appMode || "dry-run").toLowerCase(); + const resolvedDryRun = dryRun === undefined ? appMode !== "live" : Boolean(dryRun); + + return { + appMode, + dryRun: resolvedDryRun, + sourceWorkbook: path.resolve(config.sourceWorkbook || "./xxx_endpoint_bo_2026-08-04_v1.xlsm"), + outputDir: path.resolve(config.outputDir || "./outputs"), + server: { + host: config.server?.host || "127.0.0.1", + port: Number(config.server?.port || 3400), + }, + endpoint: { + url: config.endpoint?.url || "https://www.9b-plus.com/apiv1/endpoint/query", + loginUrl: config.endpoint?.loginUrl || "https://www.9b-plus.com/apiv1/auth/login", + token: config.endpoint?.token || "", + username: config.endpoint?.username || "", + password: config.endpoint?.password || "", + }, + database: { + driver: String(config.database?.driver || "endpoint").toLowerCase(), + mode: String(config.database?.mode || "local").toLowerCase(), + allowWrites: Boolean(config.database?.allowWrites), + host: config.database?.host || "127.0.0.1", + port: Number(config.database?.port || 3306), + name: config.database?.name || "catalog_maker_test", + user: config.database?.user || "catalog_maker", + password: config.database?.password || "", + connectionLimit: Number(config.database?.connectionLimit || 5), + }, + }; +} + +function readJsonFile(filePath) { + if (!fs.existsSync(filePath)) { + return {}; + } + + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function mergeConfig(base, override) { + return { + ...base, + ...override, + server: { + ...base.server, + ...override.server, + }, + endpoint: { + ...base.endpoint, + ...override.endpoint, + }, + database: { + ...base.database, + ...override.database, + }, + }; +} diff --git a/src/db/mariadb-client.js b/src/db/mariadb-client.js new file mode 100644 index 0000000..bfc08af --- /dev/null +++ b/src/db/mariadb-client.js @@ -0,0 +1,77 @@ +import mariadb from "mariadb"; +import { assertReadOnlySql } from "../lib/sql-readonly.js"; + +let pool; + +export function isMariaDbConfigured(config) { + return config.database?.driver === "mariadb"; +} + +export async function testMariaDbConnection(database) { + const connection = await mariadb.createConnection({ + host: database.host, + port: Number(database.port), + database: database.name, + user: database.user, + password: database.password, + bigIntAsNumber: true, + }); + try { + const rows = await connection.query("SELECT VERSION() AS version"); + return { connected: true, version: rows[0]?.version || "MariaDB" }; + } finally { + await connection.end(); + } +} + +export async function queryMariaDb(config, sql) { + assertReadOnlySql(sql); + const connection = await getPool(config).getConnection(); + try { + const rows = await connection.query(sql); + return { configured: true, items: normalizeRows(rows) }; + } finally { + connection.release(); + } +} + +export async function executeMariaDbWrite(config, sql) { + if (config.database.mode !== "local") { + throw new Error("MariaDB writes are blocked outside Local DB mode."); + } + if (!config.database.allowWrites) { + throw new Error("MariaDB writes are disabled. Enable local database writes in settings first."); + } + + const connection = await getPool(config).getConnection(); + try { + const result = await connection.query(sql); + return { + configured: true, + items: [], + affectedRows: result.affectedRows ?? 0, + insertId: result.insertId ?? 0, + }; + } finally { + connection.release(); + } +} + +async function getPool(config) { + if (!pool) { + pool = mariadb.createPool({ + host: config.database.host, + port: config.database.port, + database: config.database.name, + user: config.database.user, + password: config.database.password, + connectionLimit: config.database.connectionLimit, + bigIntAsNumber: true, + }); + } + return pool; +} + +function normalizeRows(rows) { + return Array.isArray(rows) ? rows.filter((row) => row && typeof row === "object" && !("meta" in row)) : []; +} diff --git a/src/endpoint/endpoint-client.js b/src/endpoint/endpoint-client.js new file mode 100644 index 0000000..3431433 --- /dev/null +++ b/src/endpoint/endpoint-client.js @@ -0,0 +1,152 @@ +import { assertReadOnlySql } from "../lib/sql-readonly.js"; +import { assertAllowedWriteSql } from "../lib/sql-write-allowlist.js"; +import { executeMariaDbWrite, isMariaDbConfigured, queryMariaDb } from "../db/mariadb-client.js"; + +export function isEndpointConfigured(config) { + return Boolean( + config.endpoint.url && + (config.endpoint.token || (config.endpoint.loginUrl && config.endpoint.username && config.endpoint.password)), + ); +} + +export async function queryEndpoint(config, sql) { + assertReadOnlySql(sql); + + if (isMariaDbConfigured(config)) { + return queryMariaDb(config, sql); + } + + if (!isEndpointConfigured(config)) { + return { + configured: false, + items: [], + message: "Endpoint token is not configured in config/local.json.", + }; + } + + let token = config.endpoint.token; + let response = await sendQuery(config, sql, token); + + if (response.status === 401 && config.endpoint.username && config.endpoint.password) { + token = await loginEndpoint(config); + response = await sendQuery(config, sql, token); + } + + const text = await response.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { raw: text }; + } + + if (!response.ok) { + const message = data?.message || data?.error || response.statusText; + throw new Error(`Endpoint request failed (${response.status}): ${message}`); + } + + return normalizeEndpointResponse(data); +} + +export async function executeEndpointWrite(config, sql, { operation } = {}) { + if (isMariaDbConfigured(config)) { + return executeMariaDbWrite(config, sql); + } + + assertAllowedWriteSql(sql, { operation }); + + if (!isEndpointConfigured(config)) { + return { + configured: false, + items: [], + message: "Endpoint token is not configured in config/local.json.", + }; + } + + let token = config.endpoint.token; + let response = await sendQuery(config, sql, token); + + if (response.status === 401 && config.endpoint.username && config.endpoint.password) { + token = await loginEndpoint(config); + response = await sendQuery(config, sql, token); + } + + const text = await response.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { raw: text }; + } + + if (!response.ok) { + const message = data?.message || data?.error || response.statusText; + throw new Error(`Endpoint write failed (${response.status}): ${message}`); + } + + return normalizeEndpointResponse(data); +} + +async function sendQuery(config, sql, token) { + const form = new FormData(); + form.append("query", sql); + + return fetch(config.endpoint.url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + body: form, + }); +} + +async function loginEndpoint(config) { + const form = new FormData(); + form.append("username", config.endpoint.username); + form.append("password", config.endpoint.password); + + const response = await fetch(config.endpoint.loginUrl, { + method: "POST", + body: form, + }); + const text = await response.text(); + let data; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { raw: text }; + } + + if (!response.ok) { + const message = data?.message || data?.error || response.statusText; + throw new Error(`Endpoint login failed (${response.status}): ${message}`); + } + + const token = data?.token || data?.access_token || data?.data?.token || data?.data?.access_token; + if (!token) { + throw new Error("Endpoint login did not return a token."); + } + + return token; +} + +function normalizeEndpointResponse(data) { + if (Array.isArray(data)) { + return { configured: true, items: data }; + } + + if (Array.isArray(data.items)) { + return { configured: true, items: data.items, meta: data }; + } + + if (Array.isArray(data.data)) { + return { configured: true, items: data.data, meta: data }; + } + + if (Array.isArray(data.rows)) { + return { configured: true, items: data.rows, meta: data }; + } + + return { configured: true, items: [], meta: data }; +} diff --git a/src/lib/args.js b/src/lib/args.js new file mode 100644 index 0000000..8a3a780 --- /dev/null +++ b/src/lib/args.js @@ -0,0 +1,36 @@ +export function parseArgs(argv) { + const args = { _: [] }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (!arg.startsWith("--")) { + args._.push(arg); + continue; + } + + const [rawKey, inlineValue] = arg.slice(2).split("=", 2); + const key = rawKey.trim(); + + if (inlineValue !== undefined) { + args[key] = coerceValue(inlineValue); + continue; + } + + const next = argv[index + 1]; + if (next && !next.startsWith("--")) { + args[key] = coerceValue(next); + index += 1; + } else { + args[key] = true; + } + } + + return args; +} + +function coerceValue(value) { + if (value === "true") return true; + if (value === "false") return false; + return value; +} diff --git a/src/lib/log.js b/src/lib/log.js new file mode 100644 index 0000000..fc6f21a --- /dev/null +++ b/src/lib/log.js @@ -0,0 +1,12 @@ +export function info(message) { + console.log(message); +} + +export function warn(message) { + console.warn(message); +} + +export function fail(error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); +} diff --git a/src/lib/sql-readonly.js b/src/lib/sql-readonly.js new file mode 100644 index 0000000..31c1b8f --- /dev/null +++ b/src/lib/sql-readonly.js @@ -0,0 +1,21 @@ +const writeStatementPattern = + /^(?:alter|analyze|call|create|delete|drop|grant|insert|load|lock|optimize|replace|revoke|set|truncate|update)\b/i; + +export function assertReadOnlySql(sql) { + const normalized = stripLeadingSqlNoise(sql); + + if (!normalized) { + throw new Error("SQL query is empty."); + } + + if (writeStatementPattern.test(normalized)) { + throw new Error(`Blocked non-read-only SQL statement: ${normalized.split(/\s+/, 1)[0].toUpperCase()}`); + } +} + +function stripLeadingSqlNoise(sql) { + return String(sql) + .replace(/^\s+/g, "") + .replace(/^(?:--[^\n]*\n|\/\*[\s\S]*?\*\/\s*)+/g, "") + .trim(); +} diff --git a/src/lib/sql-write-allowlist.js b/src/lib/sql-write-allowlist.js new file mode 100644 index 0000000..5fd9006 --- /dev/null +++ b/src/lib/sql-write-allowlist.js @@ -0,0 +1,27 @@ +const allowedOperation = "catalog-maker-refresh-manufacturer"; + +export function assertAllowedWriteSql(sql, { operation } = {}) { + if (operation !== allowedOperation) { + throw new Error(`Write SQL blocked. Unsupported operation: ${operation || "none"}`); + } + + const compact = compactSql(sql); + const allowed = [ + /^updateps_product_catalogpcsetpc\.skladovka=0,pc\.catalog_9b=0wherepc\.id_manufacturer=\d+$/, + /^updateps_product_catalogpcjoinps_import_suppliers_dumpisdonisd\.ean=pc\.ean_13setpc\.skladovka=1wherepc\.id_manufacturer=\d+$/, + /^updateps_product_catalogpcsetpc\.catalog_9b=1wherepc\.id_manufacturer=\d+andpc\.ean_13in\(selectpa\.ean13fromps_product_attributepajoinps_productponp\.id_product=pa\.id_productwherep\.id_manufacturer=\d+\)$/, + ]; + + if (!allowed.some((pattern) => pattern.test(compact))) { + throw new Error("Write SQL blocked. Statement is not on the catalog-maker allowlist."); + } +} + +function compactSql(sql) { + return String(sql) + .replace(/`/g, "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase() + .replace(/\s/g, ""); +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..c956c7c --- /dev/null +++ b/src/server.js @@ -0,0 +1,242 @@ +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 { + loadManufacturerCatalogState, + findProductSources, + listMappingSources, + listSuggestedProducts, + loadProductByEan, + loadProductInfo, + getProductPictures, +} from "./services/catalog-products.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const publicDir = path.resolve(__dirname, "../public"); +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"], + [".json", "application/json; charset=utf-8"], + [".svg", "image/svg+xml"], +]); + +const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, `http://${request.headers.host}`); + + if (url.pathname === "/api/status") { + await sendJson(response, getStatus()); + return; + } + + if (url.pathname === "/api/database/test" && request.method === "POST") { + const body = await readJsonBody(request); + const database = { + driver: "mariadb", + host: body.host || "127.0.0.1", + port: Number(body.port || 3306), + name: body.name || "", + user: body.user || "", + password: body.password || "", + }; + const result = await testMariaDbConnection(database); + await sendJson(response, result); + return; + } + + const requestConfig = url.pathname.startsWith("/api/") + ? getRequestConfig(request) + : null; + + if (url.pathname === "/api/manufacturers") { + await sendJson(response, await listManufacturers(requestConfig)); + return; + } + + if (url.pathname === "/api/languages") { + await sendJson(response, await listLanguages(requestConfig)); + return; + } + + if (url.pathname === "/api/catalog-maker/product") { + await sendJson( + response, + await loadManufacturerCatalogState(requestConfig, { + manufacturerId: url.searchParams.get("manufacturerId"), + position: url.searchParams.get("position") || 1, + }), + ); + return; + } + + if (url.pathname === "/api/catalog-maker/mapping-sources") { + await sendJson( + response, + await listMappingSources(requestConfig, { + manufacturerId: url.searchParams.get("manufacturerId"), + checkUrls: url.searchParams.get("check") === "1", + }), + ); + return; + } + + if (url.pathname === "/api/catalog-maker/find-sources") { + await sendJson( + response, + await findProductSources(requestConfig, { + manufacturerId: url.searchParams.get("manufacturerId"), + sourceKey: url.searchParams.get("sourceKey"), + productName: url.searchParams.get("productName"), + supplierReference: url.searchParams.get("supplierReference"), + ean: url.searchParams.get("ean"), + eanGroup: url.searchParams.get("eanGroup"), + color: url.searchParams.get("color"), + }), + ); + return; + } + + if (url.pathname === "/api/catalog-maker/get-pictures") { + await sendJson( + response, + await getProductPictures(requestConfig, { + manufacturerId: url.searchParams.get("manufacturerId"), + sourceKey: url.searchParams.get("sourceKey"), + productName: url.searchParams.get("productName"), + supplierReference: url.searchParams.get("supplierReference"), + ean: url.searchParams.get("ean"), + eanGroup: url.searchParams.get("eanGroup"), + color: url.searchParams.get("color"), + }), + ); + return; + } + + if (url.pathname === "/api/catalog-maker/product-info") { + await sendJson( + response, + await loadProductInfo(requestConfig, { + productId: url.searchParams.get("productId"), + manufacturerId: url.searchParams.get("manufacturerId"), + supplierReference: url.searchParams.get("supplierReference"), + }), + ); + return; + } + + if (url.pathname === "/api/catalog-maker/product-by-ean") { + await sendJson( + response, + await loadProductByEan(requestConfig, { + manufacturerId: url.searchParams.get("manufacturerId"), + ean: url.searchParams.get("ean"), + }), + ); + return; + } + + if (url.pathname === "/api/catalog-maker/suggested-products") { + await sendJson( + response, + await listSuggestedProducts(requestConfig, { + manufacturerId: url.searchParams.get("manufacturerId"), + supplierReference: url.searchParams.get("supplierReference"), + productName: url.searchParams.get("productName"), + }), + ); + return; + } + + const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname; + const safePath = path.normalize(requestedPath).replace(/^(\.\.[/\\])+/, ""); + const filePath = path.join(publicDir, safePath); + + if (!filePath.startsWith(publicDir)) { + response.writeHead(403); + response.end("Forbidden"); + return; + } + + const content = await fs.readFile(filePath); + response.writeHead(200, { + "content-type": mimeTypes.get(path.extname(filePath)) || "application/octet-stream", + }); + response.end(content); + } catch (error) { + if (error.code === "ENOENT") { + response.writeHead(404); + response.end("Not found"); + return; + } + + await sendJson(response, { + message: error.message || "Internal server error", + }, 500); + } +}); + +server.listen(config.server.port, config.server.host, () => { + console.log(`Catalog Maker preview: http://${config.server.host}:${config.server.port}`); +}); + +function getStatus() { + return { + mode: config.dryRun ? "dry-run" : "live", + databaseAccess: "read-only", + database: { + driver: config.database.driver, + mode: config.database.mode, + allowWrites: config.database.driver === "mariadb" && config.database.mode === "local" && config.database.allowWrites, + }, + workbook: path.basename(config.sourceWorkbook), + workbookExists: true, + steps: [ + ["load-context", "Ready", "Workbook and local config"], + ["load-manufacturer", "Mapping", "Manufacturer and product selection"], + ["check-stock", "Mapping", "Supplier stock and existing catalog state"], + ["build-combinations", "Mapping", "Product combinations"], + ["update-prices", "Mapping", "Price calculation preview"], + ["update-texts", "Mapping", "Product card text generation"], + ["handle-pictures", "Mapping", "Cover and detail pictures"], + ], + }; +} + +function getRequestConfig(request) { + const selectedMode = String(request.headers["x-database-mode"] || config.database.mode).toLowerCase(); + + if (selectedMode === "local" && config.database.driver !== "mariadb") { + throw new Error("Local DB is selected, but MariaDB is not configured. Live DB was not used."); + } + + if (selectedMode === "live" && config.database.driver === "mariadb") { + throw new Error("Live DB is selected, but this project is configured for local MariaDB."); + } + + return { + ...config, + database: { + ...config.database, + mode: selectedMode, + }, + }; +} + +async function sendJson(response, data, statusCode = 200) { + response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(data)); +} + +async function readJsonBody(request) { + let body = ""; + for await (const chunk of request) body += chunk; + return body ? JSON.parse(body) : {}; +} diff --git a/src/services/catalog-products.js b/src/services/catalog-products.js new file mode 100644 index 0000000..1b7d7de --- /dev/null +++ b/src/services/catalog-products.js @@ -0,0 +1,1044 @@ +import { executeEndpointWrite, queryEndpoint } from "../endpoint/endpoint-client.js"; +import { openInFirefox, searchInFirefox } from "./firefox-adapter.js"; +import { + prepareManufacturerCatalogSqls, + mappingSourcesSql, + productByEanSql, + productCombinationInfoSql, + productCombinationsByReferenceSql, + productToDoByManufacturerSql, + suggestedProductsSql, +} from "../sql/catalog-maker.js"; + +export async function loadManufacturerCatalogState(config, { manufacturerId, position = 1 }) { + const refreshResults = []; + for (const sql of prepareManufacturerCatalogSqls({ manufacturerId })) { + refreshResults.push( + await executeEndpointWrite(config, sql, { + operation: "catalog-maker-refresh-manufacturer", + }), + ); + } + + const productResult = await queryEndpoint( + config, + productToDoByManufacturerSql({ manufacturerId, position }), + ); + const product = productResult.items[0] ?? null; + + if (!product) { + return { + configured: productResult.configured, + product: null, + combinations: [], + message: "No product to do for selected manufacturer.", + }; + } + + const combinationsResult = await queryEndpoint( + config, + productCombinationsByReferenceSql({ + manufacturerId, + supplierReference: product.supplier_reference, + }), + ); + + return { + configured: true, + refresh: { + operation: "catalog-maker-refresh-manufacturer", + statements: refreshResults.length, + }, + product: normalizeProduct(product), + combinations: combinationsResult.items.map(normalizeCombination), + }; +} + +export async function loadProductInfo(config, { productId, manufacturerId, supplierReference }) { + if (!productId) { + return { + configured: true, + items: [], + message: "Product info needs id_product first.", + }; + } + + const result = await queryEndpoint( + config, + productCombinationInfoSql({ productId, manufacturerId, supplierReference }), + ); + + return { + configured: result.configured, + items: result.items.map(normalizeProductInfo), + }; +} + +export async function loadProductByEan(config, { manufacturerId, ean }) { + const productResult = await queryEndpoint(config, productByEanSql({ manufacturerId, ean })); + const product = productResult.items[0] ?? null; + + if (!product) { + return { + configured: productResult.configured, + product: null, + combinations: [], + message: "EAN not found in product catalog for selected manufacturer.", + }; + } + + const combinationsResult = await queryEndpoint( + config, + productCombinationsByReferenceSql({ + manufacturerId, + supplierReference: product.supplier_reference, + }), + ); + + return { + configured: true, + product: normalizeProduct(product), + combinations: combinationsResult.items.map(normalizeCombination), + }; +} + +export async function listSuggestedProducts( + config, + { manufacturerId, supplierReference, productName }, +) { + const result = await queryEndpoint( + config, + suggestedProductsSql({ manufacturerId, supplierReference, productName }), + ); + + return { + configured: result.configured, + items: result.items.map((row) => ({ + idProduct: row.id_product, + name: row.name, + supplierReference: row.supplier_reference, + active: row.active, + imageUrl: row.image_url, + })), + }; +} + +export async function listMappingSources(config, { manufacturerId, checkUrls = false }) { + try { + const result = await queryEndpoint(config, mappingSourcesSql({ manufacturerId })); + const items = result.items.map((row) => + normalizeMappingSource(row, "ps_product_catalog_manufacturer_source"), + ); + const checkedItems = checkUrls ? await checkMappingSourceUrls(items) : items; + + return { + configured: result.configured, + mapped: checkedItems.some((item) => item.enabled), + tableName: "ps_product_catalog_manufacturer_source", + items: checkedItems, + sourceCount: checkedItems.filter((item) => item.enabled).length, + testedCount: checkedItems.filter((item) => item.enabled && item.health === "ok").length, + message: checkedItems.some((item) => item.enabled) ? "" : "Mapping missing", + }; + } catch (error) { + if (isMissingMappingSchemaError(error)) { + return { + configured: true, + mapped: false, + items: [], + message: "Catalog source tables are missing or have an unexpected structure.", + }; + } + + throw error; + } +} + +export async function findProductSources( + config, + { manufacturerId, sourceKey, productName, supplierReference, ean, eanGroup, color }, +) { + if (!productName && !supplierReference && !ean) { + throw new Error("Product name, supplier reference, or EAN is required."); + } + + const mapping = await listMappingSources(config, { manufacturerId }); + const wantedSourceKey = String(sourceKey ?? "").trim().toLowerCase(); + const enabledSources = mapping.items.filter( + (item) => + item.enabled && + item.url && + (!wantedSourceKey || String(item.key ?? "").toLowerCase() === wantedSourceKey), + ); + const items = await Promise.all( + enabledSources.map((source) => + findSourceCandidates(source, { productName, supplierReference, ean, eanGroup, color }), + ), + ); + + for (const item of items) { + if (item.source?.key !== "hudy") continue; + const candidate = item.candidates?.[0]; + if (candidate?.url) { + try { + openInFirefox(candidate.url); + } catch { + // Picture extraction can continue even if visible Firefox cannot start. + } + } + } + + return { + configured: mapping.configured, + items, + message: items.length ? "" : "No mapping sources found.", + }; +} + +export async function getProductPictures( + config, + { manufacturerId, sourceKey, productName, supplierReference, ean, eanGroup, color }, +) { + if (!productName && !supplierReference && !ean) { + throw new Error("Product name, supplier reference, or EAN is required."); + } + + const mapping = await listMappingSources(config, { manufacturerId }); + const wantedSourceKey = String(sourceKey ?? "").trim().toLowerCase(); + const sources = mapping.items.filter( + (item) => + item.enabled && + item.url && + (!wantedSourceKey || String(item.key ?? "").toLowerCase() === wantedSourceKey), + ); + const items = await Promise.all( + sources.map((source) => + findSourceCandidates(source, { productName, supplierReference, ean, eanGroup, color }, { includePictures: true }), + ), + ); + + return { + configured: mapping.configured, + items: items.map((item) => ({ + source: item.source, + status: item.status, + error: item.error || "", + query: item.query, + searchUrl: item.searchUrl, + candidates: item.candidates.map((candidate) => ({ + title: candidate.title, + rawTitle: candidate.rawTitle, + url: candidate.url, + pictures: candidate.pictures || [], + })), + })), + message: items.length ? "" : "No picture sources mapped.", + }; +} + +async function findSourceCandidates(source, product, { includePictures = false } = {}) { + const searchAttempts = buildAdapterSearchUrls(source, product); + let lastSearchUrl = searchAttempts[0]?.url || normalizeSourceUrl(source.url); + let lastError = ""; + + if (source.browserEngine === "firefox") { + const attempt = searchAttempts[0]; + if (!attempt) { + return { + source, + query: "", + searchUrl: lastSearchUrl, + status: "error", + error: "EAN is required for Idealo Firefox search.", + candidates: [], + }; + } + + try { + const result = await searchInFirefox({ + url: attempt.url, + headless: source.browserHeadless, + waitAfterLoadMs: source.waitAfterLoadMs, + }); + + if (result.ok) { + const candidate = formatFoundCandidate( + { title: result.title || "Idealo result", url: result.url || attempt.url }, + source, + attempt, + product, + ); + if (includePictures) candidate.pictures = extractPictureUrls(result.pageSource, result.url || attempt.url); + return { + source, + query: attempt.query, + searchUrl: result.url || attempt.url, + status: "found", + candidates: [candidate], + }; + } + + return { + source, + query: attempt.query, + searchUrl: result.url || attempt.url, + status: "error", + error: "Idealo returned an error page in Firefox.", + candidates: buildManualSearchCandidates(searchAttempts, product), + }; + } catch (error) { + try { + openInFirefox(attempt.url); + } catch { + // The manual candidate below remains available even if Firefox cannot start. + } + + return { + source, + query: attempt.query, + searchUrl: attempt.url, + status: "manual-search", + error: `Firefox automation unavailable: ${error.message || "search failed"}`, + candidates: buildManualSearchCandidates(searchAttempts, product), + }; + } + } + + 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 html = await response.text(); + const candidates = [ + ...extractResponseUrlCandidate(response.url || attempt.url, html, product), + ...extractDirectCandidates(html, source, product), + ].slice(0, 3); + + if (includePictures && source.key === "hudy" && product.color) { + const colorVariantUrl = findColorVariantUrl(html, response.url || attempt.url, product.color); + if (colorVariantUrl && candidates.length) { + candidates[0] = { ...candidates[0], url: colorVariantUrl }; + } + } + + if (candidates.length) { + let verification = null; + if (includePictures && source.key === "hudy" && product.color && candidates[0].url) { + verification = await verifyHudyVariant(candidates[0].url, product); + candidates[0].verification = verification; + if (verification.matchedUrl) candidates[0].url = verification.matchedUrl; + if (includePictures && verification.pictures?.length) { + candidates[0].pictures = verification.pictures; + } + candidates[0].title = `${candidates[0].title} / ${verification.verified ? "verified" : "NOT VERIFIED"}`; + } + if (includePictures) { + for (const candidate of candidates) { + if (source.key === "hudy" && candidate.url) { + candidate.pictures = await extractPicturesFromPage(candidate.url); + } else { + candidate.pictures = extractPictureUrls(html, response.url || attempt.url); + } + } + } + return { + source, + query: attempt.query, + searchUrl: response.url || attempt.url, + status: verification && !verification.verified ? "error" : "found", + ...(verification && !verification.verified + ? { error: "Hudy page does not match the requested color/EAN group." } + : {}), + candidates: candidates.map((candidate) => + formatFoundCandidate(candidate, source, attempt, product), + ), + }; + } + } catch (error) { + lastError = error.message || "Source search failed."; + } + } + + return { + source, + query: searchAttempts[0]?.query || "", + searchUrl: searchAttempts[0]?.url || lastSearchUrl, + status: lastError ? "error" : "manual-search", + error: lastError, + candidates: buildManualSearchCandidates(searchAttempts, product), + }; +} + +function buildManualSearchCandidates(searchAttempts, product) { + const candidates = []; + const seen = new Set(); + + for (const attempt of searchAttempts) { + if (attempt.method !== "GET") continue; + const key = `${attempt.kind}:${attempt.query}`; + if (seen.has(key)) continue; + seen.add(key); + candidates.push({ + title: formatSearchCandidateTitle(attempt, product), + url: attempt.url, + }); + if (candidates.length >= 4) break; + } + + return candidates; +} + +function formatFoundCandidate(candidate, source, attempt, product) { + const via = source.key ? `Direct ${formatSourceName(source)}` : "Direct source"; + const color = attempt.kind === "EAN" && product.color ? ` / Color: ${product.color}` : ""; + + return { + ...candidate, + rawTitle: candidate.title, + title: `${via} / ${attempt.kind}: ${attempt.query}${color}`, + }; +} + +function formatSourceName(source) { + if (source.key === "hudy") return "Hudy"; + if (source.key === "lasportiva") return "La Sportiva"; + if (source.key === "idealo") return "Idealo"; + return source.name || source.key || "source"; +} + +function formatSearchCandidateTitle(attempt, product) { + const color = product.color ? ` / Color: ${product.color}` : ""; + if (attempt.kind === "EAN") return `Search EAN: ${attempt.query}${color}`; + if (attempt.fallbackSourceType === "google_site") return `Google site search: ${attempt.query}`; + if (attempt.fallbackSourceType === "same_site") return `Same site fallback: ${attempt.query}`; + if (attempt.fallbackSourceType === "none") return `Fallback disabled: ${attempt.query}`; + return `Search ${attempt.kind}: ${attempt.query}`; +} + +function buildAdapterSearchUrls(source, product) { + const configuredAttempts = buildConfiguredSearchAttempts(source, product); + if (configuredAttempts.length) return configuredAttempts; + + const baseUrl = normalizeSourceUrl(source.url); + const host = getHostname(baseUrl); + const terms = buildSearchTerms(product); + const base = baseUrl.replace(/\/$/, ""); + + if (host.includes("hudy.cz")) { + return buildHudySearchAttempts(base, product); + } + + if (host.includes("lasportiva.com")) { + return terms.map((term) => + buildSearchAttempt(`${base}/catalogsearch/result/?q=${encodeURIComponent(term)}`, term), + ); + } + + if (host.includes("idealo.de")) { + return terms.map((term) => + buildSearchAttempt( + `${base}/preisvergleich/MainSearchProductCategory.html?q=${encodeURIComponent(term)}`, + term, + ), + ); + } + + return terms.map((term) => buildSearchAttempt(`${base}/search?q=${encodeURIComponent(term)}`, term)); +} + +function buildConfiguredSearchAttempts(source, product) { + const attempts = []; + const primaryKey = source.searchPrimaryKey || "ean"; + + attempts.push(...buildAttemptsFromTemplate(source.searchUrlTemplate, source, product, primaryKey)); + + if (source.fallbackSourceType !== "none") { + for (const key of source.searchFallbackKeys) { + attempts.push( + ...buildAttemptsFromTemplate( + source.fallbackUrlTemplate || source.searchUrlTemplate, + source, + product, + key, + { fallbackSourceType: source.fallbackSourceType || "same_site" }, + ), + ); + } + } + + return dedupeAttempts(attempts); +} + +function buildAttemptsFromTemplate(template, source, product, key, options = {}) { + if (!template) return []; + const values = getSearchValuesForKey(product, key); + + return values.map((value) => + buildSearchAttempt(renderUrlTemplate(template, source, product, { key, value }), value, options), + ); +} + +function renderUrlTemplate(template, source, product, { key, value }) { + const replacements = { + base_url: normalizeSourceUrl(source.url).replace(/\/$/, ""), + ean: product.ean || "", + supplier_reference: product.supplierReference || "", + product_name: expandProductNames(product.productName)[0] || "", + product_name_alt: expandProductNames(product.productName)[1] || "", + color: product.color || "", + search_value: value || "", + [key]: value || "", + }; + + return String(template).replace(/\{([a-z0-9_]+)\}/gi, (_, name) => + encodeURIComponent(replacements[name] ?? ""), + ); +} + +function getSearchValuesForKey(product, key) { + if (key === "ean") return product.ean ? [product.ean] : []; + if (key === "supplier_reference") return product.supplierReference ? [product.supplierReference] : []; + if (key === "product_name") return expandProductNames(product.productName); + if (key === "color") return product.color ? [product.color] : []; + return []; +} + +function dedupeAttempts(attempts) { + const seen = new Set(); + return attempts.filter((attempt) => { + const key = `${attempt.url}|${attempt.query}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function buildSearchAttempt(url, query, options = {}) { + return { + url, + query, + kind: detectSearchTermKind(query), + method: "GET", + fallbackSourceType: options.fallbackSourceType || "", + }; +} + +function buildHudySearchAttempts(base, product) { + const attempts = []; + if (product.ean) { + attempts.push(buildSearchAttempt(`${base}/vyhledavani?q=${encodeURIComponent(product.ean)}`, product.ean)); + } + + const productName = expandProductNames(product.productName)[0]; + if (productName) { + attempts.push( + buildSearchAttempt( + `https://www.google.com/search?q=${encodeURIComponent(`site:hudy.cz ${productName}`)}`, + productName, + ), + ); + } + + return attempts; +} + +function extractResponseUrlCandidate(url, html, product) { + const title = extractHtmlTitle(html); + if (!looksLikeProductCandidate(url, title, product)) return []; + + return [ + { + title: title || url, + url, + }, + ]; +} + +function extractHtmlTitle(html) { + return cleanHtmlText(String(html ?? "").match(/]*>([\s\S]*?)<\/title>/i)?.[1] || ""); +} + +function findColorVariantUrl(html, pageUrl, color) { + const wanted = normalizeColorText(color); + if (!wanted) return ""; + + const linkPattern = /]*?)href=(?:"([^"]+)"|'([^']+)'|([^\s>]+))([^>]*)>([\s\S]*?)<\/a>/gi; + for (const match of String(html ?? "").matchAll(linkPattern)) { + const attributes = `${match[1]} ${match[5]}`; + const label = cleanHtmlText(match[6]); + const haystack = normalizeColorText(`${attributes} ${label}`); + if (!haystack.includes(wanted)) continue; + + const href = match[2] || match[3] || match[4]; + const url = normalizeDirectResultUrl(href, pageUrl); + if (url && isSameHost(url, getHostname(pageUrl))) return url; + } + + return ""; +} + +async function verifyHudyVariant(url, product) { + try { + const eanGroup = Array.isArray(product.eanGroup) + ? product.eanGroup + : String(product.eanGroup || product.ean || "").split(","); + const wantedEans = new Set( + eanGroup.map((value) => String(value || "").replace(/\D/g, "")).filter(Boolean), + ); + const pages = [{ url, html: "" }]; + const visited = new Set(); + let matched = null; + let lastStatus = 0; + + for (let index = 0; index < pages.length && index < 20; index += 1) { + const page = pages[index]; + 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(); + lastStatus = response.status; + const pageEans = extractPageEans(html); + const matchedEan = pageEans.find((value) => wantedEans.has(value)) || ""; + const colorMatch = normalizeColorText(cleanHtmlText(html)).includes(normalizeColorText(product.color)); + + if (matchedEan) { + matched = { + url: response.url || page.url, + html, + matchedEan, + colorMatch, + pageEans, + }; + break; + } + + for (const variantUrl of extractHudyVariantUrls(html, response.url || page.url)) { + if (!visited.has(variantUrl) && !pages.some((candidate) => candidate.url === variantUrl)) { + pages.push({ url: variantUrl, html: "" }); + } + } + } + + return { + verified: Boolean(matched), + matchedEan: matched?.matchedEan || "", + pageEans: matched?.pageEans || [], + colorMatch: matched?.colorMatch || false, + matchedUrl: matched?.url || "", + checkedVariants: visited.size, + pictures: matched ? extractHudyGalleryPictureUrls(matched.html, matched.url) : [], + httpStatus: lastStatus, + }; + } catch (error) { + return { verified: false, error: error.message || "Variant verification failed." }; + } +} + +function extractPageEans(html) { + return [...new Set( + cleanHtmlText(html).match(/\b\d{8,14}\b/g) || [], + )]; +} + +function extractHudyVariantUrls(html, pageUrl) { + const urls = []; + const linkPattern = /]*?(?:product-change-color|change-color)[^>]*)>/gi; + for (const match of String(html ?? "").matchAll(linkPattern)) { + const href = match[1].match(/href=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i); + const url = normalizeDirectResultUrl(href?.[1] || href?.[2] || href?.[3], pageUrl); + if (url && isSameHost(url, getHostname(pageUrl)) && !urls.includes(url)) urls.push(url); + } + return urls; +} + +function normalizeColorText(value) { + return String(value ?? "") + .toLowerCase() + .replace(/&/g, "&") + .replace(/[_-]+/g, " ") + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function extractPictureUrls(html, pageUrl) { + const source = String(html ?? ""); + const urls = []; + const add = (value) => { + const decoded = decodeHtmlEntities(String(value ?? "").trim()); + if (!decoded || decoded.startsWith("data:") || /\.svg(?:[?#]|$)/i.test(decoded)) return; + try { + const url = new URL(decoded, pageUrl).href; + if (!/^https?:/i.test(url) || /(?:logo|icon|sprite|favicon)/i.test(url)) return; + const largeUrl = url.replace(/\/w\d+h\d+-jpg\//i, "/w1000h1000-jpg/"); + if (largeUrl !== url && !urls.includes(largeUrl)) urls.push(largeUrl); + if (!urls.includes(url)) urls.push(url); + } catch { + // Ignore malformed image references from the source page. + } + }; + + for (const match of source.matchAll(/]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)/gi)) { + add(match[1]); + } + for (const match of source.matchAll(/(?:data-(?:original|large|full|zoom-image|image)|data-src)=["']([^"']+)/gi)) { + add(match[1]); + } + for (const match of source.matchAll(/]*href=["']([^"']+\.(?:jpe?g|png|webp)(?:\?[^"']*)?)["']/gi)) { + add(match[1]); + } + for (const match of source.matchAll(/]*(?:src|data-src)=["']([^"']+)/gi)) add(match[1]); + for (const match of source.matchAll(/(?:"image"\s*:\s*|"imageUrl"\s*:\s*")["']?([^"'\\,} ]+)/gi)) add(match[1]); + for (const match of source.matchAll(/srcset=["']([^"']+)/gi)) { + for (const part of match[1].split(",")) add(part.trim().split(/\s+/)[0]); + } + + return urls.slice(0, 12); +} + +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); + } catch { + return []; + } +} + +function extractHudyGalleryPictureUrls(html, pageUrl) { + const urls = []; + const add = (value) => { + const decoded = decodeHtmlEntities(String(value ?? "").trim()); + if (!decoded) return; + const url = normalizeDirectResultUrl(decoded, pageUrl); + if (!url || !/\/data\/images\/(?:pdbox1000w|w\d+h\d+)/i.test(url)) return; + if (!urls.includes(url)) urls.push(url); + }; + + const tagPattern = /]*?(?:js-product-top-img-link|js-product-top-img-target)[^>]*)>/gi; + for (const match of String(html ?? "").matchAll(tagPattern)) { + const href = match[1].match(/href=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i); + add(href?.[1] || href?.[2] || href?.[3]); + } + + return urls.filter((url) => /\/pdbox1000w\//i.test(url)); +} + +function detectSearchTermKind(query) { + if (/^\d{8,14}$/.test(String(query))) return "EAN"; + if (/^[a-z0-9-]{3,}$/i.test(String(query)) && /\d/.test(String(query))) return "reference"; + return "name"; +} + +function buildSearchTerms(product) { + const names = expandProductNames(product.productName); + const terms = []; + + if (product.ean) terms.push(product.ean); + if (product.supplierReference) terms.push(product.supplierReference); + terms.push(...names); + if (product.color) terms.push(`${names[0] || ""} ${product.color}`.trim()); + + return dedupeStrings(terms); +} + +function expandProductNames(name) { + const text = String(name ?? "").trim(); + if (!text) return []; + const names = [text]; + if (/\bWoman\b/i.test(text)) names.push(text.replace(/\bWoman\b/gi, "Women")); + if (/\bWomen\b/i.test(text)) names.push(text.replace(/\bWomen\b/gi, "Woman")); + return dedupeStrings(names); +} + +function dedupeStrings(items) { + return [...new Set(items.filter(Boolean))]; +} + +function extractDirectCandidates(html, source, product) { + const sourceHost = getHostname(source.url); + const candidates = []; + const seen = new Set(); + const linkPattern = /]*href=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/a>/gi; + + for (const match of html.matchAll(linkPattern)) { + const url = normalizeDirectResultUrl(match[1] || match[2], source.url); + const title = cleanHtmlText(match[3]); + if (!url || seen.has(url) || !isSameHost(url, sourceHost)) continue; + if (!looksLikeProductCandidate(url, title, product)) continue; + seen.add(url); + candidates.push({ + title: title || url, + url, + }); + } + + return candidates; +} + +function normalizeDirectResultUrl(value, baseUrl) { + const text = decodeHtmlEntities(String(value ?? "")); + try { + return new URL(text, normalizeSourceUrl(baseUrl)).href; + } catch { + return ""; + } +} + +function looksLikeProductCandidate(url, title, product) { + const haystack = `${url} ${title}`.toLowerCase(); + const productWords = expandProductNames(product.productName) + .flatMap((name) => name.toLowerCase().split(/\s+/)) + .map((word) => word.replace(/[^a-z0-9]+/g, "")) + .filter((word) => word.length >= 4 && !isGenericProductWord(word)); + const matchedWords = productWords.filter((word) => haystack.includes(word)); + const hasSpecificProductWord = matchedWords.some((word) => word.length >= 6); + const hasReference = + product.supplierReference && haystack.includes(String(product.supplierReference).toLowerCase()); + const hasEan = product.ean && haystack.includes(String(product.ean).toLowerCase()); + const badPath = + /\/(?:cart|checkout|login|account|customer|wishlist|compare)\b/i.test(url) || + /\/(?:vyhledavani|search|catalogsearch)\b/i.test(url) || + /[?&](?:search|query|q)=/i.test(url); + + return !badPath && (hasEan || hasReference || hasSpecificProductWord || matchedWords.length >= 2); +} + +function isGenericProductWord(word) { + return new Set(["trek", "gtx", "woman", "women", "mens", "womens", "boty", "shop"]).has(word); +} + +function isSameHost(url, expectedHost) { + if (!expectedHost) return true; + const actualHost = getHostname(url); + return actualHost === expectedHost || actualHost.endsWith(`.${expectedHost}`); +} + +function getHostname(url) { + try { + return new URL(normalizeSourceUrl(url)).hostname.replace(/^www\./, "").toLowerCase(); + } catch { + return ""; + } +} + +function cleanHtmlText(value) { + return decodeHtmlEntities(String(value ?? "").replace(/<[^>]*>/g, " ")) + .replace(/\s+/g, " ") + .trim(); +} + +function decodeHtmlEntities(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +async function checkMappingSourceUrls(items) { + return Promise.all( + items.map(async (item) => { + if (!item.enabled || !item.url) { + return { + ...item, + health: item.url ? "skipped" : "missing-url", + httpStatus: "", + }; + } + + return { + ...item, + ...(await checkUrl(item.url)), + }; + }), + ); +} + +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), + }); + } + + return { + health: response.ok ? "ok" : "warning", + httpStatus: response.status, + }; + } catch (error) { + return { + health: "error", + httpStatus: "", + error: error.message || "URL check failed.", + }; + } +} + +function normalizeSourceUrl(url) { + const text = String(url ?? "").trim(); + if (/^https?:\/\//i.test(text)) return text; + return `https://${text}`; +} + +function normalizeProduct(row) { + return { + idProductCatalog: row.id_product_catalog, + idManufacturer: row.id_manufacturer, + codeManufacture: row.code_manufacture, + vocDiscount: row.voc_discount, + maxMocDiscount: row.max_moc_discount ?? row.max_moc_dicount, + supplierReference: row.supplier_reference, + catalogValidUntil: row.catalog_valid_until, + name: row.name, + priceMoc: row.price ?? row.price_moc ?? row.moc_price, + combinations: row.combinations ?? row.combination, + idAttributeGroup: row.id_attribute_group, + idSupplier: row.id_supplier, + idCategoryDefault: row.id_category_default, + category9bName: row["9b_category_name"], + manufacturerName: row.manufacturer_name, + catalogCategory: row.catalog_category ?? row.category, + gender: row.gender, + position: row.position, + total: row.total, + photosLoaded: row.photos_loaded ?? getPhotosCount(row.photo_json), + referenceSeparator: row.reference_separator, + idProduct: row.id_product, + price: row.price, + supplierStock: row.skladovka, + catalog9b: row.catalog_9b, + imageUrl: row.image_url, + }; +} + +function normalizeMappingSource(row, tableName) { + return { + id: + row.mapping_id ?? + row.id_product_catalog_manufacturer_source ?? + row.id_product_catalog_source ?? + row.id ?? + "", + key: row.source_key ?? row.source_code ?? row.code ?? row.type ?? "", + name: row.source_name ?? row.name ?? row.title ?? row.source ?? "", + type: row.source_type ?? row.type ?? row.kind ?? "", + url: row.base_url ?? row.url ?? row.source_url ?? row.website ?? "", + priority: + row.mapping_priority ?? row.priority ?? row.source_priority ?? row.sort_order ?? row.position ?? "", + searchPrimaryKey: row.search_primary_key ?? "", + searchFallbackKeys: parseList(row.search_fallback_keys), + fallbackSourceType: row.fallback_source_type ?? "", + searchUrlTemplate: row.search_url_template ?? "", + fallbackUrlTemplate: row.fallback_url_template ?? "", + searchContextKeys: parseList(row.search_context_keys), + accessMode: row.access_mode ?? "browser", + browserEngine: String(row.browser_engine ?? "default").trim().toLowerCase(), + browserHeadless: normalizeEnabled(row.browser_headless ?? 0), + waitAfterLoadMs: Number(row.wait_after_load_ms) || 3000, + enabled: normalizeEnabled(row.mapping_enabled ?? row.enabled ?? row.active ?? 1) + && normalizeEnabled(row.enabled ?? 1), + note: row.note ?? row.description ?? "", + tableName, + }; +} + +function parseList(value) { + return String(value ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function normalizeEnabled(value) { + if (value === false) return false; + if (value === true) return true; + return String(value ?? "1") !== "0"; +} + +function isMissingMappingSchemaError(error) { + return /doesn't exist|unknown column|1146|1054/i.test(error.message || ""); +} + +function normalizeCombination(row) { + return { + status: [row.catalog, row.status].filter(Boolean).join("/") || row.status, + color: row.color, + combi: normalizeCombinationValue(row.combi ?? row.combination ?? row.combinations), + refer: row.refer ?? row.reference_extra, + ean13: row.ean_13, + nineB: row.catalog_9b, + supplierStock: row.skladovka, + hasProblem: false, + id: row.id_product_catalog, + voc: row.voc ?? row.wholesale_price, + moc: row.moc ?? row.moc_price, + productAttribute: row.product_attribute, + raw: row, + }; +} + +function normalizeProductInfo(row) { + return { + idProductCatalog: row.id_product_catalog, + productAttribute: row.id_product_attribute, + catalogColor: row.color_attribute_id, + catalogCombi: row.combi_attribute_id, + nineBEan13: row.nine_b_ean13, + nineBVoc: row.nine_b_voc, + nineBMoc: row.nine_b_moc, + hasProblem: Number(row.has_problem) === 1, + eanStatus: row.nine_b_ean13 && row.nine_b_ean13 !== row.catalog_ean13 ? "NEW EAN" : "", + colorAttributeId: row.color_attribute_id, + combiAttributeId: row.combi_attribute_id, + }; +} + +function normalizeCombinationValue(value) { + const text = String(value ?? "").trim(); + return /^\d+,\d+$/.test(text) ? text.replace(",", ".") : text; +} + +function getPhotosCount(photoJson) { + if (!photoJson) return ""; + + try { + const parsed = JSON.parse(photoJson); + return Array.isArray(parsed.photos) ? `${parsed.photos.length} pict.` : ""; + } catch { + return ""; + } +} diff --git a/src/services/firefox-adapter.js b/src/services/firefox-adapter.js new file mode 100644 index 0000000..e4da58d --- /dev/null +++ b/src/services/firefox-adapter.js @@ -0,0 +1,82 @@ +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.js new file mode 100644 index 0000000..3481f0a --- /dev/null +++ b/src/services/languages.js @@ -0,0 +1,14 @@ +import { queryEndpoint } from "../endpoint/endpoint-client.js"; +import { catalogMakerSql } from "../sql/catalog-maker.js"; + +export async function listLanguages(config) { + const result = await queryEndpoint(config, catalogMakerSql.languages); + return { + ...result, + items: result.items.map((row) => ({ + id: row.id_lang, + name: row.name, + isoCode: row.iso_code, + })), + }; +} diff --git a/src/services/manufacturers.js b/src/services/manufacturers.js new file mode 100644 index 0000000..f6f2a93 --- /dev/null +++ b/src/services/manufacturers.js @@ -0,0 +1,13 @@ +import { queryEndpoint } from "../endpoint/endpoint-client.js"; +import { catalogMakerSql } from "../sql/catalog-maker.js"; + +export async function listManufacturers(config) { + const result = await queryEndpoint(config, catalogMakerSql.manufacturers); + return { + ...result, + items: result.items.map((row) => ({ + id: row.id_manufacturer, + name: row.name, + })), + }; +} diff --git a/src/sql/catalog-maker.js b/src/sql/catalog-maker.js new file mode 100644 index 0000000..c7310b5 --- /dev/null +++ b/src/sql/catalog-maker.js @@ -0,0 +1,442 @@ +export const catalogMakerSql = { + manufacturers: ` + SELECT id_manufacturer, name + FROM ps_manufacturer + ORDER BY name ASC + `, + manufacturerByName: ` + SELECT id_manufacturer + FROM ps_manufacturer + WHERE name = ? + `, + languages: ` + SELECT id_lang, name, iso_code + FROM ps_lang + ORDER BY name ASC + `, +}; + +export function prepareManufacturerCatalogSqls({ manufacturerId }) { + const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId"); + + return [ + ` + UPDATE ps_product_catalog pc + SET pc.skladovka = 0, + pc.catalog_9b = 0 + WHERE pc.id_manufacturer = ${safeManufacturerId} + `, + ` + UPDATE ps_product_catalog pc + JOIN ps_import_suppliers_dump isd ON isd.ean = pc.ean_13 + SET pc.skladovka = 1 + WHERE pc.id_manufacturer = ${safeManufacturerId} + `, + ` + UPDATE ps_product_catalog pc + SET pc.catalog_9b = 1 + WHERE pc.id_manufacturer = ${safeManufacturerId} + AND pc.ean_13 IN ( + SELECT pa.ean13 + FROM ps_product_attribute pa + JOIN ps_product p ON p.id_product = pa.id_product + WHERE p.id_manufacturer = ${safeManufacturerId} + ) + `, + ]; +} + +export function productToDoByManufacturerSql({ manufacturerId, position = 1 }) { + const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId"); + const safePosition = Number.parseInt(position, 10) || 1; + + return ` + WITH todo_refs AS ( + SELECT + supplier_reference, + ROW_NUMBER() OVER (ORDER BY MIN(id_product_catalog) ASC) AS position, + COUNT(*) OVER () AS total + FROM ps_product_catalog + WHERE id_manufacturer = ${safeManufacturerId} + AND catalog_9b = 0 + AND skladovka = 1 + GROUP BY supplier_reference + ), + target_ref AS ( + SELECT supplier_reference, position, total + FROM todo_refs + WHERE position = ${safePosition} + ), + combination AS ( + SELECT pc.* + FROM ps_product_catalog pc + JOIN target_ref tr ON tr.supplier_reference = pc.supplier_reference + WHERE pc.id_manufacturer = ${safeManufacturerId} + AND pc.catalog_9b = 0 + AND pc.skladovka = 1 + ORDER BY pc.id_product_catalog ASC + LIMIT 1 + ), + existing_product AS ( + SELECT pc.supplier_reference, pc.ean_13 + FROM ps_product_catalog pc + JOIN target_ref tr ON tr.supplier_reference = pc.supplier_reference + WHERE pc.id_manufacturer = ${safeManufacturerId} + AND pc.catalog_9b = 1 + AND (pc.skladovka = 1 OR pc.skladovka = 0) + ORDER BY pc.id_product_catalog ASC + ) + SELECT + co.*, + tr.position, + tr.total, + p.id_product, + p.price, + p.id_supplier, + ( + SELECT CONCAT('https://www.9b-plus.com/', i.id_image, '-home_default/', pl.link_rewrite, '.jpg') + FROM ps_image i + JOIN ps_product_lang pl + ON pl.id_product = i.id_product + AND pl.id_lang = 2 + WHERE i.id_product = p.id_product + ORDER BY i.cover DESC, i.position ASC + LIMIT 1 + ) AS image_url + FROM combination co + JOIN target_ref tr ON tr.supplier_reference = co.supplier_reference + LEFT JOIN existing_product ep ON ep.supplier_reference = co.supplier_reference + LEFT JOIN ps_product_attribute pa ON pa.ean13 = ep.ean_13 + LEFT JOIN ps_product p ON p.id_product = pa.id_product + LIMIT 1 + `; +} + +export function productByEanSql({ manufacturerId, ean }) { + const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId"); + const safeEan = escapeSqlString(normalizeEan(ean)); + + if (!safeEan) { + throw new Error("ean is required."); + } + + return ` + WITH todo_refs AS ( + SELECT + supplier_reference, + ROW_NUMBER() OVER (ORDER BY MIN(id_product_catalog) ASC) AS position, + COUNT(*) OVER () AS total + FROM ps_product_catalog + WHERE id_manufacturer = ${safeManufacturerId} + AND catalog_9b = 0 + AND skladovka = 1 + GROUP BY supplier_reference + ), + combination AS ( + SELECT pc.* + FROM ps_product_catalog pc + WHERE pc.id_manufacturer = ${safeManufacturerId} + AND pc.ean_13 = '${safeEan}' + ORDER BY pc.id_product_catalog ASC + LIMIT 1 + ), + existing_product AS ( + SELECT pc.supplier_reference, pc.ean_13 + FROM ps_product_catalog pc + JOIN combination co ON co.supplier_reference = pc.supplier_reference + WHERE pc.id_manufacturer = ${safeManufacturerId} + AND pc.catalog_9b = 1 + ORDER BY pc.id_product_catalog ASC + ) + SELECT + co.*, + tr.position, + tr.total, + p.id_product, + p.price, + p.id_supplier, + ( + SELECT CONCAT('https://www.9b-plus.com/', i.id_image, '-home_default/', pl.link_rewrite, '.jpg') + FROM ps_image i + JOIN ps_product_lang pl + ON pl.id_product = i.id_product + AND pl.id_lang = 2 + WHERE i.id_product = p.id_product + ORDER BY i.cover DESC, i.position ASC + LIMIT 1 + ) AS image_url + FROM combination co + LEFT JOIN todo_refs tr ON tr.supplier_reference = co.supplier_reference + LEFT JOIN existing_product ep ON ep.supplier_reference = co.supplier_reference + LEFT JOIN ps_product_attribute pa ON pa.ean13 = ep.ean_13 + LEFT JOIN ps_product p ON p.id_product = pa.id_product + LIMIT 1 + `; +} + +export function productCombinationsByReferenceSql({ manufacturerId, supplierReference }) { + const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId"); + const safeSupplierReference = escapeSqlString(supplierReference); + + if (!safeSupplierReference) { + throw new Error("supplierReference is required."); + } + + return ` + SELECT pc.* + FROM ps_product_catalog pc + WHERE pc.id_manufacturer = ${safeManufacturerId} + AND pc.supplier_reference = '${safeSupplierReference}' + ORDER BY pc.id_product_catalog ASC + `; +} + +export function productCombinationInfoSql({ productId, manufacturerId, supplierReference }) { + const safeProductId = parsePositiveInteger(productId, "productId"); + const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId"); + const safeSupplierReference = escapeSqlString(supplierReference); + + if (!safeSupplierReference) { + throw new Error("supplierReference is required."); + } + + return ` + WITH catalog_rows AS ( + SELECT pc.* + FROM ps_product_catalog pc + WHERE pc.id_manufacturer = ${safeManufacturerId} + AND pc.supplier_reference = '${safeSupplierReference}' + ), + attribute_group AS ( + SELECT a.id_attribute_group + FROM ps_product_attribute pa + JOIN ps_product_attribute_combination pac + ON pac.id_product_attribute = pa.id_product_attribute + JOIN ps_attribute a + ON a.id_attribute = pac.id_attribute + JOIN ps_attribute_group_lang agl + ON agl.id_attribute_group = a.id_attribute_group + WHERE a.id_attribute_group <> 1 + AND agl.id_lang = 1 + AND pa.id_product = ${safeProductId} + GROUP BY a.id_attribute_group + ORDER BY a.id_attribute_group ASC + LIMIT 1 + ), + matched_attributes AS ( + SELECT + cr.id_product_catalog, + ( + SELECT a.id_attribute + FROM ps_attribute_lang al + JOIN ps_attribute a + ON a.id_attribute = al.id_attribute + WHERE al.name = TRIM(SUBSTRING_INDEX(cr.color, '/', 1)) + AND al.id_lang = 1 + AND a.id_attribute_group = 1 + ORDER BY a.id_attribute ASC + LIMIT 1 + ) AS color_attribute_id, + ( + SELECT al.name + FROM ps_attribute_lang al + JOIN ps_attribute a + ON a.id_attribute = al.id_attribute + WHERE al.name = TRIM(SUBSTRING_INDEX(cr.color, '/', 1)) + AND al.id_lang = 1 + AND a.id_attribute_group = 1 + ORDER BY a.id_attribute ASC + LIMIT 1 + ) AS color_name, + ( + SELECT a.id_attribute + FROM ps_attribute_lang al + JOIN ps_attribute a + ON a.id_attribute = al.id_attribute + JOIN attribute_group ag + ON ag.id_attribute_group = a.id_attribute_group + WHERE al.name = REPLACE(TRIM(cr.combination), ',', '.') + AND al.id_lang = 1 + ORDER BY a.id_attribute ASC + LIMIT 1 + ) AS combi_attribute_id, + ( + SELECT al.name + FROM ps_attribute_lang al + JOIN ps_attribute a + ON a.id_attribute = al.id_attribute + JOIN attribute_group ag + ON ag.id_attribute_group = a.id_attribute_group + WHERE al.name = REPLACE(TRIM(cr.combination), ',', '.') + AND al.id_lang = 1 + ORDER BY a.id_attribute ASC + LIMIT 1 + ) AS combi_name + FROM catalog_rows cr + ), + existing_combinations AS ( + SELECT + ma.id_product_catalog, + pa.id_product_attribute, + pa.ean13 + FROM matched_attributes ma + JOIN ps_product_attribute_combination pac_color + ON pac_color.id_attribute = ma.color_attribute_id + JOIN ps_product_attribute_combination pac_combi + ON pac_combi.id_product_attribute = pac_color.id_product_attribute + AND pac_combi.id_attribute = ma.combi_attribute_id + JOIN ps_product_attribute pa + ON pa.id_product_attribute = pac_color.id_product_attribute + AND pa.id_product = ${safeProductId} + ) + SELECT + cr.id_product_catalog, + cr.ean_13 AS catalog_ean13, + ma.color_attribute_id, + ma.color_name, + ma.combi_attribute_id, + ma.combi_name, + ec.id_product_attribute, + ec.ean13 AS nine_b_ean13, + ROUND(cr.wholesale_price * (1 - (CAST(cr.voc_discount AS DECIMAL(10, 4)) / 100)), 0) + AS nine_b_voc, + ROUND(cr.moc_price * (1 - (CAST(cr.max_moc_dicount AS DECIMAL(10, 4)) / 100)), 0) + AS nine_b_moc, + CASE + WHEN cr.skladovka = 1 + AND ( + ma.color_attribute_id IS NULL + OR ma.combi_attribute_id IS NULL + OR ec.id_product_attribute IS NULL + OR ec.ean13 <> cr.ean_13 + ) + THEN 1 + ELSE 0 + END AS has_problem + FROM catalog_rows cr + LEFT JOIN matched_attributes ma + ON ma.id_product_catalog = cr.id_product_catalog + LEFT JOIN existing_combinations ec + ON ec.id_product_catalog = cr.id_product_catalog + ORDER BY cr.id_product_catalog ASC + `; +} + +export function suggestedProductsSql({ manufacturerId, supplierReference, productName }) { + const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId"); + const safeSupplierReference = escapeSqlLike(supplierReference); + const safeProductName = escapeSqlLike(productName); + + if (!safeSupplierReference && !safeProductName) { + throw new Error("supplierReference or productName is required."); + } + + const conditions = []; + if (safeSupplierReference) { + conditions.push(`p.supplier_reference LIKE '%${safeSupplierReference}%'`); + } + if (safeProductName) { + conditions.push(`pl.name LIKE '%${safeProductName}%'`); + } + + return ` + SELECT + p.id_product, + pl.name, + p.supplier_reference, + p.active, + ( + SELECT CONCAT('https://www.9b-plus.com/', i.id_image, '-cart_default/', pli.link_rewrite, '.jpg') + FROM ps_image i + JOIN ps_product_lang pli + ON pli.id_product = i.id_product + AND pli.id_lang = 2 + WHERE i.id_product = p.id_product + ORDER BY i.cover DESC, i.position ASC + LIMIT 1 + ) AS image_url + FROM ps_product p + JOIN ps_product_lang pl + ON pl.id_product = p.id_product + AND pl.id_lang = 1 + WHERE p.id_manufacturer = ${safeManufacturerId} + AND (${conditions.join(" OR ")}) + ORDER BY p.active DESC, p.id_product DESC + LIMIT 20 + `; +} + +export function mappingSourceTablesSql() { + return ` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name IN ( + 'ps_product_catalog_source', + 'ps_product_catalog_manufacturer_source' + ) + ORDER BY FIELD( + table_name, + 'ps_product_catalog_manufacturer_source', + 'ps_product_catalog_source' + ) + `; +} + +export function mappingSourcesSql({ manufacturerId }) { + const safeManufacturerId = parsePositiveInteger(manufacturerId, "manufacturerId"); + + return ` + SELECT + ms.id_product_catalog_manufacturer_source AS mapping_id, + ms.id_manufacturer, + ms.id_product_catalog_source, + ms.enabled AS mapping_enabled, + ms.priority AS mapping_priority, + s.source_key, + s.source_name, + s.source_type, + s.base_url, + s.enabled, + s.priority AS source_priority, + s.search_primary_key, + s.search_fallback_keys, + s.fallback_source_type, + s.search_url_template, + s.fallback_url_template, + s.search_context_keys, + s.access_mode, + s.browser_engine, + s.browser_headless, + s.wait_after_load_ms, + s.note + FROM ps_product_catalog_manufacturer_source ms + JOIN ps_product_catalog_source s + ON s.id_product_catalog_source = ms.id_product_catalog_source + WHERE ms.id_manufacturer = ${safeManufacturerId} + ORDER BY + ms.priority ASC, + s.priority ASC, + s.source_name ASC + `; +} + +function escapeSqlString(value) { + return String(value ?? "").replaceAll("\\", "\\\\").replaceAll("'", "''").trim(); +} + +function escapeSqlLike(value) { + return escapeSqlString(value).replaceAll("%", "\\%").replaceAll("_", "\\_"); +} + +function parsePositiveInteger(value, name) { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer.`); + } + return parsed; +} + +function normalizeEan(value) { + return String(value ?? "").replace(/\D/g, ""); +} diff --git a/src/workflows/catalog-maker.js b/src/workflows/catalog-maker.js new file mode 100644 index 0000000..ea1c3b9 --- /dev/null +++ b/src/workflows/catalog-maker.js @@ -0,0 +1,62 @@ +import fs from "node:fs"; +import { info, warn } from "../lib/log.js"; + +const steps = [ + { + id: "load-context", + title: "Load workbook and local configuration", + status: "planned", + }, + { + id: "load-manufacturer", + title: "Load selected manufacturer and products to process", + status: "needs-mapping", + }, + { + id: "check-stock", + title: "Check supplier stock and existing catalog combinations", + status: "needs-mapping", + }, + { + id: "build-combinations", + title: "Create or update product combinations", + status: "needs-mapping", + }, + { + id: "update-prices", + title: "Update product and combination prices", + status: "needs-mapping", + }, + { + id: "update-texts", + title: "Generate/update product card texts", + status: "needs-mapping", + }, + { + id: "handle-pictures", + title: "Resolve cover/detail product pictures", + status: "needs-mapping", + }, +]; + +export async function runCatalogMaker({ config }) { + info("Catalog maker workflow"); + info(`Mode: ${config.dryRun ? "dry-run" : "live"}`); + info(`Workbook: ${config.sourceWorkbook}`); + + if (!fs.existsSync(config.sourceWorkbook)) { + throw new Error(`Source workbook not found: ${config.sourceWorkbook}`); + } + + info(""); + info("Planned replacement for Excel button: Catalog maker - 20"); + + for (const step of steps) { + const marker = step.status === "planned" ? "ready" : step.status; + info(`- ${step.id}: ${marker} - ${step.title}`); + } + + warn(""); + warn("No database or catalog changes were made. Database access in this project is read-only by rule #1."); + warn("Next step: map the original VBA/SQL behavior for CommandButton20_Click."); +} diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..6c7af0d --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,15 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { loadConfig } from "../src/config.js"; + +test("defaults to dry-run mode", () => { + const config = loadConfig({ configPath: "missing.json" }); + + assert.equal(config.dryRun, true); +}); + +test("cli dry-run override wins over local config", () => { + const config = loadConfig({ configPath: "missing.json", dryRun: true }); + + assert.equal(config.dryRun, true); +}); diff --git a/test/sql-catalog-maker.test.js b/test/sql-catalog-maker.test.js new file mode 100644 index 0000000..e667a8a --- /dev/null +++ b/test/sql-catalog-maker.test.js @@ -0,0 +1,79 @@ +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 { + catalogMakerSql, + mappingSourceTablesSql, + mappingSourcesSql, + prepareManufacturerCatalogSqls, + productByEanSql, + productCombinationInfoSql, + productCombinationsByReferenceSql, + productToDoByManufacturerSql, +} from "../src/sql/catalog-maker.js"; + +test("manufacturer query is read-only", () => { + assert.doesNotThrow(() => assertReadOnlySql(catalogMakerSql.manufacturers)); +}); + +test("languages query is read-only", () => { + assert.doesNotThrow(() => assertReadOnlySql(catalogMakerSql.languages)); +}); + +test("product to-do query is read-only", () => { + assert.doesNotThrow(() => + assertReadOnlySql(productToDoByManufacturerSql({ manufacturerId: 13, position: 1 })), + ); +}); + +test("product combinations query is read-only", () => { + assert.doesNotThrow(() => + assertReadOnlySql( + productCombinationsByReferenceSql({ manufacturerId: 13, supplierReference: "TEST" }), + ), + ); +}); + +test("product combination info query is read-only", () => { + assert.doesNotThrow(() => + assertReadOnlySql( + productCombinationInfoSql({ + productId: 135520, + manufacturerId: 13, + supplierReference: "ZFHS099", + }), + ), + ); +}); + +test("product by EAN query is read-only", () => { + assert.doesNotThrow(() => + assertReadOnlySql(productByEanSql({ manufacturerId: 13, ean: "8058428191321" })), + ); +}); + +test("mapping source queries are read-only", () => { + assert.doesNotThrow(() => assertReadOnlySql(mappingSourceTablesSql())); + assert.doesNotThrow(() => + assertReadOnlySql(mappingSourcesSql({ manufacturerId: 13 })), + ); +}); + +test("manufacturer refresh updates are narrowly allowlisted", () => { + for (const sql of prepareManufacturerCatalogSqls({ manufacturerId: 13 })) { + assert.doesNotThrow(() => + assertAllowedWriteSql(sql, { operation: "catalog-maker-refresh-manufacturer" }), + ); + } +}); + +test("random update remains blocked", () => { + assert.throws( + () => + assertAllowedWriteSql("UPDATE ps_product SET price = 0 WHERE id_product = 1", { + operation: "catalog-maker-refresh-manufacturer", + }), + /allowlist/, + ); +}); diff --git a/test/sql-readonly.test.js b/test/sql-readonly.test.js new file mode 100644 index 0000000..b2d7512 --- /dev/null +++ b/test/sql-readonly.test.js @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { assertReadOnlySql } from "../src/lib/sql-readonly.js"; + +test("allows select statements", () => { + assert.doesNotThrow(() => assertReadOnlySql("SELECT * FROM ps_product LIMIT 1")); +}); + +test("blocks update statements", () => { + assert.throws( + () => assertReadOnlySql("UPDATE ps_product SET price = 0"), + /Blocked non-read-only SQL statement: UPDATE/, + ); +}); + +test("blocks insert statements after a leading comment", () => { + assert.throws( + () => assertReadOnlySql("-- old macro query\nINSERT INTO ps_product VALUES (1)"), + /Blocked non-read-only SQL statement: INSERT/, + ); +});