From 2e7b7ae17b740a6c8be549da777dd5c9b5680221 Mon Sep 17 00:00:00 2001 From: yashin4112 Date: Mon, 20 Apr 2026 17:20:46 +0530 Subject: [PATCH 01/48] delta migration functionality added --- api/package-lock.json | 1327 +++++++++-------- api/package.json | 6 +- api/src/controllers/migration.controller.ts | 8 +- .../projects.contentMapper.controller.ts | 28 +- api/src/models/EntryMapper.ts | 60 + api/src/models/FieldMapper.ts | 22 +- api/src/models/contentTypesMapper-lowdb.ts | 27 +- api/src/models/project-lowdb.ts | 1 + api/src/models/uidMapper.ts | 51 + api/src/routes/contentMapper.routes.ts | 20 +- api/src/routes/migration.routes.ts | 13 + api/src/services/contentMapper.service.ts | 474 +++++- api/src/services/migration.service.ts | 72 +- api/src/services/projects.service.ts | 7 +- api/src/services/runCli.service.ts | 71 +- api/src/services/updateEntryCli.service.ts | 252 ++++ api/src/utils/asset-update.utils.ts | 343 +++++ api/src/utils/content-type-checker.utils.ts | 122 ++ api/src/utils/entry-duplicate.utils.ts | 30 + api/src/utils/entry-update-script.cjs | 184 +++ api/src/utils/entry-update.utils.ts | 192 +++ api/src/utils/field-attacher.utils.ts | 23 +- api/src/utils/package.json | 10 + package.json | 2 +- ui/package.json | 2 +- .../ContentMapper/contentMapper.interface.ts | 13 + .../components/ContentMapper/entryMapper.tsx | 432 ++++++ ui/src/components/ContentMapper/index.scss | 84 +- ui/src/components/ContentMapper/index.tsx | 25 +- .../LegacyCms/Actions/LoadUploadFile.tsx | 62 +- ui/src/components/LegacyCms/legacyCms.scss | 27 + .../LogScreen/MigrationLogViewer.tsx | 21 +- .../components/MigrationFlowHeader/index.tsx | 41 +- .../HorizontalStepper/HorizontalStepper.tsx | 86 +- ui/src/context/app/app.interface.ts | 4 + ui/src/pages/Migration/index.tsx | 43 +- ui/src/services/api/migration.service.ts | 50 + upload-api/migration-aem/package-lock.json | 19 + upload-api/migration-contentful/index.js | 5 +- .../libs/createInitialMapper.js | 7 +- .../libs/extractEntries.js | 60 + upload-api/migration-drupal/index.js | 4 +- .../libs/createInitialMapper.js | 4 +- .../migration-drupal/libs/extractEntries.js | 104 ++ upload-api/migration-sitecore/index.js | 5 +- .../migration-sitecore/libs/extractEntries.js | 211 +++ .../libs/extractEntries.ts | 190 +++ upload-api/package.json | 2 +- upload-api/src/config/index.json | 29 + upload-api/src/config/index.ts | 27 - upload-api/src/controllers/sitecore/index.ts | 4 +- upload-api/src/helper/index.ts | 35 +- upload-api/src/routes/index.ts | 5 +- upload-api/src/services/aws/client.ts | 3 +- upload-api/src/services/fileProcessing.ts | 4 +- 55 files changed, 4146 insertions(+), 807 deletions(-) create mode 100644 api/src/models/EntryMapper.ts create mode 100644 api/src/models/uidMapper.ts create mode 100644 api/src/services/updateEntryCli.service.ts create mode 100644 api/src/utils/asset-update.utils.ts create mode 100644 api/src/utils/content-type-checker.utils.ts create mode 100644 api/src/utils/entry-duplicate.utils.ts create mode 100644 api/src/utils/entry-update-script.cjs create mode 100644 api/src/utils/entry-update.utils.ts create mode 100644 api/src/utils/package.json create mode 100644 ui/src/components/ContentMapper/entryMapper.tsx create mode 100644 upload-api/migration-contentful/libs/extractEntries.js create mode 100644 upload-api/migration-drupal/libs/extractEntries.js create mode 100644 upload-api/migration-sitecore/libs/extractEntries.js create mode 100644 upload-api/migration-wordpress/libs/extractEntries.ts create mode 100644 upload-api/src/config/index.json delete mode 100644 upload-api/src/config/index.ts diff --git a/api/package-lock.json b/api/package-lock.json index 943e44a8c..8cd0a9007 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@contentstack/cli": "^1.58.0", - "@contentstack/cli-utilities": "^1.17.1", + "@contentstack/cli": "^1.60.1", + "@contentstack/cli-utilities": "^1.18.1", "@contentstack/json-rte-serializer": "^3.0.5", "@contentstack/marketplace-sdk": "^1.5.0", "@emnapi/core": "1.9.1", @@ -336,29 +336,29 @@ } }, "node_modules/@contentstack/cli": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@contentstack/cli/-/cli-1.60.0.tgz", - "integrity": "sha512-Zr5Dl93NO4CpAtpX+pudMETg4r7TePUCi2wZWaukgaxQ8WfigjewM04yAy07nV/RFKKcstI3EBOpWkXee/X1jA==", + "version": "1.60.1", + "resolved": "https://registry.npmjs.org/@contentstack/cli/-/cli-1.60.1.tgz", + "integrity": "sha512-tH8WJ1khzaHjLuvtGAAeAoqPOzT5EkTM20dLx3sOKvm/4Izj+gKffcbsEwtlVtI45UO5dJMiGDHLYnxGij9Rgw==", "license": "MIT", "dependencies": { - "@contentstack/cli-audit": "~1.19.0", + "@contentstack/cli-audit": "~1.19.1", "@contentstack/cli-auth": "~1.8.0", "@contentstack/cli-cm-bootstrap": "~1.19.0", - "@contentstack/cli-cm-branches": "~1.7.0", - "@contentstack/cli-cm-bulk-publish": "~1.11.0", - "@contentstack/cli-cm-clone": "~1.21.0", + "@contentstack/cli-cm-branches": "~1.7.1", + "@contentstack/cli-cm-bulk-publish": "~1.11.1", + "@contentstack/cli-cm-clone": "~1.21.1", "@contentstack/cli-cm-export": "~1.24.0", "@contentstack/cli-cm-export-to-csv": "~1.12.0", "@contentstack/cli-cm-import": "~1.32.0", - "@contentstack/cli-cm-import-setup": "~1.8.0", + "@contentstack/cli-cm-import-setup": "~1.8.1", "@contentstack/cli-cm-migrate-rte": "~1.6.4", "@contentstack/cli-cm-seed": "~1.15.0", "@contentstack/cli-command": "~1.8.0", - "@contentstack/cli-config": "~1.20.0", - "@contentstack/cli-launch": "^1.9.6", + "@contentstack/cli-config": "~1.20.1", + "@contentstack/cli-launch": "^1.9.7", "@contentstack/cli-migration": "~1.12.0", - "@contentstack/cli-utilities": "~1.18.0", - "@contentstack/cli-variants": "~1.4.0", + "@contentstack/cli-utilities": "~1.18.1", + "@contentstack/cli-variants": "~1.4.1", "@contentstack/management": "~1.27.5", "@oclif/core": "^4.8.3", "@oclif/plugin-help": "^6.2.28", @@ -383,9 +383,9 @@ } }, "node_modules/@contentstack/cli-audit": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/@contentstack/cli-audit/-/cli-audit-1.19.0.tgz", - "integrity": "sha512-NwET9cBeCmM5tM3faJ8wG4YMX9JrGYo7lYE7WQhmVUOhC/7bbYKPpWb6Jmy3JMvzP9YSwmSrztEUdwotB4F6WQ==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/@contentstack/cli-audit/-/cli-audit-1.19.1.tgz", + "integrity": "sha512-k3bu/NLXGu7/ntMtWh/kd4smytQ44Z4wlixCajoPxKR2k1A/4OaZD6n1WQ7TcJ7biosNU9Pl83PO4oKMdxrEfA==", "license": "MIT", "dependencies": { "@contentstack/cli-command": "~1.8.0", @@ -396,7 +396,7 @@ "chalk": "^4.1.2", "fast-csv": "^4.3.6", "fs-extra": "^11.3.0", - "lodash": "^4.17.23", + "lodash": "4.18.1", "uuid": "^9.0.1", "winston": "^3.17.0" }, @@ -456,9 +456,9 @@ } }, "node_modules/@contentstack/cli-cm-branches": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-branches/-/cli-cm-branches-1.7.0.tgz", - "integrity": "sha512-5uCqqzB1mGlHX8Ac3+3bteUuQ0CNBHmAZ6mRj9D6/DPTW7spbOHkKWksMyKuSrbkOTj57otjZUpcWvZobuW5kg==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-branches/-/cli-cm-branches-1.7.1.tgz", + "integrity": "sha512-IUU/Hs7/LXH/vGRkqGf+CQhDSMFLLa0KqYLOi+LneBU/irrQSC6ue+/oaGVJw4i59Wy/rV5U3buCreAKlSzd2Q==", "license": "MIT", "dependencies": { "@contentstack/cli-command": "~1.8.0", @@ -467,16 +467,16 @@ "@oclif/plugin-help": "^6.2.28", "chalk": "^4.1.2", "just-diff": "^6.0.2", - "lodash": "^4.17.23" + "lodash": "4.18.1" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@contentstack/cli-cm-bulk-publish": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-bulk-publish/-/cli-cm-bulk-publish-1.11.0.tgz", - "integrity": "sha512-oQd1se/3qa18exmuIFvBL+zH3wuSWHuS4eV1nAyYA6pVl8HU4hqcCj1ygHdMdUx7ZLckESgZjeOfeglbUJg3zQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-bulk-publish/-/cli-cm-bulk-publish-1.11.1.tgz", + "integrity": "sha512-0mjpOfzSMX/vJFXiLhQwgefCuVuiqBH2e/8BR2ks4a6/8ISquIcACkJ7Zosh+8LjiAxSOSJtCtykn6nJIaV2EA==", "license": "MIT", "dependencies": { "@contentstack/cli-command": "~1.8.0", @@ -487,7 +487,7 @@ "chalk": "^4.1.2", "dotenv": "^16.5.0", "inquirer": "8.2.7", - "lodash": "^4.17.23", + "lodash": "4.18.1", "winston": "^3.17.0" }, "engines": { @@ -495,9 +495,9 @@ } }, "node_modules/@contentstack/cli-cm-clone": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-clone/-/cli-cm-clone-1.21.0.tgz", - "integrity": "sha512-StImHRgX+9iGjdnFOM3AAdU7+ccOrMk3mr1p0oaIlqPLu5QDrMqNZialhuV0BjZ0BXPhLm93bpXuEKg0cCK+5w==", + "version": "1.21.1", + "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-clone/-/cli-cm-clone-1.21.1.tgz", + "integrity": "sha512-s/UJhEtYqjPKhLbys0eVoDz+yYgESSki5Z+4jQr/PaGcoW3GM4hDro1d+c/rG3/KhKy4VdhnZZcPVDdezRSsvQ==", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", @@ -509,7 +509,7 @@ "@oclif/plugin-help": "^6.2.28", "chalk": "^4.1.2", "inquirer": "8.2.7", - "lodash": "^4.17.23", + "lodash": "4.18.1", "merge": "^2.1.1", "ora": "^5.4.1", "prompt": "^1.3.0", @@ -520,9 +520,9 @@ } }, "node_modules/@contentstack/cli-cm-export": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-export/-/cli-cm-export-1.24.0.tgz", - "integrity": "sha512-eRdSt3z08W7MNH1CnXhZSwJ4o/jodYIBGv+f1G3Zs3ZrthNToDQBR7018gmVjjoZYXIRMvaHo8Nw8eZgbYtchA==", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-export/-/cli-cm-export-1.24.1.tgz", + "integrity": "sha512-zTaun28JcIjT88NCi+p1gZabZVkJO6bQLOBs+QGHPOUxRBn5t++AYOuomm7iA1ZJAzsfMN5FF1lWRuRZ2qPf2A==", "license": "MIT", "dependencies": { "@contentstack/cli-command": "~1.8.0", @@ -533,7 +533,7 @@ "big-json": "^3.2.0", "bluebird": "^3.7.2", "chalk": "^4.1.2", - "lodash": "^4.17.23", + "lodash": "4.18.1", "merge": "^2.1.1", "mkdirp": "^1.0.4", "progress-stream": "^2.0.0", @@ -576,9 +576,9 @@ } }, "node_modules/@contentstack/cli-cm-import": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-import/-/cli-cm-import-1.32.0.tgz", - "integrity": "sha512-S+WVN+b5kMa+VaaZSM0oaaYsR3SGTOjlJlvzeFe0Kykkxau6DKbEMvDE2esmsFn21HpUxjLb58//7KS5BhedhQ==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-import/-/cli-cm-import-1.32.1.tgz", + "integrity": "sha512-WPdKpFq2iYAoCXrMp9ZlahgzhuHbovQqmQSTxvxCBoQZPt8YX2jr8fu6wgzGEhvI8ra4Tpx/br0n2vEMBbSQSg==", "license": "MIT", "dependencies": { "@contentstack/cli-audit": "~1.19.0", @@ -591,7 +591,7 @@ "chalk": "^4.1.2", "debug": "^4.4.3", "fs-extra": "^11.3.3", - "lodash": "^4.17.23", + "lodash": "4.18.1", "marked": "^4.3.0", "merge": "^2.1.1", "mkdirp": "^1.0.4", @@ -604,9 +604,9 @@ } }, "node_modules/@contentstack/cli-cm-import-setup": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-import-setup/-/cli-cm-import-setup-1.8.0.tgz", - "integrity": "sha512-h4vTeFNfKF09NTbJmki1p4EYEh9KxMJn3G7U3CJ7IurcxI6ccNxJuEFhsCHYGwX+UwmZ8TcJMqmbsHwAMQjP0Q==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-import-setup/-/cli-cm-import-setup-1.8.1.tgz", + "integrity": "sha512-xffwa0MXGH8dk+FGOefETnv2LOOyAwKPwG9+QLoLqLxoCRadiMKsTcaw8ejZcB7i1NXbpEp4aWtOkBLOwEc9KA==", "license": "MIT", "dependencies": { "@contentstack/cli-command": "~1.8.0", @@ -615,7 +615,7 @@ "big-json": "^3.2.0", "chalk": "^4.1.2", "fs-extra": "^11.3.0", - "lodash": "^4.17.23", + "lodash": "4.18.1", "merge": "^2.1.1", "mkdirp": "^1.0.4", "winston": "^3.17.0" @@ -1019,9 +1019,9 @@ } }, "node_modules/@contentstack/cli-config": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/@contentstack/cli-config/-/cli-config-1.20.0.tgz", - "integrity": "sha512-WURtexv9+lQWNPriWvaakHS+9SmGoO3Aq/zLu5SNt2k2Mj+awJwUehYcuZIVflTVzXlUQvxtU0Bn/mCpX2jkmQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/@contentstack/cli-config/-/cli-config-1.20.1.tgz", + "integrity": "sha512-V7t2Nk5BaP1RnTn9gcd3sOAG/r0dagRD1mEIUd9qgxzQuA2f7Uwap09C4sKLP7IKLtAx8tBlFfrzuOoqr7u8sg==", "license": "MIT", "dependencies": { "@contentstack/cli-command": "~1.8.0", @@ -1029,7 +1029,7 @@ "@contentstack/utils": "~1.7.0", "@oclif/core": "^4.8.3", "@oclif/plugin-help": "^6.2.28", - "lodash": "^4.17.23" + "lodash": "^4.18.1" }, "engines": { "node": ">=14.0.0" @@ -1095,9 +1095,9 @@ } }, "node_modules/@contentstack/cli-utilities": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@contentstack/cli-utilities/-/cli-utilities-1.18.0.tgz", - "integrity": "sha512-JEm6ElIegkcibHUEjRF+Id9529bAXBqkf0Givs9GL5CZE7d8eiLzFCUnlb51VZynk1g5+SmjY5nSeghrmcVSPg==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@contentstack/cli-utilities/-/cli-utilities-1.18.1.tgz", + "integrity": "sha512-1ymPu5HbOXFdDJHJFiwtT1yVNpmDOgMH8qqCeP3kjS7ED1+rz7Q3cWPnJC9FlUfvFeOAyJaJPPQCiYd0lgujtw==", "license": "MIT", "dependencies": { "@contentstack/management": "~1.27.5", @@ -1116,7 +1116,7 @@ "inquirer-search-list": "^1.2.6", "js-yaml": "^4.1.1", "klona": "^2.0.6", - "lodash": "^4.17.23", + "lodash": "^4.18.1", "mkdirp": "^1.0.4", "open": "^8.4.2", "ora": "^5.4.1", @@ -1144,15 +1144,15 @@ } }, "node_modules/@contentstack/cli-variants": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@contentstack/cli-variants/-/cli-variants-1.4.0.tgz", - "integrity": "sha512-avYWCteVVfChz2m/r6VzLAeRKboJjwZVZuQUEONJb0wOeSlFfUC/koYbUaoAtN8v+0vbVx4Z/EkQAaTJIMDbMg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@contentstack/cli-variants/-/cli-variants-1.4.1.tgz", + "integrity": "sha512-iLl1QFeVLIxJGRSbBoTXp3OyfujBj74zj47yzQKo6eSUMBF4Eelb75zFrQlx2gI3UQY9hRX1KnAtqcfRk7jGmg==", "license": "MIT", "dependencies": { "@contentstack/cli-utilities": "~1.18.0", "@oclif/core": "^4.3.0", "@oclif/plugin-help": "^6.2.28", - "lodash": "^4.17.23", + "lodash": "4.18.1", "mkdirp": "^1.0.4", "winston": "^3.17.0" } @@ -1236,15 +1236,21 @@ } }, "node_modules/@contentstack/marketplace-sdk": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@contentstack/marketplace-sdk/-/marketplace-sdk-1.5.0.tgz", - "integrity": "sha512-n2USMwswXBDtmVOg0t5FUks8X0d49u0UDFSrwxti09X/SONeP0P8wSqIDCjoB2gGRQc6fg/Fg2YPRvejUWeR4A==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@contentstack/marketplace-sdk/-/marketplace-sdk-1.5.1.tgz", + "integrity": "sha512-XoQODTWZ4cQeo7iIAcYcYLX9bSHvgeF1J230GTM2dVhN3w9aTylZ35zZttvsa76fDZWgRmZBO5AE99dVVq7xyA==", "license": "MIT", "dependencies": { - "@contentstack/utils": "^1.6.3", - "axios": "^1.13.2" + "@contentstack/utils": "^1.9.1", + "axios": "^1.15.0" } }, + "node_modules/@contentstack/marketplace-sdk/node_modules/@contentstack/utils": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@contentstack/utils/-/utils-1.9.1.tgz", + "integrity": "sha512-THZM0rNuq0uOSKkKnvzp8lsPDvvdKIvJIcMa9JBv4foL9rC8RWkWffa2yMyb+9m/5HZrdAmpEWdubkGwARa8WQ==", + "license": "MIT" + }, "node_modules/@contentstack/utils": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@contentstack/utils/-/utils-1.7.1.tgz", @@ -1581,9 +1587,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -1598,9 +1604,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -1615,9 +1621,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -1632,9 +1638,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -1649,9 +1655,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -1666,9 +1672,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -1683,9 +1689,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -1700,9 +1706,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -1717,9 +1723,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -1734,9 +1740,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -1751,9 +1757,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -1768,9 +1774,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -1785,9 +1791,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -1802,9 +1808,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -1819,9 +1825,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -1836,9 +1842,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -1853,9 +1859,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -1870,9 +1876,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -1887,9 +1893,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -1904,9 +1910,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -1921,9 +1927,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -1938,9 +1944,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -1955,9 +1961,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -1972,9 +1978,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -1989,9 +1995,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -2006,9 +2012,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -2614,9 +2620,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -2684,9 +2690,9 @@ } }, "node_modules/@oclif/core": { - "version": "4.10.3", - "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.10.3.tgz", - "integrity": "sha512-0mD8vcrrX5uRsxzvI8tbWmSVGngvZA/Qo6O0ZGvLPAWEauSf5GFniwgirhY0SkszuHwu0S1J1ivj/jHmqtIDuA==", + "version": "4.10.5", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.10.5.tgz", + "integrity": "sha512-qcdCF7NrdWPfme6Kr34wwljRCXbCVpL1WVxiNy0Ep6vbWKjxAjFQwuhqkoyL0yjI+KdwtLcOCGn5z2yzdijc8w==", "license": "MIT", "dependencies": { "ansi-escapes": "^4.3.2", @@ -2699,7 +2705,7 @@ "indent-string": "^4.0.0", "is-wsl": "^2.2.0", "lilconfig": "^3.1.3", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "semver": "^7.7.3", "string-width": "^4.2.3", "supports-color": "^8", @@ -2713,9 +2719,9 @@ } }, "node_modules/@oclif/plugin-help": { - "version": "6.2.41", - "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-6.2.41.tgz", - "integrity": "sha512-oHqpm9a8NnLY9J5yIA+znchB2QCBqDUu5n7XINdZwfbhO6WOUZ2ANww6QN7crhvAKgpN5HK/ELN8Hy96kgLUuA==", + "version": "6.2.44", + "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-6.2.44.tgz", + "integrity": "sha512-x03Se2LtlOOlGfTuuubt5C4Z8NHeR4zKXtVnfycuLU+2VOMu2WpsGy9nbs3nYuInuvsIY1BizjVaTjUz060Sig==", "license": "MIT", "dependencies": { "@oclif/core": "^4" @@ -2725,13 +2731,13 @@ } }, "node_modules/@oclif/plugin-not-found": { - "version": "3.2.78", - "resolved": "https://registry.npmjs.org/@oclif/plugin-not-found/-/plugin-not-found-3.2.78.tgz", - "integrity": "sha512-wFg7rUYUxYsBMl0fEBHOJ+GAO0/3Nwpn4scmkqV3IQdch7+N1ke8qFOzLZal0kpa0wt+Tr/aJvaT8iYccPGZDQ==", + "version": "3.2.80", + "resolved": "https://registry.npmjs.org/@oclif/plugin-not-found/-/plugin-not-found-3.2.80.tgz", + "integrity": "sha512-yTLjWvR1r/Rd/cO2LxHdMCDoL5sQhBYRUcOMCmxZtWVWhx4rAZ8KVUPDVsb+SvjJDV5ADTDBgt1H52fFx7YWqg==", "license": "MIT", "dependencies": { "@inquirer/prompts": "^7.10.1", - "@oclif/core": "^4.10.3", + "@oclif/core": "^4.10.5", "ansis": "^3.17.0", "fast-levenshtein": "^3.0.0" }, @@ -2740,9 +2746,9 @@ } }, "node_modules/@oclif/plugin-plugins": { - "version": "5.4.59", - "resolved": "https://registry.npmjs.org/@oclif/plugin-plugins/-/plugin-plugins-5.4.59.tgz", - "integrity": "sha512-W/F3vNwhC3BHmn1o4g92H8kY4rYw9RsgVRm+GDulZg0XqSoseJYCMQell6ajTj8xljrrG0dZSTuEfc4ETwC2VA==", + "version": "5.4.61", + "resolved": "https://registry.npmjs.org/@oclif/plugin-plugins/-/plugin-plugins-5.4.61.tgz", + "integrity": "sha512-FsXYLdXJWucrAzDQ3Q2G/mFGeTaUIsL4o76ayG6qNaF8iq1n2O3YnniCl90RLphJmty2ScGTv2YIniOHt4HHjw==", "license": "MIT", "dependencies": { "@oclif/core": "^4.8.0", @@ -2812,9 +2818,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.126.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz", + "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==", "dev": true, "license": "MIT", "funding": { @@ -2832,9 +2838,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", "cpu": [ "arm64" ], @@ -2849,9 +2855,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==", "cpu": [ "arm64" ], @@ -2866,9 +2872,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==", "cpu": [ "x64" ], @@ -2883,9 +2889,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz", + "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==", "cpu": [ "x64" ], @@ -2900,9 +2906,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz", + "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==", "cpu": [ "arm" ], @@ -2917,9 +2923,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==", "cpu": [ "arm64" ], @@ -2934,9 +2940,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==", "cpu": [ "arm64" ], @@ -2951,9 +2957,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==", "cpu": [ "ppc64" ], @@ -2968,9 +2974,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==", "cpu": [ "s390x" ], @@ -2985,9 +2991,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz", + "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==", "cpu": [ "x64" ], @@ -3002,9 +3008,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz", + "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==", "cpu": [ "x64" ], @@ -3019,9 +3025,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz", + "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==", "cpu": [ "arm64" ], @@ -3036,9 +3042,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz", + "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==", "cpu": [ "wasm32" ], @@ -3046,16 +3052,52 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==", "cpu": [ "arm64" ], @@ -3070,9 +3112,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz", + "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==", "cpu": [ "x64" ], @@ -3087,9 +3129,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz", + "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==", "dev": true, "license": "MIT" }, @@ -3212,9 +3254,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", "cpu": [ "arm" ], @@ -3225,9 +3267,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", "cpu": [ "arm64" ], @@ -3238,9 +3280,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", "cpu": [ "arm64" ], @@ -3251,9 +3293,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", "cpu": [ "x64" ], @@ -3264,9 +3306,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", "cpu": [ "arm64" ], @@ -3277,9 +3319,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", "cpu": [ "x64" ], @@ -3290,9 +3332,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", "cpu": [ "arm" ], @@ -3303,9 +3345,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", "cpu": [ "arm" ], @@ -3316,9 +3358,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", "cpu": [ "arm64" ], @@ -3329,9 +3371,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", "cpu": [ "arm64" ], @@ -3342,9 +3384,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", "cpu": [ "loong64" ], @@ -3355,9 +3397,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", "cpu": [ "loong64" ], @@ -3368,9 +3410,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", "cpu": [ "ppc64" ], @@ -3381,9 +3423,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", "cpu": [ "ppc64" ], @@ -3394,9 +3436,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", "cpu": [ "riscv64" ], @@ -3407,9 +3449,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", "cpu": [ "riscv64" ], @@ -3420,9 +3462,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", "cpu": [ "s390x" ], @@ -3433,9 +3475,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", "cpu": [ "x64" ], @@ -3446,9 +3488,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", "cpu": [ "x64" ], @@ -3459,9 +3501,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", "cpu": [ "x64" ], @@ -3472,9 +3514,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", "cpu": [ "arm64" ], @@ -3485,9 +3527,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", "cpu": [ "arm64" ], @@ -3498,9 +3540,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", "cpu": [ "ia32" ], @@ -3511,9 +3553,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", "cpu": [ "x64" ], @@ -3524,9 +3566,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", "cpu": [ "x64" ], @@ -3566,18 +3608,18 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", - "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", + "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1" } }, "node_modules/@sinonjs/samsam": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-9.0.3.tgz", - "integrity": "sha512-ZgYY7Dc2RW+OUdnZ1DEHg00lhRt+9BjymPKHog4PRFzr1U3MbK57+djmscWyKxzO1qfunHqs4N45WWyKIFKpiQ==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", @@ -3755,9 +3797,9 @@ } }, "node_modules/@types/express-session": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-GbypG0bog68UbOq2tSAp7SclvCUm3ha1uDi58OPRGK1NfRvCIu7Gz0M7fTGtpNG1T9a29GpuurQj9zEcT/lMXQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3877,9 +3919,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.37", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", - "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3896,7 +3938,7 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/qs": { @@ -3915,7 +3957,7 @@ "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -4282,14 +4324,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", - "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", + "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.4", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -4303,8 +4345,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.2", - "vitest": "4.1.2" + "@vitest/browser": "4.1.4", + "vitest": "4.1.4" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4313,16 +4355,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4330,47 +4372,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.2", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/mocker/node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { @@ -4381,13 +4386,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.4", "pathe": "^2.0.3" }, "funding": { @@ -4395,14 +4400,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4411,9 +4416,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "dev": true, "license": "MIT", "funding": { @@ -4421,13 +4426,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4451,9 +4456,9 @@ } }, "node_modules/@wordpress/block-serialization-default-parser": { - "version": "5.42.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-5.42.0.tgz", - "integrity": "sha512-XcX6gOeQOuG0RrUqJV1dadPBUi77uhLhpGfQH/s8vmAEGSWqgJAbjWwUKD8RP6wCGY+IE3Gayd5zu48aVRlB4A==", + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-5.44.0.tgz", + "integrity": "sha512-XaVZyQskiI/1Ysq9r2VH4sF017mj3Cl1jOI8IXdpKykOe3YZ6WXPN7FwglVJj5y9Qhw0RgpCObXAORI0PTqDpg==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -4847,9 +4852,9 @@ } }, "node_modules/@wordpress/shortcode": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.42.0.tgz", - "integrity": "sha512-vaXEGjis5IqvPtSMYZgrT2zg5HwjePrs5fgWCwYfX5r/uiizfkeOSedpTBSH/FLpQQTMMeFsr22DLcuF0qdyeA==", + "version": "4.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.44.0.tgz", + "integrity": "sha512-Vh22BIujZdeeoKYsJ3qEineLeqN/5kURcg9OBIWGBCkKAiCktFcdXUsvaehjZ7VDKWfmNP/Hf9SP/Dt9Gyz44w==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -4933,9 +4938,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.12", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", - "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -5006,9 +5011,9 @@ } }, "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", "license": "MIT", "engines": { "node": ">=12.0" @@ -5359,9 +5364,9 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz", + "integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -5648,14 +5653,14 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -6357,7 +6362,7 @@ "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", - "yaml": "^2.8.3" + "yaml": "^1.10.0" }, "engines": { "node": ">=10" @@ -6476,7 +6481,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/csv": { @@ -7020,7 +7025,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "iconv-lite": "^0.6.2" @@ -7055,7 +7060,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7154,9 +7159,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.2", @@ -7309,9 +7314,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7322,32 +7327,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { @@ -7668,12 +7673,12 @@ } }, "node_modules/express-validator": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", - "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz", + "integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==", "license": "MIT", "dependencies": { - "lodash": "^4.17.21", + "lodash": "^4.18.1", "validator": "~13.15.23" }, "engines": { @@ -8111,9 +8116,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -8442,9 +8447,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -8731,9 +8736,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10344,7 +10349,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -11841,9 +11845,9 @@ } }, "node_modules/mysql2": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz", - "integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==", + "version": "3.22.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.1.tgz", + "integrity": "sha512-48+9UXehKyxxiP2pqCxUq+MSFvX+v41jwsSpFDQO/jAoFuAELutBGJUhWJnDbe82/OBlIhSBMC82WeonmznT/Q==", "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.2", @@ -14928,9 +14932,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -15070,9 +15074,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -15328,9 +15332,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -15420,7 +15424,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -15444,7 +15448,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -15709,11 +15713,12 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -15807,14 +15812,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz", + "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.126.0", + "@rolldown/pluginutils": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" @@ -15823,27 +15828,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", + "@rolldown/binding-darwin-x64": "1.0.0-rc.16", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" } }, "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -15856,31 +15861,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" } }, @@ -15962,14 +15967,14 @@ "license": "0BSD" }, "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, @@ -16064,7 +16069,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -16278,13 +16283,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -16350,16 +16355,15 @@ } }, "node_modules/sinon": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.3.tgz", - "integrity": "sha512-0x8TQFr8EjADhSME01u1ZK31yv2+bd6Z5NrBCHVM+n4qL1wFqbxftmeyi3bwlr49FbbzRfrqSFOpyHCOh/YmYA==", + "version": "21.1.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.1.2.tgz", + "integrity": "sha512-FS6mN+/bx7e2ajpXkEmOcWB6xBzWiuNoAQT18/+a20SS4U7FSYl8Ms7N6VTUxN/1JAjkx7aXp+THMC8xdpp0gA==", "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^15.1.1", - "@sinonjs/samsam": "^9.0.3", - "diff": "^8.0.3", - "supports-color": "^7.2.0" + "@sinonjs/fake-timers": "^15.3.2", + "@sinonjs/samsam": "^10.0.2", + "diff": "^8.0.4" }, "funding": { "type": "opencollective", @@ -16375,18 +16379,6 @@ "node": ">=0.3.1" } }, - "node_modules/sinon/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -16702,9 +16694,9 @@ } }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -17017,7 +17009,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, "license": "MIT" }, "node_modules/thirty-two": { @@ -17065,9 +17056,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", "dev": true, "license": "MIT", "engines": { @@ -17075,13 +17066,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -17417,7 +17408,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -17446,12 +17436,12 @@ } }, "node_modules/undici": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", - "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.1.0.tgz", + "integrity": "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==", "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { @@ -17605,9 +17595,9 @@ } }, "node_modules/validator": { - "version": "13.15.26", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", - "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -17622,18 +17612,145 @@ "node": ">= 0.8" } }, - "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz", + "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.16", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -17650,7 +17767,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -17700,86 +17817,22 @@ } } }, - "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "node_modules/vitest/node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, + "license": "ISC", + "optional": true, + "peer": true, "bin": { - "vitest": "vitest.mjs" + "yaml": "bin.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 14.6" }, "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/void-elements": { @@ -18163,19 +18216,13 @@ } }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" + "node": ">= 6" } }, "node_modules/yargs": { diff --git a/api/package.json b/api/package.json index 4f98711e3..ce1ce4b96 100644 --- a/api/package.json +++ b/api/package.json @@ -30,8 +30,8 @@ }, "homepage": "https://github.com/contentstack/migration-v2.git#readme", "dependencies": { - "@contentstack/cli": "^1.58.0", - "@contentstack/cli-utilities": "^1.17.1", + "@contentstack/cli": "^1.60.1", + "@contentstack/cli-utilities": "^1.18.1", "@contentstack/json-rte-serializer": "^3.0.5", "@contentstack/marketplace-sdk": "^1.5.0", "@wordpress/block-serialization-default-parser": "^5.39.0", @@ -105,4 +105,4 @@ "undici": ">=7.24.0" }, "keywords": [] -} +} \ No newline at end of file diff --git a/api/src/controllers/migration.controller.ts b/api/src/controllers/migration.controller.ts index f44d034b6..7fe65c13a 100644 --- a/api/src/controllers/migration.controller.ts +++ b/api/src/controllers/migration.controller.ts @@ -68,6 +68,11 @@ const saveMappedLocales = async (req: Request, res: Response): Promise => res.status(200).json(resp); } +const restartMigration = async (req: Request, res: Response): Promise => { + const resp = await migrationService.restartMigration(req); + res.status(200).json(resp); +} + export const migrationController = { createTestStack, deleteTestStack, @@ -76,5 +81,6 @@ export const migrationController = { getLogs, saveLocales, saveMappedLocales, - getAuditData + getAuditData, + restartMigration }; diff --git a/api/src/controllers/projects.contentMapper.controller.ts b/api/src/controllers/projects.contentMapper.controller.ts index 8b9ca120d..73c020564 100644 --- a/api/src/controllers/projects.contentMapper.controller.ts +++ b/api/src/controllers/projects.contentMapper.controller.ts @@ -164,6 +164,30 @@ const updateContentMapper = async (req: Request, res: Response): Promise = res.status(project.status).json(project); } +/** + * Retrieves entry mapping for a content type. + * + * @param req - The request object. + * @param res - The response object. + * @returns A Promise that resolves to void. + */ +const getEntryMapping = async (req: Request, res: Response): Promise => { + const resp = await contentMapperService.getEntryMapping(req); + res.status(resp?.status || 200).json(resp); +}; + +/** + * Updates the status of entries. + * + * @param req - The request object. + * @param res - The response object. + * @returns A Promise that resolves to void. + */ +const updateEntryStatus = async (req: Request, res: Response): Promise => { + const resp = await contentMapperService.updateEntryStatus(req); + res.status(resp?.status || 200).json(resp); +}; + export const contentMapperController = { getContentTypes, getFieldMapping, @@ -177,5 +201,7 @@ export const contentMapperController = { removeContentMapper, updateContentMapper, getExistingGlobalFields, - getSingleGlobalField + getSingleGlobalField, + getEntryMapping, + updateEntryStatus }; diff --git a/api/src/models/EntryMapper.ts b/api/src/models/EntryMapper.ts new file mode 100644 index 000000000..985dd5409 --- /dev/null +++ b/api/src/models/EntryMapper.ts @@ -0,0 +1,60 @@ +import { JSONFile } from "lowdb/node"; +import LowWithLodash from "../utils/lowdb-lodash.utils.js"; +import path from "path"; +import fs from 'node:fs'; + +/** + * Represents the advanced configuration options for a field mapper. + */ +export interface Advanced { + validationRegex: string; + mandatory: boolean; + multiple: boolean; + unique: boolean; + nonLocalizable: boolean; + embedObject: boolean; + embedObjects: any; + minChars: string; + maxChars: number; + default_value: string; + description: string; + validationErrorMessage: string; + options: any[]; +} + +/** + * Represents an entry mapper object. + */ +export interface EntryMapper { + entry_mapper: { + id: string; + projectId: string; + contentTypeId: string; + contentTypeUid: string; + entryName: string; + otherCmsEntryUid: string; + isUpdate: boolean; + contentstackEntryUid: string; + isDuplicateEntry: boolean; + }[]; +} + +const defaultData: EntryMapper = { entry_mapper: [] }; + +/** + * Creates and returns a database instance for the field mapper for a specific project. + * @param projectId - The unique identifier of the project + * @returns The database instance for the field mapper + */ +const getEntryMapperDb = (projectId: string, iteration: number) => { + fs.mkdirSync(path.join(process.cwd(), "database", projectId, iteration.toString()), { recursive: true }); + const db = new LowWithLodash( + new JSONFile( + path.join(process.cwd(), "database", projectId, iteration.toString(), 'entry-mapper.json') + ), + defaultData + ); + return db; +}; + +export default getEntryMapperDb; diff --git a/api/src/models/FieldMapper.ts b/api/src/models/FieldMapper.ts index 5be7d0528..5f994ef32 100644 --- a/api/src/models/FieldMapper.ts +++ b/api/src/models/FieldMapper.ts @@ -1,6 +1,7 @@ import { JSONFile } from "lowdb/node"; import LowWithLodash from "../utils/lowdb-lodash.utils.js"; import path from "path"; +import fs from 'node:fs'; /** * Represents the advanced configuration options for a field mapper. @@ -46,11 +47,20 @@ interface FieldMapper { const defaultData: FieldMapper = { field_mapper: [] }; /** - * Represents the database instance for the FieldMapper model. + * Creates and returns a database instance for the field mapper for a specific project. + * @param projectId - The unique identifier of the project + * @returns The database instance for the field mapper */ -const db = new LowWithLodash( - new JSONFile(path.join(process.cwd(), "database", "field-mapper.json")), - defaultData -); +const getFieldMapperDb = (projectId: string, iteration: number) => { + fs.mkdirSync(path.join(process.cwd(), "database", projectId, iteration.toString()), { recursive: true }); + const db = new LowWithLodash( + new JSONFile( + path.join(process.cwd(), "database", projectId, iteration.toString(), 'field-mapper.json') + ), + defaultData + ); + return db; +}; + +export default getFieldMapperDb; -export default db; diff --git a/api/src/models/contentTypesMapper-lowdb.ts b/api/src/models/contentTypesMapper-lowdb.ts index e4163818a..e4eedf500 100644 --- a/api/src/models/contentTypesMapper-lowdb.ts +++ b/api/src/models/contentTypesMapper-lowdb.ts @@ -1,6 +1,7 @@ import { JSONFile } from "lowdb/node"; import path from 'path'; import LowWithLodash from "../utils/lowdb-lodash.utils.js"; +import fs from 'node:fs'; /** * Represents a content type mapper. @@ -56,6 +57,9 @@ export interface ContentTypesMapper { */ fieldMapping: []; + entryMapping: []; + + /** * The type of the content type. */ @@ -78,11 +82,20 @@ interface ContentTypeMapperDocument { const defaultData: ContentTypeMapperDocument = { ContentTypesMappers: [] }; /** - * Represents the database instance for the content types mapper. + * Creates and returns a database instance for the content types mapper for a specific project. + * @param projectId - The unique identifier of the project + * @returns The database instance for the content types mapper */ -const db = new LowWithLodash( - new JSONFile(path.join(process.cwd(), "database", 'contentTypesMapper.json')), - defaultData -); - -export default db; +export const getContentTypesMapperDb = (projectId: string, iteration: number) => { + fs.mkdirSync(path.join(process.cwd(), "database", projectId, iteration.toString()), { recursive: true }); + const db = new LowWithLodash( + new JSONFile( + path.join(process.cwd(), "database", projectId, iteration.toString(), 'contentTypesMapper.json'), + ), + defaultData + ); + return db; +}; + +// For backward compatibility, export a default function that requires projectId +export default getContentTypesMapperDb; diff --git a/api/src/models/project-lowdb.ts b/api/src/models/project-lowdb.ts index 42d654c4d..2f4b594d0 100644 --- a/api/src/models/project-lowdb.ts +++ b/api/src/models/project-lowdb.ts @@ -70,6 +70,7 @@ interface Project { migration_execution: boolean; taxonomies?: any[]; isSSO: boolean; + iteration: number; } interface ProjectDocument { diff --git a/api/src/models/uidMapper.ts b/api/src/models/uidMapper.ts new file mode 100644 index 000000000..c4d083063 --- /dev/null +++ b/api/src/models/uidMapper.ts @@ -0,0 +1,51 @@ +import { JSONFile } from "lowdb/node"; +import LowWithLodash from "../utils/lowdb-lodash.utils.js"; +import path from "path"; +import fs from 'node:fs'; + +/** + * Represents the advanced configuration options for a field mapper. + */ +export interface Advanced { + validationRegex: string; + mandatory: boolean; + multiple: boolean; + unique: boolean; + nonLocalizable: boolean; + embedObject: boolean; + embedObjects: any; + minChars: string; + maxChars: number; + default_value: string; + description: string; + validationErrorMessage: string; + options: any[]; +} + +/** + * Represents an entry mapper object. + */ +interface EntryMapper { + entry: Record; + assets: Record; +} + +const defaultData: EntryMapper = { entry: {}, assets: {} }; + +/** + * Creates and returns a database instance for the field mapper for a specific project. + * @param projectId - The unique identifier of the project + * @returns The database instance for the field mapper + */ +const getUidMapperDb = (projectId: string, iteration: number) => { + fs.mkdirSync(path.join(process.cwd(), "database", projectId, iteration.toString()), { recursive: true }); + const db = new LowWithLodash( + new JSONFile( + path.join(process.cwd(), "database", projectId, iteration.toString(), 'uid-mapper.json') + ), + defaultData + ); + return db; +}; + +export default getUidMapperDb; diff --git a/api/src/routes/contentMapper.routes.ts b/api/src/routes/contentMapper.routes.ts index 86be4fe85..614f61de8 100644 --- a/api/src/routes/contentMapper.routes.ts +++ b/api/src/routes/contentMapper.routes.ts @@ -96,10 +96,28 @@ router.get( /** * Update content mapper - * @route GET /:orgId/:projectId + * @route PATCH /:orgId/:projectId/mapper_keys */ router.patch("/:orgId/:projectId/mapper_keys", asyncRouter(contentMapperController.updateContentMapper)); +/** + * Get Entry Mapping List + * @route GET /entryMapping/:projectId/:contentTypeId/:skip/:limit/:searchText? + */ +router.get( + "/entryMapping/:projectId/:contentTypeId/:skip/:limit/:searchText?", + asyncRouter(contentMapperController.getEntryMapping) +); + +/** + * Update Entry Status + * @route PUT /entryStatus/:projectId + */ +router.put( + "/updateEntryStatus/:projectId", + asyncRouter(contentMapperController.updateEntryStatus) +); + /** * Get Single Global Field data * @route GET /:projectId/:globalFieldUid diff --git a/api/src/routes/migration.routes.ts b/api/src/routes/migration.routes.ts index 1d6d22345..d7f620b1f 100644 --- a/api/src/routes/migration.routes.ts +++ b/api/src/routes/migration.routes.ts @@ -93,5 +93,18 @@ router.post( asyncRouter(migrationController.saveMappedLocales) ) +/** + * Route for restarting the migration. + * @route POST /restart/:orgId/:projectId + * @group Migration + * @param {string} orgId - The ID of the organization. + * @param {string} projectId - The ID of the project. + * @returns {Promise} - A promise that resolves when the migration is restarted. + */ +router.post( + "/restart/:orgId/:projectId", + asyncRouter(migrationController.restartMigration) +) + export default router; diff --git a/api/src/services/contentMapper.service.ts b/api/src/services/contentMapper.service.ts index f457fcd09..746b4bef4 100644 --- a/api/src/services/contentMapper.service.ts +++ b/api/src/services/contentMapper.service.ts @@ -26,8 +26,24 @@ import { requestWithSsoTokenRefresh } from '../utils/sso-request.utils.js'; import ProjectModelLowdb from '../models/project-lowdb.js'; import FieldMapperModel from '../models/FieldMapper.js'; import { v4 as uuidv4 } from 'uuid'; -import ContentTypesMapperModelLowdb from '../models/contentTypesMapper-lowdb.js'; -import { ContentTypesMapper } from '../models/contentTypesMapper-lowdb.js'; +import getFieldMapperDb from "../models/FieldMapper.js"; +import getEntryMapperDb, { EntryMapper } from "../models/EntryMapper.js"; +import getContentTypesMapperDb, { ContentTypesMapper } from "../models/contentTypesMapper-lowdb.js"; +import getUidMapperDb from "../models/uidMapper.js"; +import { isDuplicateEntry } from '../utils/entry-duplicate.utils.js'; + + +const idCorrector = ({ id }: { id: string }) => { + const newId = id?.replace(/[-{}]/g, (match) => + match === '-' ? '' : '' + ); + if (newId) { + return newId?.toLowerCase(); + } else { + return id; + } +}; + // Developer service to create dummy contentmapping data /** @@ -41,12 +57,21 @@ const putTestData = async (req: Request) => { const contentTypes = req.body.contentTypes; try { + // Get project data to extract iteration + await ProjectModelLowdb.read(); + const projectData = ProjectModelLowdb.chain + .get("projects") + .find({ id: projectId }) + .value(); + const iteration = projectData?.iteration || 1; + /* this code snippet is iterating over an array called contentTypes and transforming each element by adding a unique identifier (id) if it doesn't already exist. The transformed elements are then stored in the contentType variable, and the generated id values are pushed into the contentIds array. */ + const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); await ContentTypesMapperModelLowdb.read(); if (!Array?.isArray?.(contentTypes)) { throw new BadRequestError(HTTP_TEXTS.CONTENT_TYPE_INVALID); @@ -67,13 +92,12 @@ const putTestData = async (req: Request) => { if (item?.refrenceTo) { item.initialRefrenceTo = item?.refrenceTo; } - }); + }) }); const sanitizeObject = (obj: Record) => { const blockedKeys = ['__proto__', 'prototype', 'constructor']; const safeObj: Record = {}; - for (const key in obj) { if (!blockedKeys.includes(key)) { safeObj[key] = obj[key]; @@ -91,12 +115,21 @@ const putTestData = async (req: Request) => { It then updates the field_mapper property of a data object using the FieldMapperModel.update() function. Finally, it updates the fieldMapping property of each type in the contentTypes array with the fieldIds array. */ + + const FieldMapperModel = getFieldMapperDb(projectId, iteration); await FieldMapperModel.read(); - contentTypes.forEach((type: any, index: number) => { + + // Collect all fields from all content types first + const allFields: any[] = []; + + for (let index = 0; index < contentTypes.length; index++) { + const type: any = contentTypes[index]; const fieldIds: string[] = []; - const fields = Array.isArray(type?.fieldMapping) - ? type.fieldMapping.filter(Boolean).map((field: any) => { + const fields = Array.isArray(type?.fieldMapping) ? + type.fieldMapping + .filter(Boolean) + .map((field: any) => { const safeField = sanitizeObject(field); const id = safeField?.id @@ -116,12 +149,9 @@ const putTestData = async (req: Request) => { }) : []; - FieldMapperModel.update((data: any) => { - data.field_mapper = [ - ...(Array.isArray(data?.field_mapper) ? data.field_mapper : []), - ...fields, - ]; - }); + // Add to collection instead of updating DB + allFields.push(...fields); + if ( Array?.isArray?.(contentType) && Number?.isInteger?.(index) && @@ -130,13 +160,107 @@ const putTestData = async (req: Request) => { ) { contentType[index].fieldMapping = fieldIds; } + } + + // Single update with all fields + await FieldMapperModel.update((data: any) => { + data.field_mapper = allFields; + }); + + const EntryMapperModel = getEntryMapperDb(projectId, iteration); + await EntryMapperModel.read(); + + const uidMapperCurrent = getUidMapperDb(projectId, iteration); + await uidMapperCurrent.read(); + let uidMapperPrev: any = null; + if (iteration > 1) { + uidMapperPrev = getUidMapperDb(projectId, iteration - 1); + await uidMapperPrev.read(); + } + + const mergeEntry = (base: any, incoming: any) => { + const keep = { ...(base ?? {}) }; + const add = { ...(incoming ?? {}) }; + + if (!keep?.contentstackEntryUid && add?.contentstackEntryUid) { + keep.contentstackEntryUid = add.contentstackEntryUid; + } + + if (!keep?.id && add?.id) { + keep.id = add.id; + } + + Object.keys(add).forEach((k) => { + if (add[k] !== undefined) { + keep[k] = add[k]; + } + }); + + return keep; + }; + + const entryKey = (e: any) => + `${e?.contentTypeId ?? e?.contentTypeUid ?? ''}:${e?.otherCmsEntryUid ?? ''}`; + + // Collect all entries from all content types first + const allEntries: any[] = []; + + for (let index = 0; index < contentTypes.length; index++) { + const type: any = contentTypes[index]; + const entryIds: string[] = []; + + const entries = Array.isArray(type?.entryMapping) ? + type.entryMapping + .filter(Boolean) + .map((entry: any) => { + const id = entry?.id + ? entry.id.replace(/[{}]/g, '').toLowerCase() + : uuidv4(); + entry.id = id; + entryIds.push(id); + + const otherCmsUidRaw = (entry?.otherCmsEntryUid ?? id) as string; + const uidMapperValue = resolveContentstackEntryUidAcrossIterations( + entry?.otherCmsEntryUid, + id, + uidMapperCurrent, + uidMapperPrev, + ); + + return { + ...entry, + id, + otherCmsEntryUid: entry?.otherCmsEntryUid, + projectId, + contentTypeUid: entry?.contentTypeUid ?? type?.otherCmsUid ?? type?.contentTypeUid, + contentTypeId: type?.id, + isDeleted: false, + contentstackEntryUid: uidMapperValue, + }; + }) + : []; + + // Add to collection instead of updating DB + allEntries.push(...entries); + + if ( + Array?.isArray?.(contentType) && + Number?.isInteger?.(index) && + index >= 0 && + index < contentType?.length + ) { + contentType[index].entryMapping = entryIds; + } + } + + // Single update with all entries + await EntryMapperModel.update((data: any) => { + data.entry_mapper = allEntries; }); await ContentTypesMapperModelLowdb.update((data: any) => { - data.ContentTypesMappers = [ - ...(data?.ContentTypesMappers ?? []), - ...contentType, - ]; + // Simple approach: just replace with new content types + data.ContentTypesMappers = contentType; }); await ProjectModelLowdb.read(); @@ -187,6 +311,8 @@ const putTestData = async (req: Request) => { .find({ id: projectId }) .value(); + await isDuplicateEntry(projectId); + return { status: HTTP_CODES?.OK, data: pData, @@ -230,6 +356,9 @@ const getContentTypes = async (req: Request) => { throw new BadRequestError(HTTP_TEXTS.PROJECT_NOT_FOUND); } const contentMapperId = projectDetails.content_mapper; + const iteration = projectDetails?.iteration || 0; + const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); + const FieldMapperModel = getFieldMapperDb(projectId, iteration); await ContentTypesMapperModelLowdb.read(); await FieldMapperModel.read(); @@ -319,6 +448,13 @@ const getFieldMapping = async (req: Request) => { let totalCount = 0; try { + const project = ProjectModelLowdb.chain + .get('projects') + .find({ id: projectId }) + .value(); + const iteration = project?.iteration || 0; + const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); + const FieldMapperModel = getFieldMapperDb(projectId, iteration); await ContentTypesMapperModelLowdb.read(); const contentType = ContentTypesMapperModelLowdb.chain @@ -649,6 +785,9 @@ const updateContentType = async (req: Request) => { } try { + const iteration = project?.iteration || 0; + const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); + const FieldMapperModel = getFieldMapperDb(projectId, iteration); await ContentTypesMapperModelLowdb.read(); const updateIndex = ContentTypesMapperModelLowdb.chain .get('ContentTypesMappers') @@ -826,6 +965,9 @@ const resetToInitialMapping = async (req: Request) => { throw new BadRequestError(HTTP_TEXTS.CANNOT_RESET_CONTENT_MAPPING); } + const iteration = project?.iteration || 0; + const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); + const FieldMapperModel = getFieldMapperDb(projectId, iteration); await ContentTypesMapperModelLowdb.read(); const contentTypeData = ContentTypesMapperModelLowdb.chain .get('ContentTypesMappers') @@ -946,6 +1088,9 @@ const resetAllContentTypesMapping = async (projectId: string) => { ); throw new BadRequestError(HTTP_TEXTS.PROJECT_NOT_FOUND); } + const iteration = projectDetails?.iteration || 0; + const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); + const FieldMapperModel = getFieldMapperDb(projectId, iteration); await ContentTypesMapperModelLowdb.read(); const cData = contentMapperId.map((cId: any) => { const contentTypeData = ContentTypesMapperModelLowdb.chain @@ -1037,7 +1182,11 @@ const removeMapping = async (projectId: string) => { ); throw new BadRequestError(HTTP_TEXTS.PROJECT_NOT_FOUND); } + const iteration = projectDetails?.iteration || 0; + const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); + const FieldMapperModel = getFieldMapperDb(projectId, iteration); await ContentTypesMapperModelLowdb.read(); + await FieldMapperModel.read(); const cData = projectDetails?.content_mapper.map((cId: any) => { const contentTypeData = ContentTypesMapperModelLowdb.chain .get('ContentTypesMappers') @@ -1223,7 +1372,11 @@ const removeContentMapper = async (req: Request) => { ); throw new BadRequestError(HTTP_TEXTS.PROJECT_NOT_FOUND); } + const iteration = projectDetails?.iteration || 0; + const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); + const FieldMapperModel = getFieldMapperDb(projectId, iteration); await ContentTypesMapperModelLowdb.read(); + await FieldMapperModel.read(); const cData: ContentTypesMapper[] = projectDetails?.content_mapper.map( (cId: string) => { const contentTypeData: ContentTypesMapper = @@ -1651,6 +1804,291 @@ const getExistingExtensions = async ({existingStackId, token_payload}: any) => { } +const updateEntryStatus = async (req: Request) => { + const { projectId } = req.params; + const { ids } = req.body; + const validatedUids: string[] = Array.isArray(ids) ? ids : []; + const srcFunc = "updateEntryMapping"; + if (isEmpty(validatedUids)) { + logger.error( + getLogMessage( + srcFunc, + "Invalid ids" + ) + ); + return { + status: HTTP_CODES?.BAD_REQUEST, + data: { + message: "Invalid ids", + }, + }; + } + try { + await ProjectModelLowdb.read(); + const projectData = ProjectModelLowdb.chain + .get("projects") + .find({ id: projectId }) + .value(); + const iteration = projectData?.iteration || 1; + const EntryMapperModel = getEntryMapperDb(projectId, iteration); + await EntryMapperModel.read(); + const foundEntry: EntryMapper[] = []; + await EntryMapperModel.update((data: any) => { + data?.entry_mapper?.forEach((entry: any) => { + if (validatedUids.includes(entry?.id)) { + entry.isUpdate = !entry.isUpdate; + foundEntry.push(entry); + } + }); + }); + + if (foundEntry) { + return { + status: HTTP_CODES?.OK, + data: foundEntry + }; + } + + return { + status: HTTP_CODES?.NOT_FOUND, + data: { + message: "Entry not found", + }, + }; + + } catch (error: any) { + logger.error( + getLogMessage( + srcFunc, + "Error occurred while updating entry mapping", + error + ) + ); + throw new ExceptionFunction( + error?.message || HTTP_TEXTS.INTERNAL_ERROR, + error?.statusCode || error?.status || HTTP_CODES.SERVER_ERROR, + ); + } + + +} + +const getEntryMapping = async (req: Request) => { + + const srcFunc = "getEntryMapping"; + const contentTypeId = req?.params?.contentTypeId; + const projectId = req?.params?.projectId; + const skip: any = req?.params?.skip; + const limit: any = req?.params?.limit; + const search: string = req?.params?.searchText?.toLowerCase(); + + let result: any[] = []; + let filteredResult = []; + let totalCount = 0; + + try { + // Get project iteration + await ProjectModelLowdb.read(); + const projectData = ProjectModelLowdb.chain + .get("projects") + .find({ id: projectId }) + .value(); + const iteration = projectData?.iteration || 1; + + const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); + await ContentTypesMapperModelLowdb.read(); + + const contentType = ContentTypesMapperModelLowdb.chain + .get("ContentTypesMappers") + .find({ id: contentTypeId, projectId: projectId }) + .value(); + + if (isEmpty(contentType)) { + logger.error( + getLogMessage( + srcFunc, + `${HTTP_TEXTS.CONTENT_TYPE_NOT_FOUND} Id: ${contentTypeId}` + ) + ); + throw new BadRequestError(HTTP_TEXTS.CONTENT_TYPE_NOT_FOUND); + } + const EntryMapperModel = getEntryMapperDb(projectId, iteration); + await EntryMapperModel.read(); + let entryMapping = contentType?.entryMapping?.map?.((mapperUId: any) => { + const entryMapper = EntryMapperModel.chain + .get("entry_mapper") + .find({ id: mapperUId, projectId: projectId, contentTypeId: contentTypeId }) + .value(); + + return entryMapper; + }); + + // Fallback: If no entry mappings found in current iteration and we have previous iteration + if ((!entryMapping || entryMapping.length === 0 || entryMapping.every((e: any) => !e)) && iteration > 1) { + const PrevEntryMapperModel = getEntryMapperDb(projectId, iteration - 1); + await PrevEntryMapperModel.read(); + entryMapping = contentType?.entryMapping?.map?.((mapperUId: any) => { + const entryMapper = PrevEntryMapperModel.chain + .get("entry_mapper") + .find({ id: mapperUId, projectId: projectId, contentTypeId: contentTypeId }) + .value(); + + return entryMapper; + }); + } + + const enrichedMapping = await enrichEntriesWithUidMapper( + projectId, + iteration, + entryMapping ?? [], + ); + + if (!isEmpty(enrichedMapping)) { + if (search) { + filteredResult = enrichedMapping?.filter?.((item: any) => + item?.entryName?.toLowerCase().includes(search) + ); + totalCount = filteredResult.length; + result = filteredResult.slice(skip, Number(skip) + Number(limit)); + } else { + totalCount = enrichedMapping.length; + result = enrichedMapping.slice(skip, Number(skip) + Number(limit)); + } + } + return { + status: HTTP_CODES?.OK, + count: totalCount, + entryMapping: result + }; + + } catch (error: any) { + // Log error message + logger.error( + getLogMessage( + srcFunc, + "Error occurred while getting field mapping of projects", + error + ) + ); + + throw new ExceptionFunction( + error?.message || HTTP_TEXTS.INTERNAL_ERROR, + error?.statusCode || error?.status || HTTP_CODES.SERVER_ERROR + ); + + } +}; + +const resolveContentstackEntryUidAcrossIterations = ( + otherCmsEntryUid: string | undefined, + fallbackId: string | undefined, + currentModel: any, + prevModel: any | null, +): string | undefined => { + const fromCurrent = lookupContentstackEntryUidFromUidMap( + otherCmsEntryUid, + fallbackId, + currentModel, + ); + if (fromCurrent) return fromCurrent; + if (prevModel) { + return lookupContentstackEntryUidFromUidMap( + otherCmsEntryUid, + fallbackId, + prevModel, + ); + } + return undefined; +}; + +const lookupContentstackEntryUidFromUidMap = ( + otherCmsEntryUid: string | undefined, + fallbackId: string | undefined, + uidMapperModel: any, +): string | undefined => { + const map = getEntryUidMap(uidMapperModel); + const otherCmsUidRaw = (otherCmsEntryUid ?? fallbackId ?? '') as string; + if (!otherCmsUidRaw) return undefined; + const otherCmsUid = otherCmsUidRaw.replace(/[{}]/g, ''); + const otherCmsUidLower = otherCmsUid ? otherCmsUid.toLowerCase() : ''; + const resolved = + map[otherCmsUid] || + map[otherCmsUidRaw] || + map[otherCmsUidLower] || + map[idCorrector({ id: otherCmsUid })] || + (otherCmsUidLower ? map[idCorrector({ id: otherCmsUidLower })] : undefined); + if (resolved == null || resolved === '' || resolved === ' ') return undefined; + return String(resolved).trim() || undefined; +}; + +const getEntryUidMap = (uidMapperModel: any): Record => { + const d = uidMapperModel?.data ?? {}; + const pick = (x: unknown): Record => { + if (!x || typeof x !== 'object' || Array.isArray(x)) return {}; + return x as Record; + }; + const fromEntryUid = flattenNestedUidMap(pick(d.entryUid)); + const fromEntry = flattenNestedUidMap(pick(d.entry)); + const nUid = Object.keys(fromEntryUid).length; + const nEnt = Object.keys(fromEntry).length; + if (nUid > 0 && nEnt > 0) { + return { ...fromEntry, ...fromEntryUid }; + } + if (nUid > 0) return fromEntryUid; + if (nEnt > 0) return fromEntry; + return {}; +}; + +const flattenNestedUidMap = (raw: Record): Record => { + const keys = Object.keys(raw ?? {}); + if (keys.length === 0) return {}; + const nested = keys.every((k) => { + const v = raw[k]; + return v != null && typeof v === 'object' && !Array.isArray(v); + }); + if (!nested) return { ...raw }; + return keys.reduce>((acc, k) => ({ ...acc, ...raw[k] }), {}); +}; + +/** + * Fill missing contentstackEntryUid from uid-mapper. Uses **current** iteration first + * (where the latest CLI import writes), then iteration-1 so step 3 still works right + * after restart before a re-import. + */ +const enrichEntriesWithUidMapper = async ( + projectId: string, + iteration: number, + entries: any[], +): Promise => { + if (!Array.isArray(entries) || entries.length === 0) return entries; + + const currentModel = getUidMapperDb(projectId, iteration); + await currentModel.read(); + + let prevModel: any = null; + if (iteration > 1) { + prevModel = getUidMapperDb(projectId, iteration - 1); + await prevModel.read(); + } + + return entries.map((item: any) => { + if (!item) return item; + const existing = item.contentstackEntryUid; + if (existing != null && String(existing).trim() !== '' && existing !== ' ') { + return item; + } + const resolved = resolveContentstackEntryUidAcrossIterations( + item.otherCmsEntryUid, + item.id, + currentModel, + prevModel, + ); + return resolved ? { ...item, contentstackEntryUid: resolved } : item; + }); +}; + + + export const contentMapperService = { putTestData, getContentTypes, @@ -1667,4 +2105,6 @@ export const contentMapperService = { getSingleGlobalField, getExistingTaxonomies, getExistingExtensions, + getEntryMapping, + updateEntryStatus, }; diff --git a/api/src/services/migration.service.ts b/api/src/services/migration.service.ts index b43b245a7..1e9382a1b 100644 --- a/api/src/services/migration.service.ts +++ b/api/src/services/migration.service.ts @@ -21,6 +21,7 @@ import { import { BadRequestError, ExceptionFunction, + NotFoundError, } from '../utils/custom-errors.utils.js'; import { fieldAttacher } from '../utils/field-attacher.utils.js'; import { siteCoreService } from './sitecore.service.js'; @@ -41,9 +42,12 @@ import { globalFieldServie } from './globalField.service.js'; import { getSafePath, sanitizeStackId } from '../utils/sanitize-path.utils.js'; import { aemService } from './aem.service.js'; import { requestWithSsoTokenRefresh } from '../utils/sso-request.utils.js'; +import { utilsUpdateCli } from './updateEntryCli.service.js'; +import { enrichConfigWithAssetMapping, removeEntriesFromDatabase } from '../utils/entry-update.utils.js'; +import { removeExistingAssets } from '../utils/asset-update.utils.js'; /** - * Creates a test stack. + * Creates a test stack. * * @param req - The request object containing the necessary parameters. * @returns A promise that resolves to a LoginServiceType object. @@ -1051,6 +1055,21 @@ const startMigration = async (req: Request): Promise => { default: break; } + await ProjectModelLowdb.read(); + const projectData = ProjectModelLowdb.chain + .get("projects") + .find({ id: projectId }) + .value(); + const iteration = projectData?.iteration || 1; + let configFilePath: string | null = null; + + if (iteration > 1) { + await removeExistingAssets(projectId, loggerPath); + + configFilePath = await removeEntriesFromDatabase(projectId, loggerPath); + console.info("Config file written to:", configFilePath); + } + await utilsCli?.runCli( region, user_id, @@ -1059,6 +1078,20 @@ const startMigration = async (req: Request): Promise => { false, loggerPath ); + + if (configFilePath) { + console.info("Config file path:", configFilePath); + enrichConfigWithAssetMapping(configFilePath, projectId, iteration, loggerPath); + console.info("Asset mapping enriched into config"); + await utilsUpdateCli?.updateEntryCli( + // + region, + user_id, + project?.destination_stack_id, + loggerPath, + configFilePath + ); + } } }; const getAuditData = async (req: Request): Promise => { @@ -1503,6 +1536,40 @@ export const updateLocaleMapper = async (req: Request) => { } }; +const restartMigration = async (req: Request): Promise => { + const { orgId, projectId } = req?.params ?? {}; + await ProjectModelLowdb.read(); + const projectIndex = ProjectModelLowdb.chain + .get("projects") + .findIndex({ id: projectId, org_id: orgId }) + .value(); + console.info('projectIndex', projectIndex); + if (projectIndex > -1) { + await ProjectModelLowdb.update((data: any) => { + data.projects[projectIndex].migration_execution = false; + data.projects[projectIndex].isMigrationCompleted = false; + data.projects[projectIndex].isMigrationStarted = false; + data.projects[projectIndex].current_step = 1; + data.projects[projectIndex].status = 0; + data.projects[projectIndex].isMigrationStarted = false; + data.projects[projectIndex].isMigrationCompleted = false; + data.projects[projectIndex].migration_execution = false; + data.projects[projectIndex].legacy_cms = { + ...data.projects[projectIndex].legacy_cms, + is_fileValid: false, + }; + data.projects[projectIndex].iteration = 1 + (data.projects[projectIndex].iteration || 0); + data.projects[projectIndex].updated_at = new Date().toISOString(); + }); + } else { + throw new NotFoundError(HTTP_TEXTS?.PROJECT_NOT_FOUND); + } + return { + status: HTTP_CODES?.OK, + message: "Migration restarted successfully", + }; +}; + export const migrationService = { createTestStack, deleteTestStack, @@ -1512,4 +1579,5 @@ export const migrationService = { createSourceLocales, updateLocaleMapper, getAuditData, -}; + restartMigration +}; \ No newline at end of file diff --git a/api/src/services/projects.service.ts b/api/src/services/projects.service.ts index 0d426570e..8e5524022 100644 --- a/api/src/services/projects.service.ts +++ b/api/src/services/projects.service.ts @@ -2,7 +2,7 @@ import { Request } from 'express'; import ProjectModelLowdb from '../models/project-lowdb.js'; -import ContentTypesMapperModelLowdb from '../models/contentTypesMapper-lowdb.js'; +import ContentTypesMapperModelLowdb, { getContentTypesMapperDb } from '../models/contentTypesMapper-lowdb.js'; import FieldMapperModel from '../models/FieldMapper.js'; import { @@ -26,6 +26,7 @@ import logger from "../utils/logger.js"; import AuthenticationModel from "../models/authentication.js"; // import { contentMapperService } from "./contentMapper.service.js"; import { v4 as uuidv4 } from 'uuid'; +import getFieldMapperDb from '../models/FieldMapper.js'; /** * Retrieves all projects based on the provided request object. @@ -163,6 +164,7 @@ const createProject = async (req: Request) => { isMigrationCompleted:false, migration_execution:false, isSSO: isSSO, + iteration: 1, }; try { @@ -1155,6 +1157,9 @@ const deleteProject = async (req: Request) => { if (projects?.status == NEW_PROJECT_STATUS[5]) { const content_mapper_id = projects?.content_mapper; + const iteration = projects?.iteration || 0; + const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); + const FieldMapperModel = getFieldMapperDb(projectId, iteration); await ContentTypesMapperModelLowdb.read(); await FieldMapperModel.read(); if (!isEmpty(content_mapper_id) && Array.isArray(content_mapper_id)) { diff --git a/api/src/services/runCli.service.ts b/api/src/services/runCli.service.ts index 5a27db4e2..59bcdc83f 100644 --- a/api/src/services/runCli.service.ts +++ b/api/src/services/runCli.service.ts @@ -19,6 +19,7 @@ interface TestStack { isMigrated: boolean; } import { setBasicAuthConfig, setOAuthConfig } from '../utils/config-handler.util.js'; +import getUidMapperDb from '../models/uidMapper.js'; /** * Determines log level based on message content without removing ANSI codes @@ -53,6 +54,67 @@ const stripAnsiCodes = (text: string): string => { return text.replace(/\u001b\[\d+m/g, ''); }; +const writeUidMapping = async (backupPath: string, projectId: string, iteration: number) => { + try { + const assetMapperPath = path.join(backupPath, 'mapper', 'assets', 'uid-mapping.json'); + let assetJson = {}; + + // Check if file exists and has meaningful data + if (fs.existsSync(assetMapperPath)) { + const assetData = fs.readFileSync(assetMapperPath, 'utf-8'); + const parsedData = JSON.parse(assetData); + // Check if data is not empty + if (parsedData && Object.keys(parsedData).length > 0) { + assetJson = parsedData; + } + } + + // If no meaningful data found and we have previous iteration, use fallback + if (Object.keys(assetJson).length === 0 && iteration > 1) { + const prevAssetMapperPath = path.join(process.cwd(), 'database', projectId, (iteration - 1).toString(), 'uid-mapper.json'); + if (fs.existsSync(prevAssetMapperPath)) { + const prevData = JSON.parse(fs.readFileSync(prevAssetMapperPath, 'utf-8')); + assetJson = prevData.assets || {}; + } + } + + const entryMapperPath = path.join(backupPath, 'mapper', 'entries', 'uid-mapping.json'); + let entryJson = {}; + + // Check if file exists and has meaningful data + if (fs.existsSync(entryMapperPath)) { + const entryData = fs.readFileSync(entryMapperPath, 'utf-8'); + const parsedData = JSON.parse(entryData); + // Check if data is not empty + if (parsedData && Object.keys(parsedData).length > 0) { + entryJson = parsedData; + } + } + + // If no meaningful data found and we have previous iteration, use fallback + if (Object.keys(entryJson).length === 0 && iteration > 1) { + const prevEntryMapperPath = path.join(process.cwd(), 'database', projectId, (iteration - 1).toString(), 'uid-mapper.json'); + if (fs.existsSync(prevEntryMapperPath)) { + const prevData = JSON.parse(fs.readFileSync(prevEntryMapperPath, 'utf-8')); + console.info('Using previous iteration data for entries:', prevData); + entryJson = prevData.entry || {}; + } + } + + const combinedMapping = { + assets: assetJson, + entry: entryJson, + }; + const UidMapperModelLowdb = getUidMapperDb(projectId, iteration); + await UidMapperModelLowdb.read(); + UidMapperModelLowdb.data = combinedMapping; + await UidMapperModelLowdb.write(); + console.info('UID mapping data written successfully to Lowdb'); + } catch (error) { + console.error('Error writing UID mapping file:', error); + } +} + /** * Executes CLI commands and provides real-time output * Uses Node's spawn to run commands asynchronously @@ -269,6 +331,13 @@ export const runCli = async ( if (loggerPath && loggerPath !== transformePath) { fs.appendFileSync(loggerPath, JSON.stringify(directLogEntry) + '\n'); } + await ProjectModelLowdb.read(); + const projectData = ProjectModelLowdb.chain + .get("projects") + .find({ id: projectId }) + .value(); + const iteration = projectData?.iteration || 1; + await writeUidMapping(backupPath, projectId, iteration); } // Keep the project status update code: @@ -321,4 +390,4 @@ export const runCli = async ( } }; -export const utilsCli = { runCli }; +export const utilsCli = { runCli }; \ No newline at end of file diff --git a/api/src/services/updateEntryCli.service.ts b/api/src/services/updateEntryCli.service.ts new file mode 100644 index 000000000..11ed8db64 --- /dev/null +++ b/api/src/services/updateEntryCli.service.ts @@ -0,0 +1,252 @@ +/* eslint-disable */ + +import path from 'path'; +import fs from 'fs'; +import { spawn } from 'child_process'; +import { CS_REGIONS } from '../constants/index.js'; +import AuthenticationModel from '../models/authentication.js'; +import { setLogFilePath } from '../server.js'; +// import utilitiesHandler from '@contentstack/cli-utilities'; +import { setOAuthConfig } from '../utils/config-handler.util.js'; +import { setBasicAuthConfig } from '../utils/config-handler.util.js'; + +const determineLogLevel = (text: string): string => { + const lowerText = text.toLowerCase(); + + if ( + lowerText.includes('error') || + lowerText.includes('failed') || + lowerText.includes('exception') || + lowerText.includes('not found') + ) { + return 'error'; + } else if (lowerText.includes('warn') || lowerText.includes('warning')) { + return 'warn'; + } else { + return 'info'; + } +}; + +const stripAnsiCodes = (text: string): string => { + return text.replace(/\u001b\[\d+m/g, ''); +}; + +const runCommand = ( + command: string, + args: string[] = [], + logFilePath?: string, +): Promise => { + return new Promise((resolve, reject) => { + const cmdProcess = spawn(command, args, { shell: true }); + + cmdProcess.stdout.on('data', (data) => { + const output = data.toString(); + process.stdout.write(output); + + if (logFilePath) { + try { + const cleanedOutput = stripAnsiCodes(output); + const logEntry = { + level: determineLogLevel(cleanedOutput), + message: cleanedOutput.trim(), + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(logEntry) + '\n'); + } catch (err) { + console.error('Error writing to log file:', err); + } + } + }); + + cmdProcess.stderr.on('data', (data) => { + const output = data.toString(); + process.stderr.write(output); + + if (logFilePath) { + try { + const cleanedOutput = stripAnsiCodes(output); + const logEntry = { + level: 'error', + message: cleanedOutput.trim(), + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(logEntry) + '\n'); + } catch (err) { + console.error('Error writing stderr to log file:', err); + } + } + }); + + cmdProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + if (logFilePath) { + try { + const logEntry = { + level: 'error', + message: `Command failed with exit code ${code}`, + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(logEntry) + '\n'); + } catch (err) { + console.error('Error writing close event to log file:', err); + } + } + // reject(new Error(`Command failed with exit code ${code}`)); + } + }); + }); +}; + +/** + * Runs csdx cm:stacks:migration to execute the entry update script + * against the target stack. + */ +export const updateEntryCli = async ( + rg: string, + user_id: string, + stack_api_key: string, + logFilePath: string, + configFilePath: string +) => { + try { + console.info("inside updateEntryCli"); + const regionPresent = + CS_REGIONS.find((item) => item === rg) ?? 'NA'.replace(/_/g, '-'); + const regionCli = regionPresent.replace(/_/g, '-'); + + const directLogEntry1 = { + level: 'info', + message: `Starting entry update CLI process for stack: ${stack_api_key}`, + methodName: 'updateEntryCli', + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(directLogEntry1) + '\n'); + + await AuthenticationModel.read(); + const userData = AuthenticationModel.chain + .get('users') + .find({ region: regionPresent, user_id }) + .value(); + + await runCommand( + 'npx', + ['@contentstack/cli', 'config:set:region', regionCli], + logFilePath + ); + + // utilitiesHandler.configHandler.set('authtoken', userData.authtoken); + // utilitiesHandler.configHandler.set('email', userData.email); + // utilitiesHandler.configHandler.set('authorisationType', 'BASIC'); + if(userData?.access_token){ + setOAuthConfig(userData); + + }else if(userData?.authtoken){ + setBasicAuthConfig(userData); + }else { + throw new Error("No authentication token found"); + } + const hasAuth = Boolean(userData?.authtoken || userData?.access_token); + if (!hasAuth || !stack_api_key) { + const directLogEntry2 = { + level: 'info', + message: 'User not found, no auth token (authtoken or access_token), or stack API key missing.', + methodName: 'updateEntryCli', + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(directLogEntry2) + '\n'); + return; + } + + const directLogEntry3 = { + level: 'info', + message: `Authentication configured for user: ${userData.email}`, + methodName: 'updateEntryCli', + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(directLogEntry3) + '\n'); + + const scriptPath = path.join( + process.cwd(), + 'src', + 'utils', + 'entry-update-script.cjs' + ); + const directLogEntryScript = { + level: 'info', + message: `Script path: ${scriptPath}`, + methodName: 'updateEntryCli', + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(directLogEntryScript) + '\n'); + + await setLogFilePath(logFilePath); + + const directLogEntry4 = { + level: 'info', + message: 'Running update entry migration script', + methodName: 'updateEntryCli', + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(directLogEntry4) + '\n'); + + const directLogEntry5 = { + level: 'info', + message: `Updating entries using config file: ${configFilePath}`, + methodName: 'updateEntryCli', + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(directLogEntry5) + '\n'); + + await runCommand( + 'npx', + [ + '@contentstack/cli', + 'cm:stacks:migration', + '--file-path', + scriptPath.includes(' ') ? `"${scriptPath}"` : scriptPath, + '-k', + stack_api_key, + '--config-file', + configFilePath.includes(' ') ? `"${configFilePath}"` : configFilePath, + ], + logFilePath, + ); + + const directLogEntry6 = { + level: 'info', + message: 'Entry update migration completed successfully', + methodName: 'updateEntryCli', + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(directLogEntry6) + '\n'); + + const directLogEntry7 = { + level: 'info', + message: `All entries have been updated in Contentstack stack: ${stack_api_key}`, + methodName: 'updateEntryCli', + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(directLogEntry7) + '\n'); + + const directLogEntry = { + level: 'info', + message: 'Entry Update Process Completed', + methodName: 'updateEntryCli', + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(directLogEntry) + '\n'); + } catch (error) { + console.error('updateEntryCli error:', error); + const directLogEntry8 = { + level: 'error', + message: `Failed to update entries for stack: ${stack_api_key}`, + methodName: 'updateEntryCli', + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(logFilePath, JSON.stringify(directLogEntry8) + '\n'); + } +}; + +export const utilsUpdateCli = { updateEntryCli }; diff --git a/api/src/utils/asset-update.utils.ts b/api/src/utils/asset-update.utils.ts new file mode 100644 index 000000000..9aa38514a --- /dev/null +++ b/api/src/utils/asset-update.utils.ts @@ -0,0 +1,343 @@ +import ProjectModelLowdb from "../models/project-lowdb.js"; +import path from "path"; +import fs from "node:fs"; +import { MIGRATION_DATA_CONFIG } from "../constants/index.js"; + +/** + * Helper function to write log entries to file + */ +const writeLogEntry = (message: string, methodName: string, loggerPath?: string) => { + if (loggerPath) { + const directLogEntry = { + level: 'info', + message, + methodName, + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(loggerPath, JSON.stringify(directLogEntry) + '\n'); + } +}; + +interface AssetMetadata { + filename: string; + file_size: string; + url: string; +} + +/** + * Traverses an object and replaces any asset reference whose uid matches + * a source asset ID with the corresponding Contentstack asset UID. + * Asset references are objects with a "uid" property matching a known source asset ID. + */ +const replaceAssetRefsInObject = ( + obj: any, + assetUidMap: Map +): boolean => { + if (!obj || typeof obj !== "object") return false; + + let modified = false; + + for (const key of Object.keys(obj)) { + const value = obj[key]; + if (!value || typeof value !== "object") continue; + + if (value.uid && assetUidMap.has(value.uid)) { + obj[key] = assetUidMap.get(value.uid); + modified = true; + } else { + const childModified = replaceAssetRefsInObject(value, assetUidMap); + if (childModified) modified = true; + } + } + + return modified; +}; + +/** + * Saves asset metadata from index.json to database/{projectId}/{iteration}/asset-metadata.json. + * Used for validation in subsequent iterations. + */ +const saveAssetMetadata = ( + indexData: Record, + projectId: string, + iteration: number, + loggerPath?: string +): void => { + const metadata: Record = {}; + + for (const [assetId, asset] of Object.entries(indexData)) { + metadata[assetId] = { + filename: asset?.filename || "", + file_size: asset?.file_size || "", + url: asset?.url || "", + }; + } + + const metadataDir = path.join(process.cwd(), "database", projectId, iteration.toString()); + fs.mkdirSync(metadataDir, { recursive: true }); + const metadataPath = path.join(metadataDir, "asset-metadata.json"); + fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf-8"); + writeLogEntry(`Asset metadata saved: ${Object.keys(metadata).length} assets → ${metadataPath}`, "saveAssetMetadata", loggerPath); +}; + +/** + * Loads asset metadata from a previous iteration. + */ +const loadPreviousAssetMetadata = ( + projectId: string, + prevIteration: number +): Record => { + const metadataPath = path.join( + process.cwd(), "database", projectId, + prevIteration.toString(), "asset-metadata.json" + ); + if (!fs.existsSync(metadataPath)) { + // Note: This function doesn't have access to loggerPath, keeping as console for internal function + console.info(`No previous asset metadata found at ${metadataPath}`); + return {}; + } + try { + return JSON.parse(fs.readFileSync(metadataPath, "utf-8")); + } catch (err) { + console.error("Failed to read previous asset metadata:", err); + return {}; + } +}; + +/** + * Loads the asset uid mapping from a previous iteration's uid-mapper.json. + */ +const loadPreviousAssetUidMap = ( + projectId: string, + prevIteration: number +): Record => { + const uidMapperPath = path.join( + process.cwd(), "database", projectId, + prevIteration.toString(), "uid-mapper.json" + ); + if (!fs.existsSync(uidMapperPath)) { + // Note: This function doesn't have access to loggerPath, keeping as console for internal function + console.info(`No uid-mapper found at ${uidMapperPath}`); + return {}; + } + try { + const data = JSON.parse(fs.readFileSync(uidMapperPath, "utf-8")); + return data?.assets || {}; + } catch (err) { + console.error("Failed to read uid-mapper:", err); + return {}; + } +}; + +/** + * Determines whether an asset has changed by comparing current metadata + * against the previous iteration's stored metadata. + */ +const hasAssetChanged = ( + assetId: string, + currentAsset: any, + prevMetadata: Record +): boolean => { + const prev = prevMetadata[assetId]; + if (!prev) return true; + + const currentFilename = currentAsset?.filename || ""; + const currentFileSize = currentAsset?.file_size || ""; + + if (currentFilename !== prev.filename || currentFileSize !== prev.file_size) { + // Note: This function doesn't have access to loggerPath, keeping as console for internal function + console.info( + `Asset "${assetId}" changed: ` + + `filename "${prev.filename}" → "${currentFilename}", ` + + `file_size "${prev.file_size}" → "${currentFileSize}"` + ); + return true; + } + + return false; +}; + +/** + * Removes existing (already-migrated) assets from cmsMigrationData to prevent duplicates. + * + * For iteration 1: only saves asset metadata for future comparisons. + * For iteration 2+: + * 1. Reads uid-mapper.assets from previous iteration + * 2. Reads asset-metadata.json from previous iteration + * 3. For each asset in current index.json: + * - If NOT in uid-mapper → new asset, keep it + * - If in uid-mapper AND metadata matches → unchanged, replace refs with CS UID, remove from import + * - If in uid-mapper BUT metadata differs → updated asset, keep it for re-import + * 4. Replaces asset references in entry JSON files with Contentstack UIDs + * 5. Removes deduplicated asset entries from index.json and their file folders + * 6. Saves current asset metadata for the next iteration + */ +export const removeExistingAssets = async (projectId: string, loggerPath?: string): Promise => { + await ProjectModelLowdb.read(); + const projectData = ProjectModelLowdb.chain + .get("projects") + .find({ id: projectId }) + .value(); + + const iteration = projectData?.iteration || 1; + const stackId = projectData?.destination_stack_id; + + if (!stackId) { + writeLogEntry("No stackId found, skipping asset dedup.", "removeExistingAssets", loggerPath); + return; + } + + const assetsDir = path.join( + process.cwd(), MIGRATION_DATA_CONFIG.DATA, stackId, + MIGRATION_DATA_CONFIG.ASSETS_DIR_NAME + ); + const indexPath = path.join(assetsDir, MIGRATION_DATA_CONFIG.ASSETS_SCHEMA_FILE); + + if (!fs.existsSync(indexPath)) { + writeLogEntry(`Assets index.json not found at ${indexPath}, skipping.`, "removeExistingAssets", loggerPath); + return; + } + writeLogEntry(`Assets index.json found at ${indexPath}`, "removeExistingAssets", loggerPath); + + let indexData: Record; + try { + const raw = fs.readFileSync(indexPath, "utf-8"); + if (!raw.trim()) { + console.error(`Assets index.json is empty at ${indexPath}`); + return; + } + indexData = JSON.parse(raw); + } catch (error) { + console.error(`Failed to parse assets index.json at ${indexPath}:`, error instanceof Error ? error.message : String(error)); + return; + } + + saveAssetMetadata(indexData, projectId, iteration, loggerPath); + + if (iteration <= 1) { + writeLogEntry("Iteration 1: asset metadata saved, no dedup needed.", "removeExistingAssets", loggerPath); + return; + } + writeLogEntry(`Iteration ${iteration} found, loading previous asset uid map and metadata.`, "removeExistingAssets", loggerPath); + + const prevIteration = iteration - 1; + const prevAssetUidMap = loadPreviousAssetUidMap(projectId, prevIteration); + writeLogEntry(`Previous asset uid map loaded from ${prevIteration} iteration.`, "removeExistingAssets", loggerPath); + const prevMetadata = loadPreviousAssetMetadata(projectId, prevIteration); + + if (!Object.keys(prevAssetUidMap).length) { + writeLogEntry("No previous asset uid mapping found, skipping dedup.", "removeExistingAssets", loggerPath); + return; + } + writeLogEntry(`Previous asset metadata loaded from ${prevIteration} iteration.`, "removeExistingAssets", loggerPath); + const assetsToReuse = new Map(); + const assetsToRemoveFromIndex: string[] = []; + + for (const [assetId, assetData] of Object.entries(indexData)) { + const contentstackUid = prevAssetUidMap[assetId]; + if (!contentstackUid) continue; + + if (!hasAssetChanged(assetId, assetData, prevMetadata)) { + assetsToReuse.set(assetId, contentstackUid); + assetsToRemoveFromIndex.push(assetId); + writeLogEntry(`Asset "${assetId}" unchanged → reuse CS UID "${contentstackUid}"`, "removeExistingAssets", loggerPath); + writeLogEntry(`Asset "${assetId}" has been reused from previous migration`, "removeExistingAssets", loggerPath); + } else { + writeLogEntry(`Asset "${assetId}" changed → will re-import`, "removeExistingAssets", loggerPath); + } + } + + if (!assetsToReuse.size) { + writeLogEntry("No unchanged assets to deduplicate.", "removeExistingAssets", loggerPath); + return; + } + + // 1. Replace asset references in entry JSON files + const entriesDir = path.join( + process.cwd(), MIGRATION_DATA_CONFIG.DATA, stackId, + MIGRATION_DATA_CONFIG.ENTRIES_DIR_NAME + ); + + if (fs.existsSync(entriesDir)) { + const contentTypeDirs = fs.readdirSync(entriesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()); + + for (const ctDir of contentTypeDirs) { + const ctPath = path.join(entriesDir, ctDir.name); + + if (!fs.existsSync(ctPath)) { + console.warn(`Content type directory not found: ${ctPath}`); + continue; + } + + const localeDirs = fs.readdirSync(ctPath, { withFileTypes: true }) + .filter((d) => d.isDirectory()); + + for (const localeDir of localeDirs) { + const localePath = path.join(ctPath, localeDir.name); + + if (!fs.existsSync(localePath)) { + console.warn(`Locale directory not found: ${localePath}`); + continue; + } + + const jsonFiles = fs.readdirSync(localePath) + .filter((f) => f.endsWith(".json") && f !== "index.json"); + + for (const jsonFile of jsonFiles) { + const filePath = path.join(localePath, jsonFile); + + try { + const raw = fs.readFileSync(filePath, "utf-8"); + + // Check if file is empty or contains only whitespace + if (!raw.trim()) { + console.warn(`Skipping empty file: ${filePath}`); + continue; + } + + const data = JSON.parse(raw); + + const modified = replaceAssetRefsInObject(data, assetsToReuse); + if (modified) { + fs.writeFileSync(filePath, JSON.stringify(data), "utf-8"); + writeLogEntry(`Replaced asset refs in ${filePath}`, "removeExistingAssets", loggerPath); + } + } catch (error) { + console.error(`Failed to process file ${filePath}:`, error instanceof Error ? error.message : String(error)); + console.warn(`Skipping problematic file: ${filePath}`); + continue; // Skip this file and continue with others + } + } + } + } + } + + // 2. Remove deduplicated assets from index.json + for (const assetId of assetsToRemoveFromIndex) { + delete indexData[assetId]; + writeLogEntry(`Asset "${assetId}" has been removed from migration data (already exists in Contentstack)`, "removeExistingAssets", loggerPath); + } + fs.writeFileSync(indexPath, JSON.stringify(indexData, null, 4), "utf-8"); + writeLogEntry(`Removed ${assetsToRemoveFromIndex.length} assets from index.json`, "removeExistingAssets", loggerPath); + + // 3. Remove asset file folders + const filesDir = path.join(assetsDir, "files"); + if (fs.existsSync(filesDir)) { + for (const assetId of assetsToRemoveFromIndex) { + const assetFolder = path.join(filesDir, assetId); + if (fs.existsSync(assetFolder)) { + fs.rmSync(assetFolder, { recursive: true, force: true }); + writeLogEntry(`Removed asset folder: ${assetFolder}`, "removeExistingAssets", loggerPath); + writeLogEntry(`Asset "${assetId}" physical files have been removed from migration data`, "removeExistingAssets", loggerPath); + } + } + } + + writeLogEntry( + `Asset dedup complete: ${assetsToReuse.size} reused, ` + + `${Object.keys(indexData).length} remaining for import.`, + "removeExistingAssets", + loggerPath + ); +}; diff --git a/api/src/utils/content-type-checker.utils.ts b/api/src/utils/content-type-checker.utils.ts new file mode 100644 index 000000000..d816eeb3c --- /dev/null +++ b/api/src/utils/content-type-checker.utils.ts @@ -0,0 +1,122 @@ +import path from 'path'; +import fs from 'fs'; +import getContentTypesMapperDb from '../models/contentTypesMapper-lowdb.js'; + +/** + * Checks if a content type has already been created in any previous iteration. + * This prevents duplicate content type creation during delta migrations. + * + * @param projectId - The project ID to check + * @param contentTypeUid - The content type UID (otherCmsUid) to check + * @param currentIteration - The current iteration number + * @returns true if content type already exists in previous iterations, false otherwise + */ +export const isContentTypeAlreadyCreated = async ( + projectId: string, + contentTypeUid: string, + currentIteration: number +): Promise => { + // For iteration 1, no previous iterations exist + if (currentIteration <= 1) { + return false; + } + + // Check all previous iterations (1 to currentIteration-1) + for (let i = 1; i < currentIteration; i++) { + try { + // Check if iteration directory exists + const iterationPath = path.join(process.cwd(), 'database', projectId, i.toString()); + if (!fs.existsSync(iterationPath)) { + continue; // Skip missing iterations + } + + // Read the contentTypesMapper database for this iteration + const contentTypesMapperDb = getContentTypesMapperDb(projectId, i); + await contentTypesMapperDb.read(); + + // Check if any content type matches the given UID + const contentTypes = contentTypesMapperDb.data?.ContentTypesMappers || []; + const exists = contentTypes.some( + (ct: any) => ct.otherCmsUid === contentTypeUid + ); + + if (exists) { + console.info(`Content type '${contentTypeUid}' already created in iteration ${i}`); + return true; + } + } catch (error) { + console.warn(`Failed to check iteration ${i} for content type '${contentTypeUid}' : `, error); + // Continue checking other iterations even if one fails + continue; + } + } + + return false; +}; + +/** + * Gets all content type UIDs that have been created in previous iterations. + * Useful for bulk checking or debugging purposes. + * + * @param projectId - The project ID to check + * @param currentIteration - The current iteration number + * @returns Array of content type UIDs that already exist + */ +export const getPreviouslyCreatedContentTypes = async ( + projectId: string, + currentIteration: number +): Promise => { + const existingContentTypes = new Set(); + + // For iteration 1, no previous iterations exist + if (currentIteration <= 1) { + return []; + } + + // Check all previous iterations + for (let i = 1; i < currentIteration; i++) { + try { + // Check if iteration directory exists + const iterationPath = path.join(process.cwd(), 'database', projectId, i.toString()); + if (!fs.existsSync(iterationPath)) { + continue; // Skip missing iterations + } + + // Read the contentTypesMapper database for this iteration + const contentTypesMapperDb = getContentTypesMapperDb(projectId, i); + await contentTypesMapperDb.read(); + + + // Collect all content type UIDs from this iteration + const contentTypes = contentTypesMapperDb.data?.ContentTypesMappers || []; + contentTypes.forEach((ct: any) => { + if (ct.otherCmsUid) { + existingContentTypes.add(ct.otherCmsUid); + } + }); + } catch (error) { + console.warn(`Failed to read iteration ${i}: `, error); + // Continue checking other iterations + continue; + } + } + + return Array.from(existingContentTypes); +}; + +/** + * Checks if a content type should be skipped during creation. + * This is a convenience wrapper around isContentTypeAlreadyCreated. + * + * @param projectId - The project ID to check + * @param contentTypeUid - The content type UID to check + * @param currentIteration - The current iteration number + * @returns true if content type should be skipped, false if it should be created + */ +export const shouldSkipContentTypeCreation = async ( + projectId: string, + contentTypeUid: string, + currentIteration: number +): Promise => { + return await isContentTypeAlreadyCreated(projectId, contentTypeUid, currentIteration) +}; \ No newline at end of file diff --git a/api/src/utils/entry-duplicate.utils.ts b/api/src/utils/entry-duplicate.utils.ts new file mode 100644 index 000000000..62f446058 --- /dev/null +++ b/api/src/utils/entry-duplicate.utils.ts @@ -0,0 +1,30 @@ +import getEntryMapperDb from "../models/EntryMapper.js"; +import ProjectModelLowdb from "../models/project-lowdb.js"; + +export const isDuplicateEntry = async (projectId: string) => { + await ProjectModelLowdb.read(); + const projectData = ProjectModelLowdb.chain + .get("projects") + .find({ id: projectId }) + .value(); + const iteration = projectData?.iteration || 1; + const entryMapper = getEntryMapperDb(projectId, iteration); + await entryMapper.read(); + // const entryMapperData = entryMapper.chain.get("entry_mapper").value(); + const seen = new Map(); + + await entryMapper.update((data: any) => { + data?.entry_mapper?.forEach((item: any, index: number) => { + const key = `${item.contentTypeId}_${item.language}_${item.entryName}`; + + if (seen.has(key)) { + const firstIndex = seen.get(key); + data.entry_mapper[firstIndex].isDuplicateEntry = true; + item.isDuplicateEntry = true; + } + else{ + seen.set(key, index); + } + }); + }); +}; diff --git a/api/src/utils/entry-update-script.cjs b/api/src/utils/entry-update-script.cjs new file mode 100644 index 000000000..173c3c9a9 --- /dev/null +++ b/api/src/utils/entry-update-script.cjs @@ -0,0 +1,184 @@ +"use strict"; + +const isAssetField = (value) => + value && typeof value === 'object' && !Array.isArray(value) && + 'urlPath' in value && 'filename' in value; + +/** Export JSON metadata — not Contentstack content-type field UIDs (WordPress entries are flat). */ +const FLAT_PAYLOAD_SKIP = new Set([ + 'uid', + 'publish_details', + 'locale', + 'tags', + 'ACL', + '_version', + 'created_at', + 'updated_at', + 'created_by', + 'updated_by', + '_content_type_uid', + 'content', +]); + +/** + * 3-way asset resolution: + * 1. newMapping has a UID for this source asset → asset was re-imported, always use it + * 2. oldMapping has a UID but stack differs → user changed it manually, keep user's choice + * 3. oldMapping has a UID and stack matches → nothing changed, keep as-is + * 4. No mapping at all → keep whatever is on the stack + */ +const resolveAssetField = (fieldName, entryUid, updateValue, stackValue, oldMapping, newMapping) => { + const sourceCmsUid = updateValue?.uid; + if (!sourceCmsUid) { + return stackValue || updateValue; + } + + const newMappedUid = newMapping[sourceCmsUid]; + const oldMappedUid = oldMapping[sourceCmsUid]; + const currentStackUid = stackValue?.uid; + + if (newMappedUid) { + console.info(`[${entryUid}] "${fieldName}": Using newly imported asset "${newMappedUid}" (source: ${sourceCmsUid})`); + if (stackValue && typeof stackValue === 'object') { + return { ...stackValue, uid: newMappedUid }; + } + return { uid: newMappedUid }; + } + + if (oldMappedUid && currentStackUid && currentStackUid !== oldMappedUid) { + console.info(`[${entryUid}] "${fieldName}": Keeping user-modified asset "${currentStackUid}" (original was: ${oldMappedUid})`); + return stackValue; + } + + if (oldMappedUid) { + console.info(`[${entryUid}] "${fieldName}": Keeping original mapped asset "${oldMappedUid}"`); + if (stackValue && typeof stackValue === 'object') { + return stackValue; + } + return { ...updateValue, uid: oldMappedUid }; + } + + console.info(`[${entryUid}] "${fieldName}": No mapping found, keeping stack asset`); + return stackValue || updateValue; +}; + +/** + * WordPress (and similar) write migration JSON with fields at the root (email, url, …). + * Fetched stack entries keep custom fields under entry.content — merge flat updateData there. + */ +const mergeFlatPayloadIntoEntry = async (entry, entryUid, updateData, oldMapping, newMapping) => { + for (const field of Object.keys(updateData)) { + if (FLAT_PAYLOAD_SKIP.has(field)) { + continue; + } + if (field === 'title') { + if (updateData.title !== undefined && updateData.title !== null) { + entry.title = updateData.title; + } + continue; + } + let nextVal = updateData[field]; + if (isAssetField(nextVal)) { + nextVal = resolveAssetField( + field, + entryUid, + nextVal, + entry.content[field], + oldMapping, + newMapping + ); + } + entry.content[field] = nextVal; + } + await entry.update(); +}; + +module.exports = async ({ + migration, + config, + stackSDKInstance +}) => { + const assetMapping = config.__assetMapping__ || { old: {}, new: {} }; + delete config.__assetMapping__; + + const oldMapping = assetMapping.old || {}; + const newMapping = assetMapping.new || {}; + console.info(`Asset mappings loaded — old: ${Object.keys(oldMapping).length}, new: ${Object.keys(newMapping).length}`); + + const contentTypes = Object.keys(config); + console.info('contentTypes', contentTypes); + + const updateEntryTask = () => { + return { + title: "Update Entries", + successMessage: 'Entries Updated Successfully', + failedMessage: "Failed to update entries", + task: async () => { + try { + for (const contentType of contentTypes) { + const entryUids = Object.keys(config[contentType]); + console.info(`Processing content type: ${contentType}, entries: ${entryUids.length}`); + + for (const entryUid of entryUids) { + const entryRef = stackSDKInstance + .contentType(contentType) + .entry(entryUid); + + + const entry = await entryRef.fetch(); + const updateData = JSON.parse(JSON.stringify(config[contentType][entryUid])); + + const hasStackContent = entry.content && typeof entry.content === 'object'; + const hasNestedUpdate = updateData.content && typeof updateData.content === 'object'; + + if (hasStackContent && hasNestedUpdate) { + for (const field of Object.keys(updateData.content)) { + if (isAssetField(updateData.content[field])) { + updateData.content[field] = resolveAssetField( + field, + entryUid, + updateData.content[field], + entry.content[field], + oldMapping, + newMapping + ); + } + } + Object.assign(entry.content, updateData.content); + await entry.update(); + } else if (hasStackContent) { + console.info(`[${entryUid}] Merging flat migration payload into entry.content (e.g. WordPress export)`); + await mergeFlatPayloadIntoEntry(entry, entryUid, updateData, oldMapping, newMapping); + } else { + if (updateData && entry) { + for (const field of Object.keys(updateData)) { + if (isAssetField(updateData[field])) { + console.info('field is asset field'); + updateData[field] = resolveAssetField( + field, + entryUid, + updateData[field], + entry[field], + oldMapping, + newMapping + ); + } + } + } + Object.assign(entry, updateData); + await entry.update(); + } + console.info(`Updated entry: ${entryUid}`); + } + } + console.info('All entries updated successfully'); + } catch (error) { + console.error(error); + throw error; + } + }, + }; + }; + + migration.addTask(updateEntryTask()); +}; diff --git a/api/src/utils/entry-update.utils.ts b/api/src/utils/entry-update.utils.ts new file mode 100644 index 000000000..d1e8770cd --- /dev/null +++ b/api/src/utils/entry-update.utils.ts @@ -0,0 +1,192 @@ +import getEntryMapperDb from "../models/EntryMapper.js"; +import ProjectModelLowdb from "../models/project-lowdb.js"; +import path from "path"; +import fs from "node:fs"; +import { MIGRATION_DATA_CONFIG } from "../constants/index.js"; + +/** + * Helper function to write log entries to file + */ +const writeLogEntry = (message: string, methodName: string, loggerPath?: string) => { + if (loggerPath) { + const directLogEntry = { + level: 'info', + message, + methodName, + timestamp: new Date().toISOString(), + }; + fs.appendFileSync(loggerPath, JSON.stringify(directLogEntry) + '\n'); + } +}; + +// export const getEntriesToUpdate = async (projectId: string) => { +// await ProjectModelLowdb.read(); +// const projectData = ProjectModelLowdb.chain +// .get("projects") +// .find({ id: projectId }) +// .value(); +// const iteration = projectData?.iteration || 1; +// const updateEntryDataDb = getEntryMapperDb(projectId, iteration); +// await updateEntryDataDb.read(); +// const entriesToUpdate = updateEntryDataDb.chain.get("entry_mapper").filter({ isUpdate: true }).value(); +// return entriesToUpdate; +// }; + +export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: string): Promise => { + const entriesToUpdate: Record> = {}; + + await ProjectModelLowdb.read(); + const projectData = ProjectModelLowdb.chain + .get("projects") + .find({ id: projectId }) + .value(); + const iteration = projectData?.iteration || 1; + const stackId = projectData?.destination_stack_id; + const updateEntryDataDb = getEntryMapperDb(projectId, iteration); + await updateEntryDataDb.read(); + + const entryMapperItems = updateEntryDataDb.chain.get("entry_mapper").value(); + if (!entryMapperItems?.length || !stackId) { + writeLogEntry("No entry mapper items found or stackId missing, skipping removal.", "removeEntriesFromDatabase", loggerPath); + return null; + } + + const sitecoreUids = new Set( + entryMapperItems.map((item: { otherCmsEntryUid: string }) => item.otherCmsEntryUid) + ); + + const updateUidMap = new Map(); + for (const item of entryMapperItems) { + if (item.isUpdate) { + updateUidMap.set(item.otherCmsEntryUid, item.contentstackEntryUid); + } + } + + const entriesDir = path.join( + process.cwd(), + MIGRATION_DATA_CONFIG.DATA, + stackId, + MIGRATION_DATA_CONFIG.ENTRIES_DIR_NAME + ); + + if (!fs.existsSync(entriesDir)) { + writeLogEntry(`Entries directory not found: ${entriesDir}`, "removeEntriesFromDatabase", loggerPath); + return null; + } + + const contentTypeDirs = fs.readdirSync(entriesDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()); + + for (const ctDir of contentTypeDirs) { + const contentTypeName = ctDir.name; + const ctPath = path.join(entriesDir, contentTypeName); + const localeDirs = fs.readdirSync(ctPath, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()); + + for (const localeDir of localeDirs) { + const localePath = path.join(ctPath, localeDir.name); + const jsonFiles = fs.readdirSync(localePath) + .filter((file) => file.endsWith(".json") && file !== "index.json"); + + for (const jsonFile of jsonFiles) { + const filePath = path.join(localePath, jsonFile); + const raw = fs.readFileSync(filePath, "utf-8"); + const data = JSON.parse(raw); + + let modified = false; + for (const key of Object.keys(data)) { + if (sitecoreUids.has(key)) { + const csEntryUid = updateUidMap.get(key); + if (csEntryUid) { + const entryData = { ...data[key] }; + delete entryData.uid; + + if (!entriesToUpdate[contentTypeName]) { + entriesToUpdate[contentTypeName] = {}; + } + entriesToUpdate[contentTypeName][csEntryUid] = entryData; + writeLogEntry(`Collected update entry "${csEntryUid}" for content type "${contentTypeName}"`, "removeEntriesFromDatabase", loggerPath); + writeLogEntry(`Entry "${key}" has been prepared for update in Contentstack as "${csEntryUid}"`, "removeEntriesFromDatabase", loggerPath); + } + + delete data[key]; + modified = true; + writeLogEntry(`Removed entry "${key}" from ${filePath}`, "removeEntriesFromDatabase", loggerPath); + writeLogEntry(`Entry "${key}" has been removed from migration data (will be updated instead of created)`, "removeEntriesFromDatabase", loggerPath); + } + } + + if (modified) { + fs.writeFileSync(filePath, JSON.stringify(data), "utf-8"); + } + } + } + } + + const configDir = path.join(process.cwd(), "database", projectId, iteration.toString()); + fs.mkdirSync(configDir, { recursive: true }); + const configPath = path.join(configDir, "updated-entries.json"); + fs.writeFileSync(configPath, JSON.stringify(entriesToUpdate), "utf-8"); + + writeLogEntry("Finished removing entries from cmsMigrationData.", "removeEntriesFromDatabase", loggerPath); + writeLogEntry(`Config written to: ${configPath}`, "removeEntriesFromDatabase", loggerPath); + writeLogEntry(`Total entries prepared for update: ${Object.keys(entriesToUpdate).reduce((total, ct) => total + Object.keys(entriesToUpdate[ct]).length, 0)}`, "removeEntriesFromDatabase", loggerPath); + return configPath; +}; + +/** + * Reads old (previous iteration) and new (current iteration) asset uid mappings + * and merges them into the updated-entries config file under __assetMapping__. + * This allows the entry-update-script to resolve asset references using a 3-way comparison: + * - newMapping: asset just re-imported in this iteration → always wins + * - oldMapping vs stack: detect if user manually changed the asset + */ +export const enrichConfigWithAssetMapping = ( + configFilePath: string, + projectId: string, + iteration: number, + loggerPath?: string +): void => { + const dbBase = path.join(process.cwd(), "database", projectId); + + let oldAssetMapping: Record = {}; + if (iteration > 1) { + const oldPath = path.join(dbBase, (iteration - 1).toString(), "uid-mapper.json"); + if (fs.existsSync(oldPath)) { + try { + const data = JSON.parse(fs.readFileSync(oldPath, "utf-8")); + oldAssetMapping = data.assets || {}; + writeLogEntry(`Loaded ${Object.keys(oldAssetMapping).length} old asset mappings from iteration ${iteration - 1}`, "enrichConfigWithAssetMapping", loggerPath); + } catch (err) { + console.error("Failed to read old uid-mapper:", err); + } + } else { + writeLogEntry(`No old asset mapping found for iteration ${iteration - 1}`, "enrichConfigWithAssetMapping", loggerPath); + } + } + + let newAssetMapping: Record = {}; + const newPath = path.join(dbBase, iteration.toString(), "uid-mapper.json"); + if (fs.existsSync(newPath)) { + try { + const data = JSON.parse(fs.readFileSync(newPath, "utf-8")); + newAssetMapping = data.assets || {}; + writeLogEntry(`Loaded ${Object.keys(newAssetMapping).length} new asset mappings from iteration ${iteration}`, "enrichConfigWithAssetMapping", loggerPath); + } catch (err) { + console.error("Failed to read new uid-mapper:", err); + } + } else { + writeLogEntry(`No new asset mapping found for iteration ${iteration}`, "enrichConfigWithAssetMapping", loggerPath); + } + + // const config = JSON.parse(fs.readFileSync(configFilePath, "utf-8")); + // config.__assetMapping__ = { + // old: oldAssetMapping, + // new: newAssetMapping, + // }; + // fs.writeFileSync(configFilePath, JSON.stringify(config), "utf-8"); + + writeLogEntry(`Asset mapping enriched into config: old=${Object.keys(oldAssetMapping).length} keys, new=${Object.keys(newAssetMapping).length} keys`, "enrichConfigWithAssetMapping", loggerPath); + writeLogEntry(`Asset mapping configuration has been enriched for iteration ${iteration}`, "enrichConfigWithAssetMapping", loggerPath); + writeLogEntry(`Asset references will be resolved using combined old and new mappings`, "enrichConfigWithAssetMapping", loggerPath); +}; \ No newline at end of file diff --git a/api/src/utils/field-attacher.utils.ts b/api/src/utils/field-attacher.utils.ts index 532f69ac9..0b50afb97 100644 --- a/api/src/utils/field-attacher.utils.ts +++ b/api/src/utils/field-attacher.utils.ts @@ -1,7 +1,8 @@ import ProjectModelLowdb from "../models/project-lowdb.js"; -import ContentTypesMapperModelLowdb from "../models/contentTypesMapper-lowdb.js"; -import FieldMapperModel from "../models/FieldMapper.js"; +import getContentTypesMapperDb from "../models/contentTypesMapper-lowdb.js"; +import getFieldMapperDb from "../models/FieldMapper.js"; import { contenTypeMaker } from "./content-type-creator.utils.js"; +import { shouldSkipContentTypeCreation } from "./content-type-checker.utils.js"; export const fieldAttacher = async ({ projectId, orgId, destinationStackId, region, user_id, is_sso }: any) => { await ProjectModelLowdb.read(); @@ -9,6 +10,9 @@ export const fieldAttacher = async ({ projectId, orgId, destinationStackId, regi id: projectId, org_id: orgId, }).value() + const iteration = projectData?.iteration || 1; + const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); + const FieldMapperModel = getFieldMapperDb(projectId, iteration); await ContentTypesMapperModelLowdb.read(); await FieldMapperModel.read(); const contentTypes = []; @@ -27,7 +31,20 @@ export const fieldAttacher = async ({ projectId, orgId, destinationStackId, regi return field; }) } - await contenTypeMaker({ contentType, destinationStackId, projectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) + // await contenTypeMaker({ contentType, destinationStackId, projectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) + if (iteration === 1) { + await contenTypeMaker({ contentType, destinationStackId, projectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) + + } + else { + const shouldSkip = await shouldSkipContentTypeCreation(projectId, contentType.otherCmsUid, iteration); + if (!shouldSkip) { + console.info(`Creating new content type: ${contentType.otherCmsUid}`); + await contenTypeMaker({ contentType, destinationStackId, projectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) + } else { + console.info(`Skipping content type creation: ${contentType.otherCmsUid} (already exists from previous iteration)`); + } + } contentTypes?.push?.(contentType); } } diff --git a/api/src/utils/package.json b/api/src/utils/package.json new file mode 100644 index 000000000..cb27b8a55 --- /dev/null +++ b/api/src/utils/package.json @@ -0,0 +1,10 @@ +{ + "name": "MigrationPackage", + "version": "1.0.0", + "main": "", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "description": "" + } \ No newline at end of file diff --git a/package.json b/package.json index a78e68d59..7cd8d1202 100644 --- a/package.json +++ b/package.json @@ -58,4 +58,4 @@ "@contentstack/cli-utilities": "^1.17.1", "qs": "^6.14.2" } -} +} \ No newline at end of file diff --git a/ui/package.json b/ui/package.json index 17367d847..8731dea78 100644 --- a/ui/package.json +++ b/ui/package.json @@ -75,4 +75,4 @@ } } } -} +} \ No newline at end of file diff --git a/ui/src/components/ContentMapper/contentMapper.interface.ts b/ui/src/components/ContentMapper/contentMapper.interface.ts index 7b3b75024..23a52ac58 100644 --- a/ui/src/components/ContentMapper/contentMapper.interface.ts +++ b/ui/src/components/ContentMapper/contentMapper.interface.ts @@ -224,3 +224,16 @@ export interface ModifiedField { _canSelect?: boolean; contentstackFieldType?: string; } + +export interface EntryMapperType { + id: string; + projectId: string; + contentTypeId: string; + contentTypeUid: string; + entryName: string; + otherCmsEntryUid: string; + isUpdate: boolean; + contentstackEntryUid?: string; + _canSelect?: boolean; + isDuplicateEntry?: boolean; +} \ No newline at end of file diff --git a/ui/src/components/ContentMapper/entryMapper.tsx b/ui/src/components/ContentMapper/entryMapper.tsx new file mode 100644 index 000000000..8d913de12 --- /dev/null +++ b/ui/src/components/ContentMapper/entryMapper.tsx @@ -0,0 +1,432 @@ +// Libraries +import { useEffect, useState} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; +import { + Button, + InfiniteScrollTable, + Notification, + +} from '@contentstack/venus-components'; + +// Services +import { getCMSDataFromFile } from '../../cmsData/cmsSelector'; +import { + getContentTypes, + getEntryMapping, + updateEntryMapper, +} from '../../services/api/migration.service'; + +// Redux +import { RootState } from '../../store'; +import { updateMigrationData } from '../../store/slice/migrationDataSlice'; + +// Utilities +import { CS_ENTRIES } from '../../utilities/constants'; +import useBlockNavigation from '../../hooks/userNavigation'; + +// Interface +import { DEFAULT_CONTENT_MAPPING_DATA } from '../../context/app/app.interface'; +import { + ContentType, + TableTypes, + UidMap, + EntryMapperType +} from './contentMapper.interface'; +import { ItemStatusMapProp } from '@contentstack/venus-components/build/components/Table/types'; + + +// Styles and Assets +import './index.scss'; + +const EntryMapper = ({selectedContentTypeId, tableHeight}: {selectedContentTypeId: ContentType | null, tableHeight: number}) => { + // Redux State + const dispatch = useDispatch(); + + const { projectId = '' } = useParams<{ projectId: string }>(); + + const newMigrationData = useSelector((state: RootState) => state?.migration?.newMigrationData); + const selectedOrganisation = useSelector((state: RootState) => state?.authentication?.selectedOrganisation); + + // Component State + const [tableData, setTableData] = useState([]); + const [loading, setLoading] = useState(false); + const [totalCounts, setTotalCounts] = useState(tableData?.length); + const [searchText, setSearchText] = useState(''); + const [selectedContentType, setSelectedContentType] = useState(selectedContentTypeId); + const [contentTypes, setContentTypes] = useState([]); + const [itemStatusMap, setItemStatusMap] = useState({}); + + const [otherCmsTitle, setOtherCmsTitle] = useState(''); + const [contentTypeUid, setContentTypeUid] = useState(selectedContentTypeId?.id || ''); + + const [isContentType, setIsContentType] = useState(true); + + const [otherCmsUid, setOtherCmsUid] = useState(contentTypes?.[0]?.otherCmsUid); + const [rowIds, setRowIds] = useState>({}); + const [persistedRowIds, setPersistedRowIds] = useState>({}); + const [isLoadingSaveButton, setisLoadingSaveButton] = useState(false); + const [initialRowSelectedData, setInitialRowSelectedData] = useState([]); + + + + /********** ALL USEEFFECT HERE *************/ + useEffect(() => { + //check if offline CMS data field is set to true, if then read data from cms data file. + getCMSDataFromFile(CS_ENTRIES.CONTENT_MAPPING) + .then((data) => { + //Check for null + if (!data) { + dispatch(updateMigrationData({ contentMappingData: DEFAULT_CONTENT_MAPPING_DATA })); + return; + } + + dispatch(updateMigrationData({ contentMappingData: data })); + }) + .catch((err) => { + console.error(err); + }); + + fetchContentTypes(searchText || ''); + }, []); + + useEffect(() => { + if (selectedContentTypeId) { + fetchEntries(selectedContentTypeId?.id || '', searchText); + setOtherCmsTitle(selectedContentTypeId?.otherCmsTitle); + } + + },[selectedContentTypeId]); + + const buildSelectedRowIds = (entries: EntryMapperType[]) => { + return (entries ?? []).reduce((acc, item) => { + if (item?._canSelect && item?.isUpdate) { + acc[item.id] = true; + } + return acc; + }, {}); + }; + + const applySelectionToEntries = ( + entries: EntryMapperType[], + selected: Record, + ) => { + return (entries ?? []).map((item) => { + if (!item?._canSelect) return item; + return { + ...item, + isUpdate: !!selected?.[item.id], + }; + }); + }; + + const fetchContentTypes = async (searchText: string) => { + //setIsLoading(true); + + try { + const { data } = await getContentTypes(projectId || '', 0, 5000, ''); //org id will always present + + //setIsLoading(false); + setContentTypes(data?.contentTypes); + //setCount(data?.contentTypes?.length); + //setFilteredContentTypes(data?.contentTypes); + setSelectedContentType(data?.contentTypes?.[0]); + //setTotalCounts(data?.contentTypes?.[0]?.fieldMapping?.length); + setOtherCmsTitle(data?.contentTypes?.[0]?.otherCmsTitle); + setContentTypeUid(data?.contentTypes?.[0]?.id); + // fetchFields(data?.contentTypes?.[0]?.id, searchText || ''); + fetchEntries(data?.contentTypes?.[0]?.id, searchText ?? ''); + setOtherCmsUid(data?.contentTypes?.[0]?.otherCmsUid); + setIsContentType(data?.contentTypes?.[0]?.type === "content_type"); + } catch (error) { + console.error(error); + return error; + } + }; + + // Method to get fieldmapping + const fetchEntries = async (contentTypeId: string, searchText: string) => { + try { + const itemStatusMap: ItemStatusMapProp = {}; + + for (let index = 0; index <= 1000; index++) { + itemStatusMap[index] = 'loading'; + } + + setItemStatusMap(itemStatusMap); + setLoading(true); + + const { data } = await getEntryMapping(contentTypeId || '', 0, 1000, searchText, projectId); + + for (let index = 0; index <= 1000; index++) { + itemStatusMap[index] = 'loaded'; + } + + setItemStatusMap({ ...itemStatusMap }); + setLoading(false); + + const validTableData: EntryMapperType[] = (data?.entryMapping ?? []).map((entry: EntryMapperType) => ({ + ...entry, + _canSelect: !!entry?.contentstackEntryUid, + })); + + //setIsAllCheck(true); + const initialSelected = buildSelectedRowIds(validTableData ?? []); + setTableData(validTableData ?? []); + setRowIds(initialSelected); + setPersistedRowIds(initialSelected); + setTotalCounts(validTableData?.length); + setInitialRowSelectedData(validTableData?.filter((item: EntryMapperType) => !item?.isUpdate)) + + } catch (error) { + console.error('fetchData -> error', error); + } + }; + + // Fetch table data + const fetchData = async ({ searchText }: TableTypes) => { + setSearchText(searchText) + selectedContentTypeId?.id && fetchEntries(selectedContentTypeId?.id, searchText); + }; + + // Method for Load more table data + const loadMoreItems = async ({ searchText, skip, limit, startIndex, stopIndex }: TableTypes) => { + try { + const itemStatusMapCopy: ItemStatusMapProp = { ...itemStatusMap }; + + for (let index = startIndex; index <= stopIndex; index++) { + itemStatusMapCopy[index] = 'loading'; + } + + setItemStatusMap({ ...itemStatusMapCopy }); + setLoading(true); + + const { data } = await getEntryMapping(contentTypeUid || '', skip, limit, searchText || '', projectId); + + const updateditemStatusMapCopy: ItemStatusMapProp = { ...itemStatusMap }; + + for (let index = startIndex; index <= stopIndex; index++) { + updateditemStatusMapCopy[index] = 'loaded'; + } + + setItemStatusMap({ ...updateditemStatusMapCopy }); + setLoading(false); + + const validTableData: EntryMapperType[] = (data?.entryMapping ?? []).map((entry: EntryMapperType) => ({ + ...entry, + _canSelect: !!entry?.contentstackEntryUid, + })); + + // eslint-disable-next-line no-unsafe-optional-chaining + setTableData(applySelectionToEntries(validTableData ?? [], rowIds)); + + } catch (error) { + console.error('loadMoreItems -> error', error); + } + }; + + /** + * Handle the selected entries + * @param singleSelectedRowIds - The single selected row IDs + * @returns void + */ + const handleSelectedEntries = (singleSelectedRowIds: string[]) => { + const selectedObj: UidMap = {}; + singleSelectedRowIds?.forEach((uid: string) => { + selectedObj[uid] = true; + }); + + setRowIds(selectedObj); + setTableData((prev) => applySelectionToEntries(prev ?? [], selectedObj)); + }; + + const handleSaveContentType = async () => { + console.info("handleSaveContentType", rowIds); + setisLoadingSaveButton(true); + const allKeys = new Set([ + ...Object.keys(rowIds ?? {}), + ...Object.keys(persistedRowIds ?? {}), + ]); + const changedUids = Array.from(allKeys).filter( + (uid) => !!rowIds?.[uid] !== !!persistedRowIds?.[uid], + ); + const orgId = selectedOrganisation?.uid; + // const projectID = projectId; + + if (orgId && contentTypeUid) { + const dataCs = { + ids: changedUids + }; + try { + if (changedUids.length === 0) { + setisLoadingSaveButton(false); + return Notification({ + notificationContent: { text: 'No changes to save' }, + notificationProps: { + position: 'bottom-center', + hideProgressBar: true + }, + type: 'info' + }); + } + const {data, status} = await updateEntryMapper(projectId, dataCs); + console.info("status", status, typeof status, data); + + setisLoadingSaveButton(false); + if (status === 200) { + setPersistedRowIds({ ...(rowIds ?? {}) }); + setLoading(false); + return Notification({ + notificationContent: { text: 'Entries saved successfully' }, + notificationProps: { + position: 'bottom-center', + hideProgressBar: true + }, + type: 'success' + }); + } + else{ + setisLoadingSaveButton(false); + return Notification({ + notificationContent: { text: 'Failed to save entries' }, + notificationProps: { + position: 'bottom-center', + hideProgressBar: true + }, + type: 'error' + }); + } + } catch (error) { + console.error(error); + setisLoadingSaveButton(false); + return error; + } + + } + } + const accessorCall = (data: EntryMapperType) => { + // Clean field name (remove parent hierarchy) + const cleanFieldName = data?.entryName + return ( +
+
+
+ {cleanFieldName} +
+
+
+ ); + }; + + const accessorContentstackCall = (data: EntryMapperType) => { + // Clean field name (remove parent hierarchy) + const cleanFieldName = data?.contentstackEntryUid + return ( +
+
+
+ {cleanFieldName ? cleanFieldName : '-'} +
+
+
+ + ); + }; + + const accessorForCMSUid = (data: EntryMapperType) => { + const cleanFieldName = data?.otherCmsEntryUid + return ( +
+
+
+ {cleanFieldName ? cleanFieldName : '-'} +
+
+
+ ); + } + + const columns = [ + { + disableSortBy: true, + Header: ( + + {`${newMigrationData?.legacy_cms?.selectedCms?.title}: ${otherCmsTitle}`} + + ), + accessor: accessorCall, + id: 'uuid', + width: '250px', + }, + { + disableSortBy: true, + Header: ( + + {`${newMigrationData?.legacy_cms?.selectedCms?.title} UIDs:`} + + ), + accessor: accessorForCMSUid, + id: '1' + }, + { + disableSortBy: true, + Header: ( + + {'Contentstack UIDs:'} + + ), + accessor: accessorContentstackCall, + id: '2' + } + ]; + + return ( +
+ 0 ? [...tableData] : []} + data={[...tableData]} + columns={columns} + uniqueKey={'id'} + isRowSelect={true} + fullRowSelect={true} + itemStatusMap={itemStatusMap} + //searchPlaceholder={tableSearchPlaceholder} + fetchTableData={fetchData} + loadMoreItems={loadMoreItems} + tableHeight={tableHeight} + equalWidthColumns={true} + columnSelector={false} + // initialRowSelectedData={initialRowSelectedData} + initialSelectedRowIds={rowIds} + itemSize={80} + getSelectedRow={handleSelectedEntries} + rowSelectCheckboxProp={{ key: '_canSelect', value: true }} + name={{ + singular: '', + plural: `${totalCounts === 0 ? 'Count' : ''}` + }} + + /> +
+
Total Entries: {totalCounts}
+ +
+ + +
+ + ) +} +export default EntryMapper; \ No newline at end of file diff --git a/ui/src/components/ContentMapper/index.scss b/ui/src/components/ContentMapper/index.scss index 09a207a19..79eda4b80 100644 --- a/ui/src/components/ContentMapper/index.scss +++ b/ui/src/components/ContentMapper/index.scss @@ -140,56 +140,58 @@ .table-container { flex: 1 0 auto; } -.table-wrapper { - flex: 1; - .TablePanel { - border-left: 0 none; - .TablePanel__list-count { - display: none; +.content-mapper-container { + .table-wrapper { + flex: 1; + .TablePanel { + border-left: 0 none; + .TablePanel__list-count { + display: none; + } } - } - .Table { - border-left: 0 none; - min-height: 24.25rem; - .Table__body__row { + .Table { + border-left: 0 none; + min-height: 24.25rem; + .Table__body__row { .Table-select-body { - >.checkbox-wrapper { - align-items: flex-start; + > .checkbox-wrapper { + align-items: flex-start; + } } } - } - .Table__body__column { - padding: 0 1.25rem; - &:not(:last-of-type) { - display: block; + .Table__body__column { + padding: 0 1.25rem; + &:not(:last-of-type) { + display: block; + } } - } - .Table-select-body { - width: 68px; - } - .cms-field { - // text-transform: capitalize; - // overflow: hidden; - // text-overflow: ellipsis; - // white-space: nowrap; - // width: 445px; + .Table-select-body { + width: 68px; + } + .cms-field { + // text-transform: capitalize; + // overflow: hidden; + // text-overflow: ellipsis; + // white-space: nowrap; + // width: 445px; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 1; - overflow: hidden; - text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + overflow: hidden; + text-overflow: ellipsis; + } + .InstructionText { + font-size: $size-font-small; + margin: 0; + } + .EmptyStateWrapper { + margin-top: $px-20; + } } - .InstructionText { - font-size: $size-font-small; + .import-cta { margin: 0; } - .EmptyStateWrapper { - margin-top: $px-20; - } - } - .import-cta { - margin: 0; } } .disabled-field { diff --git a/ui/src/components/ContentMapper/index.tsx b/ui/src/components/ContentMapper/index.tsx index 6351ebb1f..942c441d3 100644 --- a/ui/src/components/ContentMapper/index.tsx +++ b/ui/src/components/ContentMapper/index.tsx @@ -84,6 +84,7 @@ import { // Styles and Assets import './index.scss'; import { NoDataFound, SCHEMA_PREVIEW } from '../../common/assets'; +import EntryMapper from './entryMapper'; /** Renders the menu in the document body so `menuPlacement="auto"` matches the control when inside scroll/overflow containers (e.g. InfiniteScrollTable). */ const CONTENT_MAPPER_SELECT_MENU_PORTAL = @@ -300,7 +301,9 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: const migrationData = useSelector((state: RootState) => state?.migration?.migrationData); const newMigrationData = useSelector((state: RootState) => state?.migration?.newMigrationData); const selectedOrganisation = useSelector((state: RootState) => state?.authentication?.selectedOrganisation); - + const iteration = useSelector( + (state: RootState) => state?.migration?.newMigrationData?.iteration + ); // When setting contentModels from Redux, ensure it's cloned const reduxContentTypes = newMigrationData?.content_mapping?.existingCT; // Assume this gets your Redux state const reduxGlobalFields = newMigrationData?.content_mapping?.existingGlobal @@ -368,6 +371,7 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: const [activeFilter, setActiveFilter] = useState(''); const [isAllCheck, setIsAllCheck] = useState(false); const [isResetFetch, setIsResetFetch] = useState(false); + const [iterationCount, setIterationCount] = useState(newMigrationData?.iteration); /** ALL HOOKS Here */ @@ -398,6 +402,14 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: fetchContentTypes(searchText || ''); }, []); + useEffect(() => { + const currentIteration = newMigrationData?.iteration || 1; + if (currentIteration !== iterationCount) { + setIterationCount(currentIteration); + fetchContentTypes(searchText || ''); + } + }, [newMigrationData?.iteration, iterationCount, searchText]); + // Make title and url field non editable useEffect(() => { tableData?.forEach((field) => { @@ -3265,6 +3277,15 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: {/* Content Type Fields */}
+ {iteration > 1 ? ( +
+ +
+ ): ( +
+ )} +
: { const isSQL = fileFormatId?.toLowerCase() === 'sql'; + const newMigrationData = useSelector((state: RootState) => state?.migration?.newMigrationData); + const [isEditing, setIsEditing] = useState((newMigrationData?.iteration > 1 && !newMigrationData?.legacy_cms?.uploadedFile?.isValidated) ? true : false); + const [localPath, setLocalPath] = useState(fileDetails?.localPath || ''); + const dispatch = useDispatch(); + const currentPath = newMigrationData?.legacy_cms?.uploadedFile?.file_details?.localPath || fileDetails?.localPath || ''; + + + const handleEditFile = async () => { + setIsEditing(true); + setLocalPath(currentPath); + }; + + const handleBlur = async () => { + setIsEditing(false); + + // Update Redux state with new path + const updatedMigrationData = { + ...newMigrationData, + legacy_cms: { + ...newMigrationData?.legacy_cms, + uploadedFile: { + ...newMigrationData?.legacy_cms?.uploadedFile, + name: localPath, + url: localPath, + file_details: { + ...newMigrationData?.legacy_cms?.uploadedFile?.file_details, + localPath: localPath + } + } + } + }; + + dispatch(updateNewMigrationData(updatedMigrationData)); + const fileFormatData = { + "file_path": localPath, + } + }; + return (
@@ -67,8 +105,28 @@ const FileComponent = ( { fileDetails, fileFormatId }: Props ) => ) : fileDetails?.isLocalPath ? ( // ✅ Local path (file or directory — format driven by legacyCms.json)
- +
+ {isEditing ? ( + ) => setLocalPath(e.target.value)} + onBlur={handleBlur} + width="full" + version="v2" + placeholder="Enter local path" + aria-label="local path" + autoFocus + /> + ) : ( + + )}
+ {( +
+ +
+ )} +
) : ( // ✅ AWS S3 details (isLocalPath is false)
diff --git a/ui/src/components/LegacyCms/legacyCms.scss b/ui/src/components/LegacyCms/legacyCms.scss index 550ddb95d..1b460251c 100644 --- a/ui/src/components/LegacyCms/legacyCms.scss +++ b/ui/src/components/LegacyCms/legacyCms.scss @@ -156,6 +156,9 @@ margin-top: 5px; } .file-container { + display: flex; + justify-content: space-between; + align-items: center; overflow: hidden; padding: 0; white-space: normal; @@ -163,6 +166,30 @@ line-clamp: 2; -webkit-box-orient: vertical; width: 540px; + gap: 10px; + + .file-path-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + + .TextInput { + width: 100%; + } + } + + .edit-icon { + flex-shrink: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + + &:hover { + opacity: 0.7; + } + } } .message-container { display: inline-flex; diff --git a/ui/src/components/LogScreen/MigrationLogViewer.tsx b/ui/src/components/LogScreen/MigrationLogViewer.tsx index 0eea6be8b..eba1dccf8 100644 --- a/ui/src/components/LogScreen/MigrationLogViewer.tsx +++ b/ui/src/components/LogScreen/MigrationLogViewer.tsx @@ -52,6 +52,7 @@ const MigrationLogViewer = ({ serverPath }: LogsType) => { ]); const [isModalOpen, setIsModalOpen] = useState(false); const [zoomLevel, setZoomLevel] = useState(1); + const [hasShownCompletionNotification, setHasShownCompletionNotification] = useState(false); const newMigrationData = useSelector((state: RootState) => state?.migration?.newMigrationData); const selectedOrganisation = useSelector( @@ -116,7 +117,21 @@ const MigrationLogViewer = ({ serverPath }: LogsType) => { }, []); useBlockNavigation(isModalOpen); + + useEffect(() => { + if (newMigrationData?.migration_execution?.migrationCompleted) { + dispatch(updateNewMigrationData({ stepValue: 'Restart Migration' })); + } + }, [newMigrationData?.migration_execution?.migrationCompleted, dispatch]); + + // Reset notification flag when a new migration starts + useEffect(() => { + if (newMigrationData?.migration_execution?.migrationStarted && !newMigrationData?.migration_execution?.migrationCompleted) { + setHasShownCompletionNotification(false); + } + }, [newMigrationData?.migration_execution?.migrationStarted, newMigrationData?.migration_execution?.migrationCompleted]); + /** * Scrolls to the top of the logs container. */ @@ -180,8 +195,9 @@ const MigrationLogViewer = ({ serverPath }: LogsType) => { //const logObject = JSON.parse(log); const message = log.message; - if (message === 'Migration Process Completed') { + if (message === 'Migration Process Completed' && !hasShownCompletionNotification) { setIsModalOpen(true); + setHasShownCompletionNotification(true); const newMigrationDataObj: INewMigration = { ...newMigrationData, @@ -189,7 +205,8 @@ const MigrationLogViewer = ({ serverPath }: LogsType) => { ...newMigrationData?.migration_execution, migrationStarted: false, migrationCompleted: true - } + }, + stepValue: 'Restart Migration' }; dispatch(updateNewMigrationData(newMigrationDataObj)); diff --git a/ui/src/components/MigrationFlowHeader/index.tsx b/ui/src/components/MigrationFlowHeader/index.tsx index 196c35ad9..eec19e9f3 100644 --- a/ui/src/components/MigrationFlowHeader/index.tsx +++ b/ui/src/components/MigrationFlowHeader/index.tsx @@ -1,7 +1,7 @@ // Libraries import { useEffect, useState } from 'react'; import { Button, Tooltip } from '@contentstack/venus-components'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { Params, useNavigate, useParams } from 'react-router'; import { RootState } from '../../store'; @@ -11,6 +11,7 @@ import { MigrationResponse } from '../../services/api/service.interface'; // CSS import './index.scss'; +import { updateNewMigrationData } from '../../store/slice/migrationDataSlice'; type MigrationFlowHeaderProps = { handleOnClick: (event: MouseEvent, handleStepChange: (currentStep: number) => void) => void; @@ -44,6 +45,7 @@ const MigrationFlowHeader = ({ (state: RootState) => state?.authentication?.selectedOrganisation ); const newMigrationData = useSelector((state: RootState) => state?.migration?.newMigrationData); + const dispatch = useDispatch(); useEffect(() => { fetchProject(); @@ -62,14 +64,25 @@ const MigrationFlowHeader = ({ navigate(url, { replace: true }); }; - let stepValue; - if (params?.stepId === '3' || params?.stepId === '4') { - stepValue = 'Continue'; - } else if (params?.stepId === '5') { - stepValue = 'Start Migration'; - } else { - stepValue = 'Save and Continue'; - } + useEffect(() => { + let newStepValue; + + // Check conditions in priority order + if (newMigrationData?.legacy_cms?.projectStatus === 5 && newMigrationData?.migration_execution?.migrationCompleted) { + newStepValue = 'Restart Migration'; + } else if (params?.stepId === '5') { + newStepValue = 'Start Migration'; + } else if (params?.stepId === '3' || params?.stepId === '4') { + newStepValue = 'Continue'; + } else { + newStepValue = 'Save and Continue'; + } + + // Only update if the value has changed + if (newStepValue !== newMigrationData?.stepValue) { + dispatch(updateNewMigrationData({ stepValue: newStepValue })); + } + }, [params?.stepId, newMigrationData?.legacy_cms?.projectStatus, newMigrationData?.migration_execution?.migrationCompleted, newMigrationData?.stepValue, dispatch]); const isStep4AndNotMigrated = params?.stepId === '4' && @@ -129,13 +142,13 @@ const MigrationFlowHeader = ({ isProjectStatusThreeAndMapperNotGenerated ? isFileValidated : isStep4AndNotMigrated || - isStepInvalid || - isExecutionStarted || - destinationStackMigrated + isStepInvalid + // isExecutionStarted || + // destinationStackMigrated } > - {stepValue} - + {newMigrationData?.stepValue || 'Save and Continue'} +
); }; diff --git a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx index 3a1be8f44..1e20359e6 100644 --- a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx +++ b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx @@ -83,9 +83,15 @@ const HorizontalStepper = forwardRef( const newMigrationData = useSelector((state: RootState) => state?.migration?.newMigrationData); const { steps, className, emptyStateMsg, hideTabView, testId } = props; - const [showStep, setShowStep] = useState(stepIndex); + + // Initialize showStep based on current state - if restarted, start from 0 + const initialStep = (newMigrationData?.project_current_step === 1 && + newMigrationData?.legacy_cms?.projectStatus === 0) ? 0 : stepIndex; + + const [showStep, setShowStep] = useState(initialStep); const [stepsCompleted, setStepsCompleted] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); + const [lastIteration, setLastIteration] = useState(newMigrationData?.iteration || 1); const navigate = useNavigate(); const { projectId = '' } = useParams(); @@ -99,31 +105,75 @@ const HorizontalStepper = forwardRef( newMigrationDataRef.current = newMigrationData; }, [newMigrationData]); + // Reset stepper when migration is restarted (detected by iteration increment) + useEffect(() => { + const currentIteration = newMigrationData?.iteration || 1; + + // If iteration has increased, it means migration was restarted + if (currentIteration > lastIteration) { + setStepsCompleted([]); + setShowStep(0); + setLastIteration(currentIteration); + } + }, [newMigrationData?.iteration, lastIteration]); + + // Also reset stepper when migration is restarted (fallback detection) + useEffect(() => { + // Check if migration was restarted by looking at multiple indicators + const isRestarted = + newMigrationData?.project_current_step === 1 && + newMigrationData?.legacy_cms?.projectStatus === 0 && + newMigrationData?.legacy_cms?.currentStep === 1 && + !newMigrationData?.migration_execution?.migrationCompleted && + !newMigrationData?.migration_execution?.migrationStarted; + + if (isRestarted && stepsCompleted.length > 0) { + setStepsCompleted([]); + setShowStep(0); + } + }, [ + newMigrationData?.project_current_step, + newMigrationData?.legacy_cms?.projectStatus, + newMigrationData?.legacy_cms?.currentStep, + newMigrationData?.migration_execution?.migrationCompleted, + newMigrationData?.migration_execution?.migrationStarted, + stepsCompleted.length + ]); + useEffect(() => { const stepIndex = parseInt(stepId || '', 10) - 1; if (!Number.isNaN(stepIndex) && stepIndex >= 0 && stepIndex < steps?.length) { !newMigrationDataRef?.current?.isprojectMapped && setShowStep(stepIndex); - setStepsCompleted((prev) => { - const updatedStepsCompleted = [...prev]; - if ( - stepIndex === 4 && - (props?.projectData?.isMigrationCompleted || - newMigrationData?.migration_execution?.migrationCompleted) - ) { - if (!updatedStepsCompleted?.includes(4)) { - updatedStepsCompleted.push(4); + + // Only auto-complete previous steps if migration hasn't been restarted recently + // Check if this is a fresh restart (project_current_step = 1 and low projectStatus) + const isFreshRestart = + newMigrationData?.project_current_step === 1 && + newMigrationData?.legacy_cms?.projectStatus === 0; + + if (!isFreshRestart) { + setStepsCompleted((prev) => { + const updatedStepsCompleted = [...prev]; + if ( + stepIndex === 4 && + (props?.projectData?.isMigrationCompleted || + newMigrationData?.migration_execution?.migrationCompleted) + ) { + if (!updatedStepsCompleted?.includes(4)) { + updatedStepsCompleted.push(4); + } } - } - for (let i = 0; i < stepIndex; i++) { - if (!updatedStepsCompleted?.includes(i)) { - updatedStepsCompleted?.push(i); + for (let i = 0; i < stepIndex; i++) { + if (!updatedStepsCompleted?.includes(i)) { + updatedStepsCompleted?.push(i); + } } - } - return updatedStepsCompleted; - }); + return updatedStepsCompleted; + }); + } } - }, [stepId, newMigrationData?.migration_execution?.migrationCompleted]); + }, [stepId, newMigrationData?.migration_execution?.migrationCompleted, newMigrationData?.project_current_step, newMigrationData?.legacy_cms?.projectStatus]); useImperativeHandle(ref, () => ({ handleStepChange: (currentStep: number) => { diff --git a/ui/src/context/app/app.interface.ts b/ui/src/context/app/app.interface.ts index 80ef579fd..dc34b0c00 100644 --- a/ui/src/context/app/app.interface.ts +++ b/ui/src/context/app/app.interface.ts @@ -216,6 +216,8 @@ export interface INewMigration { migration_execution: IMigrationExecutionStep; project_current_step: number; settings:ISetting; + iteration: number; + stepValue?: string; } export interface TestStacks { @@ -405,6 +407,8 @@ export const DEFAULT_NEW_MIGRATION: INewMigration = { project_current_step: 0, settings: DEFAULT_SETTING, isContentMapperGenerated: false, + iteration: 1, + stepValue: 'Save and Continue', }; export const DEFAULT_URL_TYPE: IURLType = { diff --git a/ui/src/pages/Migration/index.tsx b/ui/src/pages/Migration/index.tsx index 7dc709045..6e8fecda4 100644 --- a/ui/src/pages/Migration/index.tsx +++ b/ui/src/pages/Migration/index.tsx @@ -23,7 +23,8 @@ import { getExistingGlobalFields, startMigration, updateMigrationKey, - updateLocaleMapper + updateLocaleMapper, + restartMigration } from '../../services/api/migration.service'; import { getCMSDataFromFile } from '../../cmsData/cmsSelector'; @@ -808,6 +809,7 @@ const Migration = () => { const handleOnClickMigrationExecution = async () => { setIsLoading(true); + if (newMigrationData?.stepValue !== 'Restart Migration') { try { const migrationRes = await startMigration( newMigrationData?.destination_stack?.selectedOrg?.value, @@ -838,9 +840,48 @@ const Migration = () => { } catch (error) { // return error; console.error(error); + }} + else{ + setIsLoading(false); + handleRestartMigration(); } }; + const handleRestartMigration = async () => { + const newMigrationDataObj: INewMigration = { + ...newMigrationData, + legacy_cms: { + ...newMigrationData?.legacy_cms, + projectStatus: 0, + currentStep: 1, + uploadedFile: { + ...newMigrationData?.legacy_cms?.uploadedFile, + isValidated: false + } + }, + migration_execution: { + ...newMigrationData?.migration_execution, + migrationStarted: false + }, + project_current_step: 1, + iteration: newMigrationData?.iteration ? newMigrationData?.iteration + 1 : 1 + }; + dispatch(updateNewMigrationData(newMigrationDataObj)); + const res = await restartMigration(selectedOrganisation?.value, projectId); + if (res?.status === 200) { + Notification({ + notificationContent: { text: 'Migration restarted successfully' }, + type: 'success' + }); + navigate(`/projects/${projectId}/migration/steps/1`); + } else { + Notification({ + notificationContent: { text: 'Failed to restart migration' }, + type: 'error' + }); + } + }; + /** * Once Save Changes Modal is shown, Change the dropdown state to false and store in rdux */ diff --git a/ui/src/services/api/migration.service.ts b/ui/src/services/api/migration.service.ts index 0555ccacc..27d662265 100644 --- a/ui/src/services/api/migration.service.ts +++ b/ui/src/services/api/migration.service.ts @@ -367,3 +367,53 @@ export const getMigrationLogs = async (orgId: string, projectId: string, stackId } } } + +export const restartMigration = async (orgId: string, projectId: string) => { + try { + return await postCall( + `${API_VERSION}/migration/restart/${orgId}/${projectId}`, {}, options()); + } catch (error) { + return error; + } +} + +export const getEntryMapping = async ( + contentTypeId: string, + skip: number, + limit: number, + searchText: string, + projectId: string +) => { + try { + const encodedSearchText = encodeURIComponent(searchText); + return await getCall( + `${API_VERSION}/mapper/entryMapping/${projectId}/${contentTypeId}/${skip}/${limit}/${encodedSearchText}?`, + options() + ); + } catch (error) { + if (error instanceof Error) { + throw new Error(`${error.message}`); + } else { + throw new Error('Unknown error'); + } + } +}; +export const updateEntryMapper = async ( + projectId: string, + data: ObjectType +) => { + try { + return await putCall( + `${API_VERSION}/mapper/updateEntryStatus/${projectId}`, + data, + options() + ); + } catch (error) { + if (error instanceof Error) { + throw new Error(`${error.message}`); + } else { + throw new Error('Unknown error'); + } + } +}; + diff --git a/upload-api/migration-aem/package-lock.json b/upload-api/migration-aem/package-lock.json index 28c9def83..64234fd05 100644 --- a/upload-api/migration-aem/package-lock.json +++ b/upload-api/migration-aem/package-lock.json @@ -108,6 +108,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.19.0" + } + }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -755,6 +766,14 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", diff --git a/upload-api/migration-contentful/index.js b/upload-api/migration-contentful/index.js index 045af75ec..c69f4b7d2 100644 --- a/upload-api/migration-contentful/index.js +++ b/upload-api/migration-contentful/index.js @@ -3,9 +3,10 @@ const extractContentTypes = require('./libs/extractContentTypes'); const createInitialMapper = require('./libs/createInitialMapper'); const extractLocale = require('./libs/extractLocale'); - +const extractEntries = require('./libs/extractEntries'); module.exports = { extractContentTypes, createInitialMapper, - extractLocale + extractLocale, + extractEntries }; diff --git a/upload-api/migration-contentful/libs/createInitialMapper.js b/upload-api/migration-contentful/libs/createInitialMapper.js index 331359311..dd8262173 100644 --- a/upload-api/migration-contentful/libs/createInitialMapper.js +++ b/upload-api/migration-contentful/libs/createInitialMapper.js @@ -8,7 +8,7 @@ const fs = require('fs/promises'); const path = require('path'); // const contentTypeMapper = require('./contentTypeMapper'); const contentTypeMapper = require('./contentTypeMapper'); - +const extractEntries = require('./extractEntries'); /** * Internal module dependencies. @@ -66,6 +66,7 @@ const createInitialMapper = async (cleanLocalPath, affix) => { try { const alldata = readFile(cleanLocalPath); const { entries } = alldata; + const entriesByContentType = extractEntries(cleanLocalPath); const initialMapper = []; const files = await fs.readdir( @@ -77,6 +78,7 @@ const createInitialMapper = async (cleanLocalPath, affix) => { path.resolve(process.cwd(), `${config.data}/${config.contentful.contentful}/${file}`) ); const title = file.split('.')[0]; + const contentfulID = data?.[0]?.contentfulID; const contentTypeObject = { status: 1, @@ -87,7 +89,8 @@ const createInitialMapper = async (cleanLocalPath, affix) => { contentstackTitle: title.charAt(0).toUpperCase() + title.slice(1), contentstackUid: uidCorrector(data?.[0]?.contentUid, affix), type: 'content_type', - fieldMapping: [] + fieldMapping: [], + entryMapping: entriesByContentType[contentfulID] || [] }; const uidTitle = [ { diff --git a/upload-api/migration-contentful/libs/extractEntries.js b/upload-api/migration-contentful/libs/extractEntries.js new file mode 100644 index 000000000..03c441240 --- /dev/null +++ b/upload-api/migration-contentful/libs/extractEntries.js @@ -0,0 +1,60 @@ +'use strict'; +/* eslint-disable @typescript-eslint/no-var-requires */ + +const { readFile } = require('../utils/helper'); + +/** + * Extracts entries from a Contentful export and groups them by content type ID. + * + * @param {string} cleanLocalPath - Path to the Contentful export JSON file. + * @returns {Record} A map of contentTypeId to array of entry mapping objects. + */ +const extractEntries = (cleanLocalPath) => { + try { + const alldata = readFile(cleanLocalPath); + const { entries } = alldata; + const locales = alldata?.locales?.map((locale) => locale?.code); + + if (!entries || !Array.isArray(entries) || entries.length === 0) { + console.info('No entries found in Contentful export'); + return {}; + } + + const entriesByContentType = {}; + + for (const entry of entries) { + const contentTypeId = entry?.sys?.contentType?.sys?.id; + const entryId = entry?.sys?.id; + for (const locale of locales) { + let entryTitle = entry?.fields?.title?.[locale]; + entryTitle = !entryTitle ? entry?.fields?.name?.[locale] : entryTitle; + if (!entryTitle) continue; + console.info(`entryTitle: ${entryTitle}`); + console.info(`contentTypeId: ${contentTypeId}`); + // if (!entryTitle) continue; + if (!entriesByContentType[contentTypeId]) { + entriesByContentType[contentTypeId] = []; + } + + entriesByContentType[contentTypeId].push({ + contentTypeUid: contentTypeId, + entryName: entryTitle, + otherCmsEntryUid: entryId, + isUpdate: false, + language: locale, + }); + } + } + + console.info( + `extractEntries: Extracted entries for ${Object.keys(entriesByContentType).length} content types` + ); + + return entriesByContentType; + } catch (err) { + console.error('Error extracting Contentful entries:', err); + return {}; + } +}; + +module.exports = extractEntries; diff --git a/upload-api/migration-drupal/index.js b/upload-api/migration-drupal/index.js index ac588e200..ec551137e 100644 --- a/upload-api/migration-drupal/index.js +++ b/upload-api/migration-drupal/index.js @@ -3,10 +3,12 @@ const extractTaxonomy = require('./libs/extractTaxonomy'); const createInitialMapper = require('./libs/createInitialMapper'); const extractLocale = require('./libs/extractLocale'); +const extractEntries = require('./libs/extractEntries'); module.exports = { // extractContentTypes, extractTaxonomy, createInitialMapper, - extractLocale + extractLocale, + extractEntries }; diff --git a/upload-api/migration-drupal/libs/createInitialMapper.js b/upload-api/migration-drupal/libs/createInitialMapper.js index cbcd41242..4bb0e2664 100644 --- a/upload-api/migration-drupal/libs/createInitialMapper.js +++ b/upload-api/migration-drupal/libs/createInitialMapper.js @@ -239,6 +239,7 @@ const createInitialMapper = async (systemConfig, prefix) => { (contentType) => contentType && contentType.toLowerCase() !== 'profile' ); + const entryMapping = await extractEntries(connection, prefix); // Process each content type for (const contentType of contentTypes) { // Extra safety check - skip if contentType is profile (case-insensitive) @@ -263,7 +264,8 @@ const createInitialMapper = async (systemConfig, prefix) => { contentstackTitle: contenttypeTitle.charAt(0).toUpperCase() + contenttypeTitle.slice(1), contentstackUid: uidCorrector(contenttypeTitle, prefix), type: 'content_type', - fieldMapping: [] + fieldMapping: [], + entryMapping: entryMapping[contentType] || [] }; // Map fields using contentTypeMapper, passing actual taxonomy usage diff --git a/upload-api/migration-drupal/libs/extractEntries.js b/upload-api/migration-drupal/libs/extractEntries.js new file mode 100644 index 000000000..0dc6dc1a1 --- /dev/null +++ b/upload-api/migration-drupal/libs/extractEntries.js @@ -0,0 +1,104 @@ +// upload-api/migration-drupal/libs/extractEntries.js + +'use strict'; +/* eslint-disable @typescript-eslint/no-var-requires */ + +/** + * Builds per-bundle entry lists for the content mapper (entryMapping). + * (uidCorrector + `content_type_entries_title_${nid}`) so iteration > 1 + * updates (removeEntriesFromDatabase + entry-update-script) line up with locale JSON keys. + */ + +const DEFAULT_PREFIX = 'cs'; + +function startsWithNumber(str) { + return typeof str === 'string' && /^\d/.test(str); +} + +/** createMapper may pass affix as string | string[] */ +function normalizeAffix(prefix) { + if (prefix == null) { + return ''; + } + if (Array.isArray(prefix)) { + const first = prefix[0]; + return first != null && String(first).trim() !== '' ? String(first) : ''; + } + const s = String(prefix); + return s.trim() !== '' ? s : ''; +} + +/** + * `uidCorrector` for the `id` branch (no separate `uid`). + */ +function entrySourceUidCorrector({ id, prefix }) { + const value = id != null && id !== '' ? String(id) : ''; + if (!value) { + return ''; + } + const affix = normalizeAffix(prefix); + const effectivePrefix = affix !== '' ? affix : DEFAULT_PREFIX; + if (startsWithNumber(value)) { + return `${effectivePrefix}_${value.replace(/[ -]/g, '_').toLowerCase()}`; + } + return value.replace(/[ -]/g, '_').toLowerCase(); +} + +/** + * @param {import('mysql2').Connection} connection - Open mysql2 connection + * @param {string} [prefix] - Stack affix / prefix + * @returns {Promise>>} Map of Drupal bundle machine name - entryMapping rows + */ +async function extractEntries(connection, prefix) { + const byBundle = {}; + if (!connection || typeof connection.promise !== 'function') { + console.warn('extractEntries (Drupal): invalid connection, skipping'); + return byBundle; + } + + try { + const query = ` + SELECT nid, title, langcode, type + FROM node_field_data + WHERE status = 1 + AND LOWER(type) <> 'profile' + ORDER BY type, nid, langcode + `; + const [rows] = await connection.promise().query(query); + + for (const row of rows) { + const bundle = row.type; + if (!bundle) { + continue; + } + const otherCmsEntryUid = entrySourceUidCorrector({ + id: `content_type_entries_title_${row.nid}`, + prefix, + }); + const entryName = row.title ? String(row.title) : `Node ${row.nid}`; + + if (!byBundle[bundle]) { + byBundle[bundle] = []; + } + byBundle[bundle].push({ + contentTypeUid: bundle, + entryName, + language: row.langcode || '', + otherCmsEntryUid, + otherCmsCTName: bundle, + isUpdate: false, + }); + } + + const total = Object.values(byBundle).reduce((n, arr) => n + arr.length, 0); + console.info( + `extractEntries (Drupal): ${total} entries across ${Object.keys(byBundle).length} bundle(s)` + ); + return byBundle; + } catch (err) { + console.error('extractEntries (Drupal) error:', err?.message || err); + return byBundle; + } +} + +module.exports = extractEntries; \ No newline at end of file diff --git a/upload-api/migration-sitecore/index.js b/upload-api/migration-sitecore/index.js index ce84e6dae..2c80c5120 100644 --- a/upload-api/migration-sitecore/index.js +++ b/upload-api/migration-sitecore/index.js @@ -9,11 +9,12 @@ const reference = require('./libs/reference.js'); const ExtractFiles = require('./libs/convert.js'); // eslint-disable-next-line @typescript-eslint/no-var-requires const extractLocales = require('./libs/extractLocales.js'); - +const extractEntries = require('./libs/extractEntries.js'); module.exports = { contentTypes, ExtractConfiguration, reference, ExtractFiles, - extractLocales + extractLocales, + extractEntries }; diff --git a/upload-api/migration-sitecore/libs/extractEntries.js b/upload-api/migration-sitecore/libs/extractEntries.js new file mode 100644 index 000000000..6e5f49d34 --- /dev/null +++ b/upload-api/migration-sitecore/libs/extractEntries.js @@ -0,0 +1,211 @@ +const path = require('path'); +const fs = require('fs'); +const _ = require('lodash'); +const read = require('fs-readdir-recursive'); +const helper = require('../utils/helper'); +const restrictedUid = require('../utils'); +const { MIGRATION_DATA_CONFIG } = require('../constants/index'); + +const idToString = (id) => { + if (id === null || id === undefined) return ''; + if (typeof id === 'string') return id; + if (typeof id === 'number' || typeof id === 'bigint' || typeof id === 'boolean') return String(id); + if (Array.isArray(id)) return idToString(id[0]); + + if (typeof id === 'object') { + const candidate = id.id ?? id.guid ?? id.value ?? id.$id ?? id._id; + if (typeof candidate === 'string' || typeof candidate === 'number' || typeof candidate === 'bigint') { + return String(candidate); + } + + if (typeof id.toString === 'function' && id.toString !== Object.prototype.toString) { + const str = id.toString(); + if (typeof str === 'string' && str && str !== '[object Object]') return str; + } + + try { + return JSON.stringify(id); + } catch { + return ''; + } + } + + return ''; +}; + +const idCorrector = (id) => { + if (id === null || id === undefined) return id; + + const raw = idToString(id).trim(); + if (!raw) return id; + + const isGuidLike = + /^\{?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\}?$/.test(raw); + + if (isGuidLike) { + return raw.replace(/[-{}]/g, '').toLowerCase(); + } + + return raw.toLowerCase(); +}; + +const uidCorrector = ({ uid } ) => { + if (!uid || typeof uid !== 'string') { + return ''; + } + + let newUid = uid; + + // Note: UIDs starting with numbers and restricted keywords are handled externally in Sitecore + // The prefix is applied in contentTypeMaker function when needed + + // Clean up the UID + newUid = newUid + .replace(/[ -]/g, '_') // Replace spaces and hyphens with underscores + .replace(/[^a-zA-Z0-9_]+/g, '_') // Replace non-alphanumeric characters (except underscore) + .replace(/([A-Z])/g, (match) => `_${match.toLowerCase()}`) // Handle camelCase + .toLowerCase() // Convert to lowercase + .replace(/_+/g, '_') // Replace multiple underscores with single + .replace(/^_|_$/g, ''); // Remove leading/trailing underscores + + // Ensure UID doesn't start with underscore (Contentstack requirement) + if (newUid.startsWith('_')) { + newUid = newUid.substring(1); + } + + return newUid; +}; + +const extractEntries = async (newPath) => { + try { + + const srcFunc = 'extractEntries'; + + const folderName = path.join(newPath,'master', 'sitecore', 'content'); + + const entriesData = []; + if (fs.existsSync(folderName)) { + + const entryPath = read?.(folderName); + for await (const file of entryPath) { + if (file?.endsWith('data.json')) { + const data = await fs.promises.readFile( + path.join(folderName, file), + 'utf8' + ); + const jsonData = JSON.parse(data); + + const { language, template, tid } = jsonData?.item?.$ ?? {}; + const id = idCorrector(jsonData?.item?.$?.id ); + const entries = {}; + entries[id] = { + meta: jsonData?.item?.$, + fields: jsonData?.item?.fields, + }; + const templateIndex = entriesData?.findIndex( + (ele) => ele?.template === template + ); + if (templateIndex >= 0) { + const entry = entriesData?.[templateIndex]?.locale?.[language]; + if (entry !== undefined) { + entry[id] = { + meta: jsonData?.item?.$, + fields: jsonData?.item?.fields, + }; + } else { + entriesData[templateIndex].locale[language] = entries; + } + } else { + const locale = {}; + locale[language] = entries; + entriesData?.push({ template, locale, tid }); + } + } + } + } + const contentTypeUids = fs.readFileSync( + path.join(process.cwd(), MIGRATION_DATA_CONFIG.DATA, MIGRATION_DATA_CONFIG?.DATA_MAPPER_DIR, 'contentTypeKey.json'), + 'utf8' + ); + const contentTypes = JSON.parse(contentTypeUids); + if(!fs.existsSync(path.join(process.cwd(), MIGRATION_DATA_CONFIG.DATA, MIGRATION_DATA_CONFIG?.ENTRIES_DIR_NAME))){ + fs.mkdirSync(path.join(process.cwd(), MIGRATION_DATA_CONFIG.DATA, MIGRATION_DATA_CONFIG?.ENTRIES_DIR_NAME), { recursive: true }); + } + + + Object.entries(contentTypes).map(([key, value]) => { + console.info(`🚀 ~ extractEntries ~ Processing Content Type UID:`, value, key); + let contentTypeTitle = ''; + const entryPresent = entriesData?.find( + (item) => + item?.tid === key + ); + const AllentryArray = Array.isArray(entryPresent) ? entryPresent : [entryPresent]; + const entriesArray = []; + + if (AllentryArray && AllentryArray.length > 0) { + //console.info(`🚀 ~ extractEntries ~ AllentryArray:`, AllentryArray); + for(const entry of AllentryArray){ + const locales = entry?.locale && Object?.keys(entry?.locale); + + if(locales?.length <= 0) continue; + + if(!locales) continue; + + for (const locale of locales) { + Object.entries(entry?.locale?.[locale] || {}).map(([uid, item])=>{ + contentTypeTitle = entry?.template; + const otherCmsEntryUid = idCorrector(item?.meta?.id || ''); + entriesArray.push({ + contentTypeUid: key, + entryName: item?.meta?.name, + language: item?.meta?.language, + otherCmsEntryUid: otherCmsEntryUid, + otherCmsCTName: item?.template, + isUpdate: false, + }); + + }) + } + }; + const message = `${srcFunc} Transforming entries of Content Type ${contentTypeTitle} has begun.`; + console.info(message); + + } + const contentType ={ + "id": key, + "status": 1, + "otherCmsTitle": contentTypeTitle, + "otherCmsUid": value, + "isUpdated": false, + "updateAt": "", + "contentstackTitle": contentTypeTitle, + "contentstackUid": value, + "entryMapping": entriesArray, + + } + + if(entriesArray?.length > 0){ + if(fs.existsSync(path.join(process.cwd(), MIGRATION_DATA_CONFIG.DATA, MIGRATION_DATA_CONFIG?.CONTENT_TYPES_DIR_NAME, `${contentType?.contentstackUid}.json`))){ + const data = fs.readFileSync( + path.join(process.cwd(), MIGRATION_DATA_CONFIG.DATA, MIGRATION_DATA_CONFIG?.CONTENT_TYPES_DIR_NAME, `${contentType?.contentstackUid}.json`), + 'utf8' + ); + const existingContentType = JSON.parse(data); + existingContentType.entryMapping = entriesArray; + fs.writeFileSync( + path.join(process.cwd(), MIGRATION_DATA_CONFIG.DATA, MIGRATION_DATA_CONFIG?.CONTENT_TYPES_DIR_NAME, `${contentType?.contentstackUid}.json`), + JSON.stringify(existingContentType, null, 4)); + } + + } + + }); + + return true; + } catch (err) { + console.error('🚀 ~ createEntry ~ err:', err); + } +}; + +module.exports = extractEntries; \ No newline at end of file diff --git a/upload-api/migration-wordpress/libs/extractEntries.ts b/upload-api/migration-wordpress/libs/extractEntries.ts new file mode 100644 index 000000000..783cea92f --- /dev/null +++ b/upload-api/migration-wordpress/libs/extractEntries.ts @@ -0,0 +1,190 @@ +import fs from 'fs'; +import path from 'path'; +import config from '../config/index.json'; + +const { contentTypes: contentTypesConfig } = config?.modules; +const contentTypeFolderPath = path.resolve(config?.data, contentTypesConfig?.dirName); + +const EXCLUDED_POST_TYPES = new Set(['attachment', 'wp_global_styles', 'wp_navigation']); + +const normalizeArray = (value: T | T[] | undefined): T[] => { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +}; + +const idCorrector = (id: string) => { + const normalized = id?.replace(/[-{}]/g, ''); + return normalized ? normalized.toLowerCase() : id; +}; + +const getEntryName = (item: any): string => { + if (typeof item?.title === 'string' && item.title.trim()) { + return item.title.trim(); + } + if (item?.title?.text) { + return String(item.title.text).trim(); + } + + // Handle author entries + if (item?.['wp:author_display_name']) { + return String(item['wp:author_display_name']).trim(); + } + if (item?.['wp:author_login']) { + return String(item['wp:author_login']).trim(); + } + + // Handle terms entries + if (item?.['wp:term_name']) { + return String(item['wp:term_name']).trim(); + } + if (item?.['wp:term_slug']) { + return String(item['wp:term_slug']).trim(); + } + + if (typeof item?.['wp:post_name'] === 'string' && item['wp:post_name'].trim()) { + return item['wp:post_name'].trim(); + } + return 'Untitled Entry'; +}; + +/** + * All WordPress source entry keys use `posts_${...}` (any content type) so they align with + * wordpress.service export JSON and CLI uid-mapping. + */ +const getSourceEntryUid = (item: any): string => { + const postId = item?.['wp:post_id']; + if (postId != null && String(postId).trim() !== '') { + return idCorrector(`posts_${postId}`); + } + + const authorId = item?.['wp:author_id']; + if (authorId != null && String(authorId).trim() !== '') { + return idCorrector(`posts_${authorId}`); + } + + const termId = item?.['wp:term_id']; + if (termId != null && String(termId).trim() !== '') { + return idCorrector(`posts_${termId}`); + } + + const candidate = + item?.guid?.text ?? item?.guid ?? item?.link ?? getEntryName(item); + const base = idCorrector(String(candidate || 'entry')); + return idCorrector(`posts_${base}`); +}; + +const getEntryLanguage = (item: any, channelLanguage?: string): string => { + const postMeta = normalizeArray(item?.['wp:postmeta']); + const languageMeta = postMeta.find((meta: any) => { + const key = String(meta?.['wp:meta_key'] || '').toLowerCase(); + return key === 'language' || key === '_language' || key === 'locale' || key === '_locale'; + }); + + const metaLanguage = languageMeta?.['wp:meta_value']; + if (typeof metaLanguage === 'string' && metaLanguage.trim()) { + return metaLanguage.trim(); + } + + if (typeof channelLanguage === 'string' && channelLanguage.trim()) { + return channelLanguage.trim(); + } + + return 'en-us'; +}; + +const extractEntries = async (filePath: string, contentTypeData: any[] = []) => { + try { + const rawData = await fs.promises.readFile(filePath, 'utf8'); + const jsonData = JSON.parse(rawData); + const items = normalizeArray(jsonData?.rss?.channel?.item); + const channelLanguage = jsonData?.rss?.channel?.language; + + const groupedByType = items?.reduce((acc: Record, item: any) => { + const postType = item?.['wp:post_type'] || 'unknown'; + if (EXCLUDED_POST_TYPES.has(postType)) return acc; + if (!acc[postType]) acc[postType] = []; + acc[postType].push(item); + return acc; + }, {}); + + // Extract author entries + const authorData = jsonData?.rss?.channel?.['wp:author']; + if (authorData) { + const authorEntries = normalizeArray(authorData).map((author: any) => ({ + 'wp:post_type': 'author', + 'wp:post_id': author?.['wp:author_id'], + title: author?.['wp:author_display_name'] || author?.['wp:author_login'], + 'wp:author_login': author?.['wp:author_login'], + 'wp:author_email': author?.['wp:author_email'], + 'wp:author_display_name': author?.['wp:author_display_name'], + 'wp:author_first_name': author?.['wp:author_first_name'], + 'wp:author_last_name': author?.['wp:author_last_name'] + })); + if (authorEntries.length > 0) { + groupedByType['author'] = authorEntries; + } + } + + // Extract terms entries (wp:term) + const termData = jsonData?.rss?.channel?.['wp:term']; + if (termData) { + const termEntries = normalizeArray(termData).map((term: any) => ({ + 'wp:post_type': 'terms', + 'wp:post_id': term?.['wp:term_id'], + title: term?.['wp:term_name'] || term?.['wp:term_slug'], + 'wp:term_id': term?.['wp:term_id'], + 'wp:term_taxonomy': term?.['wp:term_taxonomy'], + 'wp:term_slug': term?.['wp:term_slug'], + 'wp:term_name': term?.['wp:term_name'] + })); + if (termEntries.length > 0) { + groupedByType['terms'] = termEntries; + } + } + + const updatedTypes = contentTypeData?.map((ct) => ({ ...ct })); + + for (const [type, entries] of Object.entries(groupedByType)) { + const entryMapping = normalizeArray(entries) + .map((item: any) => { + const otherCmsEntryUid = getSourceEntryUid(item); + if (!otherCmsEntryUid) return null; + return { + contentTypeUid: type, + entryName: getEntryName(item), + otherCmsEntryUid, + otherCmsCTName: type, + language: getEntryLanguage(item, channelLanguage), + isUpdate: false + }; + }) + .filter(Boolean); + + const contentTypeFilePath = path.join(contentTypeFolderPath, `${type.toLowerCase()}.json`); + if (fs.existsSync(contentTypeFilePath)) { + const ctFile = JSON.parse(await fs.promises.readFile(contentTypeFilePath, 'utf8')); + ctFile.entryMapping = entryMapping; + await fs.promises.writeFile(contentTypeFilePath, JSON.stringify(ctFile, null, 4), 'utf8'); + } + + const index = updatedTypes.findIndex( + (ct: any) => + ct?.otherCmsUid?.toLowerCase?.() === type.toLowerCase() || + ct?.contentstackUid?.toLowerCase?.() === type.toLowerCase() + ); + if (index >= 0) { + updatedTypes[index] = { + ...updatedTypes[index], + entryMapping + }; + } + } + + return updatedTypes; + } catch (error: any) { + console.error('Error while extracting WordPress entries:', error?.message || error); + return contentTypeData; + } +}; + +export default extractEntries; diff --git a/upload-api/package.json b/upload-api/package.json index 58745d61b..395012b7e 100644 --- a/upload-api/package.json +++ b/upload-api/package.json @@ -90,4 +90,4 @@ "diff": ">=5.2.2", "qs": ">=6.14.2" } -} +} \ No newline at end of file diff --git a/upload-api/src/config/index.json b/upload-api/src/config/index.json new file mode 100644 index 000000000..789a93bf4 --- /dev/null +++ b/upload-api/src/config/index.json @@ -0,0 +1,29 @@ +{ + "plan": { + "dropdown": { + "optionLimit": 100 + } + }, + "cmsType": "", + "isLocalPath": true, + "awsData": { + "awsRegion": "us-east-2", + "awsAccessKeyId": "", + "awsSecretAccessKey": "", + "awsSessionToken": "", + "bucketName": "", + "bucketKey": "" + }, + "mysql": { + "host": "host_name", + "user": "user_name", + "password": "", + "database": "database_name", + "port": "port_number" + }, + "assetsConfig": { + "base_url": "drupal_assets_base_url", + "public_path": "drupal_assets_public_path" + }, + "localPath": "" +} \ No newline at end of file diff --git a/upload-api/src/config/index.ts b/upload-api/src/config/index.ts deleted file mode 100644 index adf727923..000000000 --- a/upload-api/src/config/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -export default { - plan: { - dropdown: { optionLimit: 100 } - }, - cmsType: process.env.CMS_TYPE || 'cmsType', - isLocalPath: true, - awsData: { - awsRegion: 'us-east-2', - awsAccessKeyId: '', - awsSecretAccessKey: '', - awsSessionToken: '', - bucketName: '', - bucketKey: '' - }, - mysql: { - host: process.env.MYSQL_HOST || 'host_name', - user: process.env.MYSQL_USER || 'user_name', - password: process.env.MYSQL_PASSWORD || '', - database: process.env.MYSQL_DATABASE || 'database_name', - port: process.env.MYSQL_PORT || 'port_number' - }, - assetsConfig: { - base_url: process.env.DRUPAL_ASSETS_BASE_URL || 'drupal_assets_base_url', - public_path: process.env.DRUPAL_ASSETS_PUBLIC_PATH || 'drupal_assets_public_path' - }, - localPath: process.env.CMS_LOCAL_PATH || process.env.CONTAINER_PATH || 'localPath', -}; diff --git a/upload-api/src/controllers/sitecore/index.ts b/upload-api/src/controllers/sitecore/index.ts index df7db6b4e..8e824dcef 100644 --- a/upload-api/src/controllers/sitecore/index.ts +++ b/upload-api/src/controllers/sitecore/index.ts @@ -12,7 +12,8 @@ const { ExtractConfiguration, reference, ExtractFiles, - extractLocales + extractLocales, + extractEntries } = require('migration-sitecore'); const { CONTENT_TYPES_DIR_NAME, GLOBAL_FIELDS_DIR_NAME, GLOBAL_FIELDS_FILE_NAME } = @@ -141,6 +142,7 @@ const createSitecoreMapper = async ( await createLocaleSource?.({ app_token, localeData, projectId }); await ExtractConfiguration(newPath); await contentTypes(newPath, affix, config); + await extractEntries(newPath); const infoMap = await reference(); if (infoMap?.contentTypeUids?.length) { const fieldMapping: any = { contentTypes: [], extractPath: filePath }; diff --git a/upload-api/src/helper/index.ts b/upload-api/src/helper/index.ts index 07cc83a37..370bf338e 100644 --- a/upload-api/src/helper/index.ts +++ b/upload-api/src/helper/index.ts @@ -209,4 +209,37 @@ function deleteFolderSync(folderPath: string): void { } } -export { getFileName, saveZip, saveJson, fileOperationLimiter, deleteFolderSync, parseXmlToJson }; +async function updateConfigFile(filePath?: string) { + try { + const configFilePath = path.join(process.cwd(), 'src', 'config', 'index.json'); + const config:any = JSON.parse(await fs.promises.readFile(configFilePath, 'utf8')); + + + // If filePath is provided and not empty, update the config file + if (filePath && typeof filePath === 'string' && filePath.trim() !== '') { + const resolvedFilePath = path.resolve(filePath.trim()); + + // Read current config + const updatedConfig = { + ...config, + localPath: resolvedFilePath + }; + + // Write updated config back to file + const configContent = JSON.stringify(updatedConfig, null, 2); + await fs.promises.writeFile(configFilePath, configContent, 'utf8'); + + // Return updated config + return updatedConfig; + } + + // If no filePath provided, just return current config + return config; + } catch (error) { + console.error('Error updating config file:', error); + // Return current config as fallback + + } +} + +export { getFileName, saveZip, saveJson, fileOperationLimiter, deleteFolderSync, parseXmlToJson, updateConfigFile }; diff --git a/upload-api/src/routes/index.ts b/upload-api/src/routes/index.ts index 385dc4d0f..2bd420920 100644 --- a/upload-api/src/routes/index.ts +++ b/upload-api/src/routes/index.ts @@ -10,9 +10,8 @@ import { UploadPartCommand } from '@aws-sdk/client-s3'; import { client } from '../services/aws/client'; -import { fileOperationLimiter } from '../helper'; +import { fileOperationLimiter, updateConfigFile } from '../helper'; import handleFileProcessing from '../services/fileProcessing'; -import config from '../config/index'; import createMapper from '../services/createMapper'; import { sanitizeId, sanitizeFilename, isPathWithinBase } from '../utils/sanitize-path.utils'; @@ -98,6 +97,7 @@ router.get( const projectId: string = sanitizeId(req?.headers?.projectid ?? ''); const app_token: string | string[] = req?.headers?.app_token ?? ''; const affix: string = sanitizeId(req?.headers?.affix ?? 'csm'); + const config = await updateConfigFile(); const cmsType = config?.cmsType?.toLowerCase(); if (config?.isLocalPath) { @@ -417,6 +417,7 @@ router.get( router.get('/config', async function (req: Request, res: Response) { // Strip mysql password before sending config to the client + const config = await updateConfigFile(); const { password, ...safeMysql } = config?.mysql || {}; const safeConfig = { ...config, diff --git a/upload-api/src/services/aws/client.ts b/upload-api/src/services/aws/client.ts index 1b2c90e5d..2df06353f 100644 --- a/upload-api/src/services/aws/client.ts +++ b/upload-api/src/services/aws/client.ts @@ -1,5 +1,5 @@ import { S3Client } from '@aws-sdk/client-s3'; -import config from '../../config'; +import config from '../../config/index.json'; interface AWSCredentials { accessKeyId: string; @@ -15,6 +15,7 @@ interface S3ClientConfig { //process.env.AWS_ACCESS_KEY_ID ?? //process.env.AWS_SECRET_ACCESS_KEY ?? //process.env.AWS_SESSION_TOKEN ?? + const clientConfig: S3ClientConfig = { region: config?.awsData?.awsRegion, credentials: { diff --git a/upload-api/src/services/fileProcessing.ts b/upload-api/src/services/fileProcessing.ts index de81727e8..84a8f942f 100644 --- a/upload-api/src/services/fileProcessing.ts +++ b/upload-api/src/services/fileProcessing.ts @@ -1,8 +1,7 @@ import { HTTP_TEXTS, HTTP_CODES } from '../constants'; -import { parseXmlToJson, saveJson, saveZip } from '../helper'; +import { parseXmlToJson, saveJson, saveZip, updateConfigFile } from '../helper'; import JSZip from 'jszip'; import validator from '../validators'; -import config from '../config/index'; import logger from '../utils/logger.js'; const handleFileProcessing = async ( @@ -11,6 +10,7 @@ const handleFileProcessing = async ( cmsType: string, name: string ) => { + const config = await updateConfigFile(); if (fileExt === 'zip') { const zip = new JSZip(); await zip.loadAsync(zipBuffer); From ab42f19d496f5b435b03b5061307bfc9fbfa5c9b Mon Sep 17 00:00:00 2001 From: yashin4112 Date: Wed, 22 Apr 2026 12:54:29 +0530 Subject: [PATCH 02/48] refactor: update route and enhance optional chaining in contentMapper service and utils; unit test cases added --- api/src/routes/contentMapper.routes.ts | 2 +- api/src/services/contentMapper.service.ts | 44 +- api/src/services/migration.service.ts | 86 ++-- api/src/services/runCli.service.ts | 12 +- api/src/services/updateEntryCli.service.ts | 4 +- api/src/utils/asset-update.utils.ts | 22 +- api/src/utils/content-type-checker.utils.ts | 4 +- api/src/utils/entry-duplicate.utils.ts | 2 +- api/src/utils/entry-update-script.cjs | 22 +- api/src/utils/entry-update.utils.ts | 40 +- api/src/utils/field-attacher.utils.ts | 4 +- api/src/utils/sanitize-path.utils.ts | 20 + api/tests/unit/helper/index.test.ts | 144 +++++++ .../unit/middlewares/auth.middleware.test.ts | 8 +- .../middlewares/logger.middleware.test.ts | 8 + .../unmatched-routes.middleware.test.ts | 24 +- .../unit/models/FieldMapper.model.test.ts | 6 +- .../contentTypesMapper-lowdb.model.test.ts | 6 +- .../unit/models/entry-mapper.model.test.ts | 37 ++ .../unit/models/uid-mapper.model.test.ts | 37 ++ .../unit/routes/contentMapper.routes.test.ts | 2 + .../unit/routes/migration.routes.test.ts | 1 + .../auth.service.refresh-oauth.test.ts | 117 ++++++ .../unit/services/auth.service.sso.test.ts | 177 ++++++++ .../services/contentMapper.service.test.ts | 224 ++++++---- .../unit/services/projects.service.test.ts | 88 ++-- .../unit/utils/asset-update.utils.test.ts | 396 ++++++++++++++++++ api/tests/unit/utils/auth.utils.test.ts | 87 +++- .../unit/utils/batch-processor.utils.test.ts | 18 +- .../unit/utils/config-handler.util.test.ts | 76 ++++ .../utils/content-type-checker.utils.test.ts | 85 ++++ api/tests/unit/utils/crypto.utils.test.ts | 99 +++++ .../unit/utils/custom-errors.utils.test.ts | 20 + .../unit/utils/entry-duplicate.utils.test.ts | 64 +++ .../unit/utils/entry-update.utils.test.ts | 147 +++++++ api/tests/unit/utils/index.test.ts | 18 + api/tests/unit/utils/pagination.utils.test.ts | 27 +- .../unit/utils/sso-request.utils.test.ts | 114 +++++ .../components/ContentMapper/entryMapper.tsx | 9 +- .../LegacyCms/Actions/LoadUploadFile.tsx | 8 +- .../components/MigrationFlowHeader/index.tsx | 2 - .../HorizontalStepper/HorizontalStepper.tsx | 4 +- .../libs/createInitialMapper.js | 2 +- .../libs/extractEntries.js | 3 - .../migration-drupal/libs/extractEntries.js | 10 +- .../migration-sitecore/libs/extractEntries.js | 6 +- upload-api/src/helper/index.ts | 24 +- upload-api/src/routes/index.ts | 22 +- upload-api/src/services/fileProcessing.ts | 8 + .../tests/unit/config/index.config.test.ts | 34 +- .../tests/unit/routes/index.routes.test.ts | 3 +- .../tests/unit/services/aws-client.test.ts | 2 +- .../unit/services/fileProcessing.test.ts | 24 +- 53 files changed, 2116 insertions(+), 337 deletions(-) create mode 100644 api/tests/unit/helper/index.test.ts create mode 100644 api/tests/unit/middlewares/logger.middleware.test.ts create mode 100644 api/tests/unit/models/entry-mapper.model.test.ts create mode 100644 api/tests/unit/models/uid-mapper.model.test.ts create mode 100644 api/tests/unit/services/auth.service.refresh-oauth.test.ts create mode 100644 api/tests/unit/services/auth.service.sso.test.ts create mode 100644 api/tests/unit/utils/asset-update.utils.test.ts create mode 100644 api/tests/unit/utils/config-handler.util.test.ts create mode 100644 api/tests/unit/utils/content-type-checker.utils.test.ts create mode 100644 api/tests/unit/utils/crypto.utils.test.ts create mode 100644 api/tests/unit/utils/entry-duplicate.utils.test.ts create mode 100644 api/tests/unit/utils/entry-update.utils.test.ts create mode 100644 api/tests/unit/utils/sso-request.utils.test.ts diff --git a/api/src/routes/contentMapper.routes.ts b/api/src/routes/contentMapper.routes.ts index 614f61de8..d1dcbfb4f 100644 --- a/api/src/routes/contentMapper.routes.ts +++ b/api/src/routes/contentMapper.routes.ts @@ -111,7 +111,7 @@ router.get( /** * Update Entry Status - * @route PUT /entryStatus/:projectId + * @route PUT /updateEntryStatus/:projectId */ router.put( "/updateEntryStatus/:projectId", diff --git a/api/src/services/contentMapper.service.ts b/api/src/services/contentMapper.service.ts index 746b4bef4..5a9ee676c 100644 --- a/api/src/services/contentMapper.service.ts +++ b/api/src/services/contentMapper.service.ts @@ -53,8 +53,8 @@ const idCorrector = ({ id }: { id: string }) => { * @returns The updated project data. */ const putTestData = async (req: Request) => { - const projectId = req.params.projectId; - const contentTypes = req.body.contentTypes; + const projectId = req?.params?.projectId; + const contentTypes = req?.body?.contentTypes; try { // Get project data to extract iteration @@ -122,7 +122,7 @@ const putTestData = async (req: Request) => { // Collect all fields from all content types first const allFields: any[] = []; - for (let index = 0; index < contentTypes.length; index++) { + for (let index = 0; index < contentType?.length; index++) { const type: any = contentTypes[index]; const fieldIds: string[] = []; @@ -205,7 +205,7 @@ const putTestData = async (req: Request) => { // Collect all entries from all content types first const allEntries: any[] = []; - for (let index = 0; index < contentTypes.length; index++) { + for (let index = 0; index < contentTypes?.length; index++) { const type: any = contentTypes[index]; const entryIds: string[] = []; @@ -355,7 +355,7 @@ const getContentTypes = async (req: Request) => { ); throw new BadRequestError(HTTP_TEXTS.PROJECT_NOT_FOUND); } - const contentMapperId = projectDetails.content_mapper; + const contentMapperId = projectDetails?.content_mapper; const iteration = projectDetails?.iteration || 0; const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); const FieldMapperModel = getFieldMapperDb(projectId, iteration); @@ -1805,8 +1805,8 @@ const getExistingExtensions = async ({existingStackId, token_payload}: any) => { } const updateEntryStatus = async (req: Request) => { - const { projectId } = req.params; - const { ids } = req.body; + const { projectId } = req?.params; + const { ids } = req?.body; const validatedUids: string[] = Array.isArray(ids) ? ids : []; const srcFunc = "updateEntryMapping"; if (isEmpty(validatedUids)) { @@ -1924,7 +1924,7 @@ const getEntryMapping = async (req: Request) => { }); // Fallback: If no entry mappings found in current iteration and we have previous iteration - if ((!entryMapping || entryMapping.length === 0 || entryMapping.every((e: any) => !e)) && iteration > 1) { + if ((!entryMapping || entryMapping?.length === 0 || entryMapping?.every((e: any) => !e)) && iteration > 1) { const PrevEntryMapperModel = getEntryMapperDb(projectId, iteration - 1); await PrevEntryMapperModel.read(); entryMapping = contentType?.entryMapping?.map?.((mapperUId: any) => { @@ -1948,11 +1948,11 @@ const getEntryMapping = async (req: Request) => { filteredResult = enrichedMapping?.filter?.((item: any) => item?.entryName?.toLowerCase().includes(search) ); - totalCount = filteredResult.length; - result = filteredResult.slice(skip, Number(skip) + Number(limit)); + totalCount = filteredResult?.length; + result = filteredResult?.slice(skip, Number(skip) + Number(limit)); } else { - totalCount = enrichedMapping.length; - result = enrichedMapping.slice(skip, Number(skip) + Number(limit)); + totalCount = enrichedMapping?.length; + result = enrichedMapping?.slice(skip, Number(skip) + Number(limit)); } } return { @@ -2029,8 +2029,8 @@ const getEntryUidMap = (uidMapperModel: any): Record => { }; const fromEntryUid = flattenNestedUidMap(pick(d.entryUid)); const fromEntry = flattenNestedUidMap(pick(d.entry)); - const nUid = Object.keys(fromEntryUid).length; - const nEnt = Object.keys(fromEntry).length; + const nUid = Object?.keys(fromEntryUid).length; + const nEnt = Object?.keys(fromEntry).length; if (nUid > 0 && nEnt > 0) { return { ...fromEntry, ...fromEntryUid }; } @@ -2040,9 +2040,9 @@ const getEntryUidMap = (uidMapperModel: any): Record => { }; const flattenNestedUidMap = (raw: Record): Record => { - const keys = Object.keys(raw ?? {}); - if (keys.length === 0) return {}; - const nested = keys.every((k) => { + const keys = Object?.keys(raw ?? {}); + if (keys?.length === 0) return {}; + const nested = keys?.every((k) => { const v = raw[k]; return v != null && typeof v === 'object' && !Array.isArray(v); }); @@ -2060,7 +2060,7 @@ const enrichEntriesWithUidMapper = async ( iteration: number, entries: any[], ): Promise => { - if (!Array.isArray(entries) || entries.length === 0) return entries; + if (!Array.isArray(entries) || entries?.length === 0) return entries; const currentModel = getUidMapperDb(projectId, iteration); await currentModel.read(); @@ -2071,15 +2071,15 @@ const enrichEntriesWithUidMapper = async ( await prevModel.read(); } - return entries.map((item: any) => { + return entries?.map((item: any) => { if (!item) return item; - const existing = item.contentstackEntryUid; + const existing = item?.contentstackEntryUid; if (existing != null && String(existing).trim() !== '' && existing !== ' ') { return item; } const resolved = resolveContentstackEntryUidAcrossIterations( - item.otherCmsEntryUid, - item.id, + item?.otherCmsEntryUid, + item?.id, currentModel, prevModel, ); diff --git a/api/src/services/migration.service.ts b/api/src/services/migration.service.ts index 1e9382a1b..f0bad4bc3 100644 --- a/api/src/services/migration.service.ts +++ b/api/src/services/migration.service.ts @@ -39,7 +39,12 @@ import fsPromises from 'fs/promises'; import { matchesSearchText } from '../utils/search.util.js'; import { taxonomyService } from './taxonomy.service.js'; import { globalFieldServie } from './globalField.service.js'; -import { getSafePath, sanitizeStackId } from '../utils/sanitize-path.utils.js'; +import { + assertResolvedPathUnderBase, + getSafePath, + sanitizeProjectId, + sanitizeStackId, +} from '../utils/sanitize-path.utils.js'; import { aemService } from './aem.service.js'; import { requestWithSsoTokenRefresh } from '../utils/sso-request.utils.js'; import { utilsUpdateCli } from './updateEntryCli.service.js'; @@ -301,12 +306,20 @@ const startTestMigration = async (req: Request): Promise => { const { legacy_cms: { cms, file_path }, } = project; + const logsBase = path.resolve(process.cwd(), 'logs'); + const safeTestProjectId = sanitizeProjectId(projectId); + const safeTestStackId = sanitizeStackId(project?.current_test_stack_id); + if (!safeTestProjectId || !safeTestStackId) { + throw new BadRequestError( + 'Invalid project or test stack identifier; cannot create log file path.' + ); + } const loggerPath = path.join( - process.cwd(), - 'logs', - projectId, - `${project?.current_test_stack_id}.log` + logsBase, + safeTestProjectId, + `${safeTestStackId}.log` ); + assertResolvedPathUnderBase(logsBase, loggerPath); const message = getLogMessage( 'startTestMigration', 'Starting Test Migration...', @@ -414,8 +427,8 @@ const startTestMigration = async (req: Request): Promise => { await copyLogsToTestStack(project?.current_test_stack_id, loggerPath); const contentTypes = await fieldAttacher({ orgId, - projectId, - destinationStackId: project?.current_test_stack_id, + projectId: safeTestProjectId, + destinationStackId: safeTestStackId, region, user_id, is_sso, @@ -700,12 +713,20 @@ const startMigration = async (req: Request): Promise => { const { legacy_cms: { cms, file_path }, } = project; + const logsBase = path.resolve(process.cwd(), 'logs'); + const safeFinalProjectId = sanitizeProjectId(projectId); + const safeFinalStackId = sanitizeStackId(project?.destination_stack_id); + if (!safeFinalProjectId || !safeFinalStackId) { + throw new BadRequestError( + 'Invalid project or destination stack identifier; cannot create log file path.' + ); + } const loggerPath = path.join( - process.cwd(), - 'logs', - projectId, - `${project?.destination_stack_id}.log` + logsBase, + safeFinalProjectId, + `${safeFinalStackId}.log` ); + assertResolvedPathUnderBase(logsBase, loggerPath); const message = getLogMessage( 'start Migration', 'Starting Migration...', @@ -1062,13 +1083,28 @@ const startMigration = async (req: Request): Promise => { .value(); const iteration = projectData?.iteration || 1; let configFilePath: string | null = null; + let safeDeltaMigrationLogPath: string | undefined; if (iteration > 1) { - await removeExistingAssets(projectId, loggerPath); - - configFilePath = await removeEntriesFromDatabase(projectId, loggerPath); - console.info("Config file written to:", configFilePath); - } + const logsBase = path.resolve(process.cwd(), 'logs'); + const safePid = sanitizeProjectId(projectId); + const safeStack = sanitizeStackId(project?.destination_stack_id); + if (safePid && safeStack) { + const candidate = path.join(logsBase, safePid, `${safeStack}.log`); + try { + assertResolvedPathUnderBase(logsBase, candidate); + safeDeltaMigrationLogPath = candidate; + } catch { + safeDeltaMigrationLogPath = undefined; + } + } + await removeExistingAssets(projectId, safeDeltaMigrationLogPath); + configFilePath = await removeEntriesFromDatabase( + projectId, + safeDeltaMigrationLogPath + ); + console.info('Config file written to:', configFilePath); + } await utilsCli?.runCli( region, @@ -1080,15 +1116,18 @@ const startMigration = async (req: Request): Promise => { ); if (configFilePath) { - console.info("Config file path:", configFilePath); - enrichConfigWithAssetMapping(configFilePath, projectId, iteration, loggerPath); - console.info("Asset mapping enriched into config"); + console.info('Config file path:', configFilePath); + enrichConfigWithAssetMapping( + configFilePath, + projectId, + iteration, + safeDeltaMigrationLogPath + ); await utilsUpdateCli?.updateEntryCli( - // - region, + region, user_id, project?.destination_stack_id, - loggerPath, + safeDeltaMigrationLogPath, configFilePath ); } @@ -1551,9 +1590,6 @@ const restartMigration = async (req: Request): Promise => { data.projects[projectIndex].isMigrationStarted = false; data.projects[projectIndex].current_step = 1; data.projects[projectIndex].status = 0; - data.projects[projectIndex].isMigrationStarted = false; - data.projects[projectIndex].isMigrationCompleted = false; - data.projects[projectIndex].migration_execution = false; data.projects[projectIndex].legacy_cms = { ...data.projects[projectIndex].legacy_cms, is_fileValid: false, diff --git a/api/src/services/runCli.service.ts b/api/src/services/runCli.service.ts index 59bcdc83f..99ba39908 100644 --- a/api/src/services/runCli.service.ts +++ b/api/src/services/runCli.service.ts @@ -64,17 +64,17 @@ const writeUidMapping = async (backupPath: string, projectId: string, iteration: const assetData = fs.readFileSync(assetMapperPath, 'utf-8'); const parsedData = JSON.parse(assetData); // Check if data is not empty - if (parsedData && Object.keys(parsedData).length > 0) { + if (parsedData && Object?.keys(parsedData)?.length > 0) { assetJson = parsedData; } } // If no meaningful data found and we have previous iteration, use fallback - if (Object.keys(assetJson).length === 0 && iteration > 1) { + if (Object?.keys(assetJson)?.length === 0 && iteration > 1) { const prevAssetMapperPath = path.join(process.cwd(), 'database', projectId, (iteration - 1).toString(), 'uid-mapper.json'); if (fs.existsSync(prevAssetMapperPath)) { const prevData = JSON.parse(fs.readFileSync(prevAssetMapperPath, 'utf-8')); - assetJson = prevData.assets || {}; + assetJson = prevData?.assets || {}; } } @@ -86,18 +86,18 @@ const writeUidMapping = async (backupPath: string, projectId: string, iteration: const entryData = fs.readFileSync(entryMapperPath, 'utf-8'); const parsedData = JSON.parse(entryData); // Check if data is not empty - if (parsedData && Object.keys(parsedData).length > 0) { + if (parsedData && Object?.keys(parsedData)?.length > 0) { entryJson = parsedData; } } // If no meaningful data found and we have previous iteration, use fallback - if (Object.keys(entryJson).length === 0 && iteration > 1) { + if (Object?.keys(entryJson)?.length === 0 && iteration > 1) { const prevEntryMapperPath = path.join(process.cwd(), 'database', projectId, (iteration - 1).toString(), 'uid-mapper.json'); if (fs.existsSync(prevEntryMapperPath)) { const prevData = JSON.parse(fs.readFileSync(prevEntryMapperPath, 'utf-8')); console.info('Using previous iteration data for entries:', prevData); - entryJson = prevData.entry || {}; + entryJson = prevData?.entry || {}; } } diff --git a/api/src/services/updateEntryCli.service.ts b/api/src/services/updateEntryCli.service.ts index 11ed8db64..fb1faf346 100644 --- a/api/src/services/updateEntryCli.service.ts +++ b/api/src/services/updateEntryCli.service.ts @@ -93,7 +93,7 @@ const runCommand = ( console.error('Error writing close event to log file:', err); } } - // reject(new Error(`Command failed with exit code ${code}`)); + reject(new Error(`Command failed with exit code ${code}`)); } }); }); @@ -161,7 +161,7 @@ export const updateEntryCli = async ( const directLogEntry3 = { level: 'info', - message: `Authentication configured for user: ${userData.email}`, + message: `Authentication configured for user: ${userData?.email}`, methodName: 'updateEntryCli', timestamp: new Date().toISOString(), }; diff --git a/api/src/utils/asset-update.utils.ts b/api/src/utils/asset-update.utils.ts index 9aa38514a..d1eb8e48a 100644 --- a/api/src/utils/asset-update.utils.ts +++ b/api/src/utils/asset-update.utils.ts @@ -41,8 +41,8 @@ const replaceAssetRefsInObject = ( const value = obj[key]; if (!value || typeof value !== "object") continue; - if (value.uid && assetUidMap.has(value.uid)) { - obj[key] = assetUidMap.get(value.uid); + if (value?.uid && assetUidMap?.has(value?.uid)) { + obj[key] = assetUidMap?.get(value?.uid); modified = true; } else { const childModified = replaceAssetRefsInObject(value, assetUidMap); @@ -77,7 +77,7 @@ const saveAssetMetadata = ( fs.mkdirSync(metadataDir, { recursive: true }); const metadataPath = path.join(metadataDir, "asset-metadata.json"); fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf-8"); - writeLogEntry(`Asset metadata saved: ${Object.keys(metadata).length} assets → ${metadataPath}`, "saveAssetMetadata", loggerPath); + writeLogEntry(`Asset metadata saved: ${Object?.keys(metadata)?.length} assets → ${metadataPath}`, "saveAssetMetadata", loggerPath); }; /** @@ -225,7 +225,7 @@ export const removeExistingAssets = async (projectId: string, loggerPath?: strin writeLogEntry(`Previous asset uid map loaded from ${prevIteration} iteration.`, "removeExistingAssets", loggerPath); const prevMetadata = loadPreviousAssetMetadata(projectId, prevIteration); - if (!Object.keys(prevAssetUidMap).length) { + if (!Object?.keys(prevAssetUidMap)?.length) { writeLogEntry("No previous asset uid mapping found, skipping dedup.", "removeExistingAssets", loggerPath); return; } @@ -247,7 +247,7 @@ export const removeExistingAssets = async (projectId: string, loggerPath?: strin } } - if (!assetsToReuse.size) { + if (!assetsToReuse?.size) { writeLogEntry("No unchanged assets to deduplicate.", "removeExistingAssets", loggerPath); return; } @@ -260,10 +260,10 @@ export const removeExistingAssets = async (projectId: string, loggerPath?: strin if (fs.existsSync(entriesDir)) { const contentTypeDirs = fs.readdirSync(entriesDir, { withFileTypes: true }) - .filter((d) => d.isDirectory()); + ?.filter((d) => d?.isDirectory()); for (const ctDir of contentTypeDirs) { - const ctPath = path.join(entriesDir, ctDir.name); + const ctPath = path.join(entriesDir, ctDir?.name); if (!fs.existsSync(ctPath)) { console.warn(`Content type directory not found: ${ctPath}`); @@ -271,10 +271,10 @@ export const removeExistingAssets = async (projectId: string, loggerPath?: strin } const localeDirs = fs.readdirSync(ctPath, { withFileTypes: true }) - .filter((d) => d.isDirectory()); + ?.filter((d) => d?.isDirectory()); for (const localeDir of localeDirs) { - const localePath = path.join(ctPath, localeDir.name); + const localePath = path.join(ctPath, localeDir?.name); if (!fs.existsSync(localePath)) { console.warn(`Locale directory not found: ${localePath}`); @@ -319,7 +319,7 @@ export const removeExistingAssets = async (projectId: string, loggerPath?: strin writeLogEntry(`Asset "${assetId}" has been removed from migration data (already exists in Contentstack)`, "removeExistingAssets", loggerPath); } fs.writeFileSync(indexPath, JSON.stringify(indexData, null, 4), "utf-8"); - writeLogEntry(`Removed ${assetsToRemoveFromIndex.length} assets from index.json`, "removeExistingAssets", loggerPath); + writeLogEntry(`Removed ${assetsToRemoveFromIndex?.length} assets from index.json`, "removeExistingAssets", loggerPath); // 3. Remove asset file folders const filesDir = path.join(assetsDir, "files"); @@ -336,7 +336,7 @@ export const removeExistingAssets = async (projectId: string, loggerPath?: strin writeLogEntry( `Asset dedup complete: ${assetsToReuse.size} reused, ` + - `${Object.keys(indexData).length} remaining for import.`, + `${Object?.keys(indexData)?.length} remaining for import.`, "removeExistingAssets", loggerPath ); diff --git a/api/src/utils/content-type-checker.utils.ts b/api/src/utils/content-type-checker.utils.ts index d816eeb3c..5797a8b29 100644 --- a/api/src/utils/content-type-checker.utils.ts +++ b/api/src/utils/content-type-checker.utils.ts @@ -90,8 +90,8 @@ export const getPreviouslyCreatedContentTypes = async ( // Collect all content type UIDs from this iteration const contentTypes = contentTypesMapperDb.data?.ContentTypesMappers || []; contentTypes.forEach((ct: any) => { - if (ct.otherCmsUid) { - existingContentTypes.add(ct.otherCmsUid); + if (ct?.otherCmsUid) { + existingContentTypes.add(ct?.otherCmsUid); } }); } catch (error) { diff --git a/api/src/utils/entry-duplicate.utils.ts b/api/src/utils/entry-duplicate.utils.ts index 62f446058..55e78502b 100644 --- a/api/src/utils/entry-duplicate.utils.ts +++ b/api/src/utils/entry-duplicate.utils.ts @@ -15,7 +15,7 @@ export const isDuplicateEntry = async (projectId: string) => { await entryMapper.update((data: any) => { data?.entry_mapper?.forEach((item: any, index: number) => { - const key = `${item.contentTypeId}_${item.language}_${item.entryName}`; + const key = `${item?.contentTypeId}_${item?.language}_${item?.entryName}`; if (seen.has(key)) { const firstIndex = seen.get(key); diff --git a/api/src/utils/entry-update-script.cjs b/api/src/utils/entry-update-script.cjs index 173c3c9a9..29f667772 100644 --- a/api/src/utils/entry-update-script.cjs +++ b/api/src/utils/entry-update-script.cjs @@ -72,8 +72,8 @@ const mergeFlatPayloadIntoEntry = async (entry, entryUid, updateData, oldMapping continue; } if (field === 'title') { - if (updateData.title !== undefined && updateData.title !== null) { - entry.title = updateData.title; + if (updateData?.title !== undefined && updateData?.title !== null) { + entry.title = updateData?.title; } continue; } @@ -83,7 +83,7 @@ const mergeFlatPayloadIntoEntry = async (entry, entryUid, updateData, oldMapping field, entryUid, nextVal, - entry.content[field], + entry?.content[field], oldMapping, newMapping ); @@ -125,26 +125,26 @@ module.exports = async ({ .entry(entryUid); - const entry = await entryRef.fetch(); + const entry = await entryRef?.fetch(); const updateData = JSON.parse(JSON.stringify(config[contentType][entryUid])); - const hasStackContent = entry.content && typeof entry.content === 'object'; - const hasNestedUpdate = updateData.content && typeof updateData.content === 'object'; + const hasStackContent = entry?.content && typeof entry?.content === 'object'; + const hasNestedUpdate = updateData?.content && typeof updateData?.content === 'object'; if (hasStackContent && hasNestedUpdate) { - for (const field of Object.keys(updateData.content)) { - if (isAssetField(updateData.content[field])) { + for (const field of Object.keys(updateData?.content)) { + if (isAssetField(updateData?.content[field])) { updateData.content[field] = resolveAssetField( field, entryUid, - updateData.content[field], - entry.content[field], + updateData?.content[field], + entry?.content[field], oldMapping, newMapping ); } } - Object.assign(entry.content, updateData.content); + Object.assign(entry?.content, updateData?.content); await entry.update(); } else if (hasStackContent) { console.info(`[${entryUid}] Merging flat migration payload into entry.content (e.g. WordPress export)`); diff --git a/api/src/utils/entry-update.utils.ts b/api/src/utils/entry-update.utils.ts index d1e8770cd..f4dd394c1 100644 --- a/api/src/utils/entry-update.utils.ts +++ b/api/src/utils/entry-update.utils.ts @@ -19,19 +19,6 @@ const writeLogEntry = (message: string, methodName: string, loggerPath?: string) } }; -// export const getEntriesToUpdate = async (projectId: string) => { -// await ProjectModelLowdb.read(); -// const projectData = ProjectModelLowdb.chain -// .get("projects") -// .find({ id: projectId }) -// .value(); -// const iteration = projectData?.iteration || 1; -// const updateEntryDataDb = getEntryMapperDb(projectId, iteration); -// await updateEntryDataDb.read(); -// const entriesToUpdate = updateEntryDataDb.chain.get("entry_mapper").filter({ isUpdate: true }).value(); -// return entriesToUpdate; -// }; - export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: string): Promise => { const entriesToUpdate: Record> = {}; @@ -52,13 +39,13 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: } const sitecoreUids = new Set( - entryMapperItems.map((item: { otherCmsEntryUid: string }) => item.otherCmsEntryUid) + entryMapperItems.map((item: { otherCmsEntryUid: string }) => item?.otherCmsEntryUid) ); const updateUidMap = new Map(); for (const item of entryMapperItems) { if (item.isUpdate) { - updateUidMap.set(item.otherCmsEntryUid, item.contentstackEntryUid); + updateUidMap.set(item?.otherCmsEntryUid, item?.contentstackEntryUid); } } @@ -81,12 +68,12 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: const contentTypeName = ctDir.name; const ctPath = path.join(entriesDir, contentTypeName); const localeDirs = fs.readdirSync(ctPath, { withFileTypes: true }) - .filter((dirent) => dirent.isDirectory()); + ?.filter((dirent) => dirent?.isDirectory()); for (const localeDir of localeDirs) { const localePath = path.join(ctPath, localeDir.name); const jsonFiles = fs.readdirSync(localePath) - .filter((file) => file.endsWith(".json") && file !== "index.json"); + ?.filter((file) => file?.endsWith(".json") && file !== "index.json"); for (const jsonFile of jsonFiles) { const filePath = path.join(localePath, jsonFile); @@ -94,12 +81,12 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: const data = JSON.parse(raw); let modified = false; - for (const key of Object.keys(data)) { + for (const key of Object?.keys(data)) { if (sitecoreUids.has(key)) { const csEntryUid = updateUidMap.get(key); if (csEntryUid) { const entryData = { ...data[key] }; - delete entryData.uid; + delete entryData?.uid; if (!entriesToUpdate[contentTypeName]) { entriesToUpdate[contentTypeName] = {}; @@ -130,7 +117,7 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: writeLogEntry("Finished removing entries from cmsMigrationData.", "removeEntriesFromDatabase", loggerPath); writeLogEntry(`Config written to: ${configPath}`, "removeEntriesFromDatabase", loggerPath); - writeLogEntry(`Total entries prepared for update: ${Object.keys(entriesToUpdate).reduce((total, ct) => total + Object.keys(entriesToUpdate[ct]).length, 0)}`, "removeEntriesFromDatabase", loggerPath); + writeLogEntry(`Total entries prepared for update: ${Object?.keys(entriesToUpdate)?.reduce((total, ct) => total + Object?.keys(entriesToUpdate[ct])?.length, 0)}`, "removeEntriesFromDatabase", loggerPath); return configPath; }; @@ -155,7 +142,7 @@ export const enrichConfigWithAssetMapping = ( if (fs.existsSync(oldPath)) { try { const data = JSON.parse(fs.readFileSync(oldPath, "utf-8")); - oldAssetMapping = data.assets || {}; + oldAssetMapping = data?.assets || {}; writeLogEntry(`Loaded ${Object.keys(oldAssetMapping).length} old asset mappings from iteration ${iteration - 1}`, "enrichConfigWithAssetMapping", loggerPath); } catch (err) { console.error("Failed to read old uid-mapper:", err); @@ -170,7 +157,7 @@ export const enrichConfigWithAssetMapping = ( if (fs.existsSync(newPath)) { try { const data = JSON.parse(fs.readFileSync(newPath, "utf-8")); - newAssetMapping = data.assets || {}; + newAssetMapping = data?.assets || {}; writeLogEntry(`Loaded ${Object.keys(newAssetMapping).length} new asset mappings from iteration ${iteration}`, "enrichConfigWithAssetMapping", loggerPath); } catch (err) { console.error("Failed to read new uid-mapper:", err); @@ -179,14 +166,7 @@ export const enrichConfigWithAssetMapping = ( writeLogEntry(`No new asset mapping found for iteration ${iteration}`, "enrichConfigWithAssetMapping", loggerPath); } - // const config = JSON.parse(fs.readFileSync(configFilePath, "utf-8")); - // config.__assetMapping__ = { - // old: oldAssetMapping, - // new: newAssetMapping, - // }; - // fs.writeFileSync(configFilePath, JSON.stringify(config), "utf-8"); - - writeLogEntry(`Asset mapping enriched into config: old=${Object.keys(oldAssetMapping).length} keys, new=${Object.keys(newAssetMapping).length} keys`, "enrichConfigWithAssetMapping", loggerPath); + writeLogEntry(`Asset mapping enriched into config: old=${Object?.keys(oldAssetMapping)?.length} keys, new=${Object?.keys(newAssetMapping)?.length} keys`, "enrichConfigWithAssetMapping", loggerPath); writeLogEntry(`Asset mapping configuration has been enriched for iteration ${iteration}`, "enrichConfigWithAssetMapping", loggerPath); writeLogEntry(`Asset references will be resolved using combined old and new mappings`, "enrichConfigWithAssetMapping", loggerPath); }; \ No newline at end of file diff --git a/api/src/utils/field-attacher.utils.ts b/api/src/utils/field-attacher.utils.ts index 0b50afb97..f5eb95ea6 100644 --- a/api/src/utils/field-attacher.utils.ts +++ b/api/src/utils/field-attacher.utils.ts @@ -31,13 +31,13 @@ export const fieldAttacher = async ({ projectId, orgId, destinationStackId, regi return field; }) } - // await contenTypeMaker({ contentType, destinationStackId, projectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) + if (iteration === 1) { await contenTypeMaker({ contentType, destinationStackId, projectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) } else { - const shouldSkip = await shouldSkipContentTypeCreation(projectId, contentType.otherCmsUid, iteration); + const shouldSkip = await shouldSkipContentTypeCreation(projectId, contentType?.otherCmsUid, iteration); if (!shouldSkip) { console.info(`Creating new content type: ${contentType.otherCmsUid}`); await contenTypeMaker({ contentType, destinationStackId, projectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) diff --git a/api/src/utils/sanitize-path.utils.ts b/api/src/utils/sanitize-path.utils.ts index 961bf3eff..45f9a16dc 100644 --- a/api/src/utils/sanitize-path.utils.ts +++ b/api/src/utils/sanitize-path.utils.ts @@ -73,6 +73,26 @@ export const sanitizeStackId = ( return safeValue; }; +/** Same rules as stack IDs (UUIDs, API keys); use for path segments such as `database//`. */ +export const sanitizeProjectId = sanitizeStackId; + +/** + * Throws if {@link targetPath} resolves outside {@link baseDir} (after path.resolve). + */ +export const assertResolvedPathUnderBase = ( + baseDir: string, + targetPath: string +): void => { + const base = path.resolve(baseDir); + const resolved = path.resolve(targetPath); + const rel = path.relative(base, resolved); + if (rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error( + 'Invalid path: resolved location is outside the allowed base directory' + ); + } +}; + /** * Resolves and validates a safe path dynamically. * Supports full paths, path.join(), and path.resolve(). diff --git a/api/tests/unit/helper/index.test.ts b/api/tests/unit/helper/index.test.ts new file mode 100644 index 000000000..021e01e2f --- /dev/null +++ b/api/tests/unit/helper/index.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockCreateConnection = vi.fn(); +const mockConnect = vi.fn(); +const mockDestroy = vi.fn(); +const mockEnd = vi.fn(); +const mockCustomLogger = vi.fn().mockResolvedValue(undefined); + +vi.mock('mysql2', () => ({ + default: { + createConnection: (...args: unknown[]) => mockCreateConnection(...args), + }, +})); + +vi.mock('../../../src/utils/custom-logger.utils.js', () => ({ + default: (...args: unknown[]) => mockCustomLogger(...args), +})); + +import { createDbConnection, getDbConnection } from '../../../src/helper/index.js'; + +describe('helper createDbConnection', () => { + const config = { + host: 'h', + user: 'u', + password: 'p', + database: 'd', + port: '3306', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockCustomLogger.mockReset(); + mockCustomLogger.mockResolvedValue(undefined); + mockDestroy.mockClear(); + mockEnd.mockImplementation((cb?: (err?: Error) => void) => { + if (cb) cb(); + }); + mockCreateConnection.mockReturnValue({ + connect: mockConnect, + destroy: mockDestroy, + end: mockEnd, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves connection on successful connect', async () => { + mockConnect.mockImplementation((cb: (err: Error | null) => void) => { + cb(null); + }); + const conn = await createDbConnection(config, 'proj', 'stack'); + expect(conn).toBeDefined(); + expect(mockCreateConnection).toHaveBeenCalled(); + }); + + it('still resolves connection when info logger throws synchronously', async () => { + mockCustomLogger.mockImplementation((_p, _s, level: string) => { + if (level === 'info') throw new Error('sync log'); + return Promise.resolve(); + }); + mockConnect.mockImplementation((cb: (err: Error | null) => void) => { + cb(null); + }); + const conn = await createDbConnection(config, 'proj', 'stack'); + expect(conn).toBeDefined(); + }); + + it('still resolves connection when info logger rejects asynchronously', async () => { + mockCustomLogger.mockImplementation((_p, _s, level: string) => + level === 'info' ? Promise.reject(new Error('async log')) : Promise.resolve() + ); + mockConnect.mockImplementation((cb: (err: Error | null) => void) => { + cb(null); + }); + const conn = await createDbConnection(config, 'proj', 'stack'); + expect(conn).toBeDefined(); + }); + + it('rejects when connect returns error', async () => { + const dbErr = new Error('conn refused'); + mockConnect.mockImplementation((cb: (err: Error | null) => void) => { + cb(dbErr); + }); + await expect(createDbConnection(config, 'proj', 'stack')).rejects.toThrow('conn refused'); + expect(mockEnd).toHaveBeenCalled(); + }); + + it('rejects with original DB error when error logger throws synchronously', async () => { + const dbErr = new Error('conn refused'); + mockConnect.mockImplementation((cb: (err: Error | null) => void) => { + cb(dbErr); + }); + mockCustomLogger.mockImplementation((_p, _s, level: string) => { + if (level === 'error') throw new Error('logger broke'); + return Promise.resolve(); + }); + await expect(createDbConnection(config, 'proj', 'stack')).rejects.toThrow('conn refused'); + }); + + it('logs warn when connection.end reports an error after connect failure', async () => { + const dbErr = new Error('conn refused'); + mockConnect.mockImplementation((cb: (err: Error | null) => void) => { + cb(dbErr); + }); + mockEnd.mockImplementation((cb?: (e?: Error) => void) => { + if (cb) cb(new Error('end failed')); + }); + await expect(createDbConnection(config, 'proj', 'stack')).rejects.toThrow('conn refused'); + }); + + it('returns null when createConnection throws synchronously', async () => { + mockCreateConnection.mockImplementation(() => { + throw new Error('bad config'); + }); + const conn = await createDbConnection(config, 'proj', 'stack'); + expect(conn).toBeNull(); + }); + + it('times out when connect never completes', async () => { + vi.useFakeTimers(); + mockConnect.mockImplementation(() => {}); + const p = createDbConnection(config, 'proj', 'stack', 5000); + vi.advanceTimersByTime(5000); + await expect(p).rejects.toThrow('timed out'); + expect(mockDestroy).toHaveBeenCalled(); + }); + + it('getDbConnection throws when connection is null', async () => { + mockCreateConnection.mockImplementation(() => { + throw new Error('fail'); + }); + await expect(getDbConnection(config, 'p', 's')).rejects.toThrow('Could not establish database connection'); + }); + + it('getDbConnection returns connection on success', async () => { + mockConnect.mockImplementation((cb: (err: Error | null) => void) => { + cb(null); + }); + const conn = await getDbConnection(config, 'p', 's'); + expect(conn).toBe(mockCreateConnection.mock.results[0].value); + }); +}); diff --git a/api/tests/unit/middlewares/auth.middleware.test.ts b/api/tests/unit/middlewares/auth.middleware.test.ts index 248e596a3..513858a6b 100644 --- a/api/tests/unit/middlewares/auth.middleware.test.ts +++ b/api/tests/unit/middlewares/auth.middleware.test.ts @@ -24,7 +24,7 @@ describe('auth.middleware', () => { next = vi.fn(); }); - it('should return 401 when app_token header is missing', () => { + it('should return 401 when app_token header is missing or empty', () => { req.get.mockReturnValue(undefined); authenticateUser(req, res, next); @@ -34,6 +34,12 @@ describe('auth.middleware', () => { expect.objectContaining({ message: 'Unauthorized - Token missing' }) ); expect(next).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + req.get.mockReturnValue(''); + authenticateUser(req, res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); }); it('should return 401 when JWT verification fails', () => { diff --git a/api/tests/unit/middlewares/logger.middleware.test.ts b/api/tests/unit/middlewares/logger.middleware.test.ts new file mode 100644 index 000000000..31dd98a91 --- /dev/null +++ b/api/tests/unit/middlewares/logger.middleware.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; + +describe('logger.middleware', () => { + it('exports express-winston middleware', async () => { + const mod = await import('../../../src/middlewares/logger.middleware.js'); + expect(mod.default).toBeDefined(); + }); +}); diff --git a/api/tests/unit/middlewares/unmatched-routes.middleware.test.ts b/api/tests/unit/middlewares/unmatched-routes.middleware.test.ts index f682aebd3..25ec75e38 100644 --- a/api/tests/unit/middlewares/unmatched-routes.middleware.test.ts +++ b/api/tests/unit/middlewares/unmatched-routes.middleware.test.ts @@ -2,21 +2,19 @@ import { describe, it, expect, vi } from 'vitest'; import { unmatchedRoutesMiddleware } from '../../../src/middlewares/unmatched-routes.middleware.js'; describe('unmatched-routes.middleware', () => { - it('should return 404 with route error message', () => { + it('returns 404 JSON payload', () => { const req = {} as any; - const res = { - status: vi.fn().mockReturnThis(), - json: vi.fn().mockReturnThis(), - }; + const status = vi.fn().mockReturnThis(); + const json = vi.fn(); + const res = { status, json } as any; - unmatchedRoutesMiddleware(req, res as any); + unmatchedRoutesMiddleware(req, res); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ - error: { - code: 404, - message: 'Sorry, the requested resource is not available.', - }, - }); + expect(status).toHaveBeenCalledWith(404); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ code: 404 }), + }) + ); }); }); diff --git a/api/tests/unit/models/FieldMapper.model.test.ts b/api/tests/unit/models/FieldMapper.model.test.ts index 96efda500..f8fe1a060 100644 --- a/api/tests/unit/models/FieldMapper.model.test.ts +++ b/api/tests/unit/models/FieldMapper.model.test.ts @@ -24,7 +24,8 @@ describe('FieldMapper model', () => { }); it('should export db with field_mapper array in default data', async () => { - const fieldMapperDb = (await import('../../../src/models/FieldMapper.js')).default; + const getFieldMapperDb = (await import('../../../src/models/FieldMapper.js')).default; + const fieldMapperDb = getFieldMapperDb('test-project', 0); expect(fieldMapperDb).toBeDefined(); expect(fieldMapperDb.data).toBeDefined(); @@ -34,7 +35,8 @@ describe('FieldMapper model', () => { }); it('should have correct default structure for FieldMapper', async () => { - const fieldMapperDb = (await import('../../../src/models/FieldMapper.js')).default; + const getFieldMapperDb = (await import('../../../src/models/FieldMapper.js')).default; + const fieldMapperDb = getFieldMapperDb('test-project', 0); expect(fieldMapperDb.data).toMatchObject({ field_mapper: [], diff --git a/api/tests/unit/models/contentTypesMapper-lowdb.model.test.ts b/api/tests/unit/models/contentTypesMapper-lowdb.model.test.ts index 66ce8ce16..ab7a5eb29 100644 --- a/api/tests/unit/models/contentTypesMapper-lowdb.model.test.ts +++ b/api/tests/unit/models/contentTypesMapper-lowdb.model.test.ts @@ -24,7 +24,8 @@ describe('contentTypesMapper-lowdb model', () => { }); it('should export db with ContentTypesMappers array in default data', async () => { - const contentTypesDb = (await import('../../../src/models/contentTypesMapper-lowdb.js')).default; + const getContentTypesMapperDb = (await import('../../../src/models/contentTypesMapper-lowdb.js')).default; + const contentTypesDb = getContentTypesMapperDb('test-project', 0); expect(contentTypesDb).toBeDefined(); expect(contentTypesDb.data).toBeDefined(); @@ -34,7 +35,8 @@ describe('contentTypesMapper-lowdb model', () => { }); it('should have correct default structure for ContentTypeMapperDocument', async () => { - const contentTypesDb = (await import('../../../src/models/contentTypesMapper-lowdb.js')).default; + const getContentTypesMapperDb = (await import('../../../src/models/contentTypesMapper-lowdb.js')).default; + const contentTypesDb = getContentTypesMapperDb('test-project', 0); expect(contentTypesDb.data).toMatchObject({ ContentTypesMappers: [], diff --git a/api/tests/unit/models/entry-mapper.model.test.ts b/api/tests/unit/models/entry-mapper.model.test.ts new file mode 100644 index 000000000..de89b87a1 --- /dev/null +++ b/api/tests/unit/models/entry-mapper.model.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockMkdirSync = vi.fn(); + +vi.mock('node:fs', () => ({ + default: { mkdirSync: mockMkdirSync }, +})); + +vi.mock('lowdb/node', () => ({ + JSONFile: vi.fn(function JSONFile(this: { path: unknown }, p: unknown) { + this.path = p; + }), +})); + +vi.mock('../../../src/utils/lowdb-lodash.utils.js', () => ({ + default: class LowWithLodash { + adapter: unknown; + data: unknown; + constructor(adapter: unknown, data: unknown) { + this.adapter = adapter; + this.data = data; + } + }, +})); + +describe('EntryMapper factory', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('getEntryMapperDb creates db under database/{projectId}/{iteration}', async () => { + const getEntryMapperDb = (await import('../../../src/models/EntryMapper.js')).default; + const db = getEntryMapperDb('proj-a', 3); + expect(mockMkdirSync).toHaveBeenCalled(); + expect(db).toBeDefined(); + }); +}); diff --git a/api/tests/unit/models/uid-mapper.model.test.ts b/api/tests/unit/models/uid-mapper.model.test.ts new file mode 100644 index 000000000..25e9ac074 --- /dev/null +++ b/api/tests/unit/models/uid-mapper.model.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockMkdirSync = vi.fn(); + +vi.mock('node:fs', () => ({ + default: { mkdirSync: mockMkdirSync }, +})); + +vi.mock('lowdb/node', () => ({ + JSONFile: vi.fn(function JSONFile(this: { path: unknown }, p: unknown) { + this.path = p; + }), +})); + +vi.mock('../../../src/utils/lowdb-lodash.utils.js', () => ({ + default: class LowWithLodash { + adapter: unknown; + data: unknown; + constructor(adapter: unknown, data: unknown) { + this.adapter = adapter; + this.data = data; + } + }, +})); + +describe('uidMapper factory', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('getUidMapperDb creates db under database/{projectId}/{iteration}', async () => { + const getUidMapperDb = (await import('../../../src/models/uidMapper.js')).default; + const db = getUidMapperDb('proj-b', 2); + expect(mockMkdirSync).toHaveBeenCalled(); + expect(db).toBeDefined(); + }); +}); diff --git a/api/tests/unit/routes/contentMapper.routes.test.ts b/api/tests/unit/routes/contentMapper.routes.test.ts index 5b9474f8b..b949cb1ab 100644 --- a/api/tests/unit/routes/contentMapper.routes.test.ts +++ b/api/tests/unit/routes/contentMapper.routes.test.ts @@ -12,6 +12,8 @@ vi.mock('../../../src/controllers/projects.contentMapper.controller.js', () => ( resetContentType: vi.fn((_req: any, res: any) => res.status(200).json({})), removeContentMapper: vi.fn((_req: any, res: any) => res.status(200).json({})), updateContentMapper: vi.fn((_req: any, res: any) => res.status(200).json({})), + getEntryMapping: vi.fn((_req: any, res: any) => res.status(200).json({})), + updateEntryStatus: vi.fn((_req: any, res: any) => res.status(200).json({})), }, })); diff --git a/api/tests/unit/routes/migration.routes.test.ts b/api/tests/unit/routes/migration.routes.test.ts index 85ce2f391..2a6b1d7f7 100644 --- a/api/tests/unit/routes/migration.routes.test.ts +++ b/api/tests/unit/routes/migration.routes.test.ts @@ -10,6 +10,7 @@ vi.mock('../../../src/controllers/migration.controller.js', () => ({ getAuditData: vi.fn((_req: any, res: any) => res.status(200).json({})), saveLocales: vi.fn((_req: any, res: any) => res.status(200).json({})), saveMappedLocales: vi.fn((_req: any, res: any) => res.status(200).json({})), + restartMigration: vi.fn((_req: any, res: any) => res.status(200).json({})), }, })); diff --git a/api/tests/unit/services/auth.service.refresh-oauth.test.ts b/api/tests/unit/services/auth.service.refresh-oauth.test.ts new file mode 100644 index 000000000..40382aced --- /dev/null +++ b/api/tests/unit/services/auth.service.refresh-oauth.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + mockAuthRead, + mockAuthUpdate, + mockChainGet, + mockFindValue, + mockExistsSync, + mockReadFileSync, + mockAxiosPost, +} = vi.hoisted(() => { + const mockFindValue = vi.fn(); + const mockChainGet = vi.fn(() => ({ + find: vi.fn().mockReturnValue({ value: mockFindValue }), + })); + return { + mockAuthRead: vi.fn(), + mockAuthUpdate: vi.fn(), + mockChainGet, + mockFindValue, + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), + mockAxiosPost: vi.fn(), + }; +}); + +vi.mock('axios', () => ({ + default: { post: (...args: unknown[]) => mockAxiosPost(...args) }, +})); + +vi.mock('fs', () => ({ + default: { + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + }, +})); + +vi.mock('../../../src/utils/logger.js', () => ({ + default: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, +})); + +vi.mock('../../../src/utils/crypto.utils.js', () => ({ + decryptAppConfig: (c: Record) => c, +})); + +vi.mock('../../../src/models/authentication.js', () => ({ + default: { + read: mockAuthRead, + update: mockAuthUpdate, + chain: { + get: mockChainGet, + }, + }, +})); + +import { refreshOAuthToken } from '../../../src/services/auth.service.js'; + +describe('auth.service refreshOAuthToken', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAuthRead.mockResolvedValue(undefined); + mockAuthUpdate.mockImplementation((fn: (d: { users: unknown[] }) => void) => { + fn({ users: [{ user_id: 'u1', refresh_token: 'rt', email: 'a@b.com', region: 'NA' }] }); + }); + mockFindValue.mockReturnValue({ + user_id: 'u1', + refresh_token: 'rt-old', + email: 'a@b.com', + region: 'NA', + }); + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + oauthData: { + client_id: 'cid', + client_secret: 'sec', + redirect_uri: 'https://cb', + }, + }) + ); + mockAxiosPost.mockResolvedValue({ + data: { access_token: 'new-at', refresh_token: 'new-rt' }, + }); + }); + + it('throws when user record missing', async () => { + mockFindValue.mockReturnValue(null); + await expect(refreshOAuthToken('u1')).rejects.toThrow('User record not found'); + }); + + it('throws when refresh_token missing on user', async () => { + mockFindValue.mockReturnValue({ user_id: 'u1', email: 'a@b.com' }); + await expect(refreshOAuthToken('u1')).rejects.toThrow('No refresh token available'); + }); + + it('throws when app.json missing', async () => { + mockExistsSync.mockReturnValue(false); + await expect(refreshOAuthToken('u1')).rejects.toThrow('app.json file not found'); + }); + + it('throws when OAuth client fields missing', async () => { + mockReadFileSync.mockReturnValue(JSON.stringify({ oauthData: {} })); + await expect(refreshOAuthToken('u1')).rejects.toThrow('client_id or client_secret'); + }); + + it('posts refresh request and returns new access_token', async () => { + const token = await refreshOAuthToken('u1'); + expect(token).toBe('new-at'); + expect(mockAxiosPost).toHaveBeenCalled(); + expect(mockAuthUpdate).toHaveBeenCalled(); + }); + + it('wraps axios failures with friendly error', async () => { + mockAxiosPost.mockRejectedValue(new Error('network')); + await expect(refreshOAuthToken('u1')).rejects.toThrow('Failed to refresh token'); + }); +}); diff --git a/api/tests/unit/services/auth.service.sso.test.ts b/api/tests/unit/services/auth.service.sso.test.ts new file mode 100644 index 000000000..92412146b --- /dev/null +++ b/api/tests/unit/services/auth.service.sso.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + mockGenerateToken, + mockAuthRead, + mockAuthUpdate, + mockFindIndexInner, + mockFindInner, + mockExistsSync, + mockReadFileSync, + mockGetAppOrgUid, + mockChainGet, +} = vi.hoisted(() => { + const mockFindIndexInner = vi.fn(); + const mockFindInner = vi.fn(); + const mockChainGet = vi.fn(() => ({ + findIndex: vi.fn().mockReturnValue({ value: mockFindIndexInner }), + find: vi.fn().mockReturnValue({ value: mockFindInner }), + })); + return { + mockGenerateToken: vi.fn(() => 'app-jwt'), + mockAuthRead: vi.fn(), + mockAuthUpdate: vi.fn(), + mockFindIndexInner, + mockFindInner, + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), + mockGetAppOrgUid: vi.fn(() => 'org-match'), + mockChainGet, + }; +}); + +vi.mock('../../../src/utils/jwt.utils.js', () => ({ + generateToken: (...args: unknown[]) => mockGenerateToken(...args), +})); + +vi.mock('../../../src/models/authentication.js', () => ({ + default: { + read: mockAuthRead, + update: mockAuthUpdate, + chain: { + get: mockChainGet, + }, + }, +})); + +vi.mock('../../../src/utils/logger.js', () => ({ + default: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, +})); + +vi.mock('fs', () => ({ + default: { + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + }, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, +})); + +vi.mock('../../../src/utils/crypto.utils.js', () => ({ + decryptAppConfig: (c: Record) => c, +})); + +vi.mock('../../../src/utils/auth.utils.js', () => ({ + getAppOrganizationUID: () => mockGetAppOrgUid(), +})); + +import { getAppData, checkSSOAuthStatus } from '../../../src/services/auth.service.js'; +import { authService } from '../../../src/services/auth.service.js'; + +describe('auth.service SSO helpers', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAuthRead.mockResolvedValue(undefined); + mockAuthUpdate.mockImplementation(async (fn: (d: { users: unknown[] }) => void) => { + fn({ users: [] }); + }); + mockFindIndexInner.mockReturnValue(-1); + mockFindInner.mockReturnValue(null); + }); + + describe('getAppData', () => { + it('throws when app.json is missing', async () => { + mockExistsSync.mockReturnValue(false); + await expect(getAppData()).rejects.toThrow('app.json file not found'); + }); + + it('throws when isDefault is true', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ isDefault: true })); + await expect(getAppData()).rejects.toThrow('SSO is not configured'); + }); + + it('returns config when valid', async () => { + mockExistsSync.mockReturnValue(true); + const cfg = { isDefault: false, oauthData: { client_id: 'c' } }; + mockReadFileSync.mockReturnValue(JSON.stringify(cfg)); + await expect(getAppData()).resolves.toMatchObject(cfg); + }); + + it('throws on invalid JSON', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('{ not json'); + await expect(getAppData()).rejects.toThrow('Invalid JSON format'); + }); + }); + + describe('checkSSOAuthStatus', () => { + it('returns not authenticated when user missing', async () => { + mockFindInner.mockReturnValue(null); + await expect(checkSSOAuthStatus('u1')).resolves.toEqual({ + authenticated: false, + message: 'SSO authentication not completed', + }); + }); + + it('returns not authenticated when no access_token', async () => { + mockFindInner.mockReturnValue({ user_id: 'u1', email: 'a@b.com' }); + await expect(checkSSOAuthStatus('u1')).resolves.toMatchObject({ + authenticated: false, + }); + }); + + it('returns not authenticated when org mismatch', async () => { + mockGetAppOrgUid.mockReturnValueOnce('org-match'); + mockFindInner.mockReturnValue({ + user_id: 'u1', + access_token: 'tok', + organization_uid: 'other-org', + region: 'NA', + email: 'a@b.com', + updated_at: new Date().toISOString(), + }); + await expect(checkSSOAuthStatus('u1')).resolves.toMatchObject({ + authenticated: false, + message: 'Organization mismatch', + }); + }); + + it('returns success when token fresh and org matches', async () => { + mockGetAppOrgUid.mockReturnValue('org-match'); + mockFindInner.mockReturnValue({ + user_id: 'u1', + access_token: 'tok', + organization_uid: 'org-match', + region: 'NA', + email: 'a@b.com', + updated_at: new Date().toISOString(), + }); + + const out = await checkSSOAuthStatus('u1'); + expect(out.authenticated).toBe(true); + expect(out).toHaveProperty('app_token', 'app-jwt'); + expect(mockGenerateToken).toHaveBeenCalled(); + }); + }); + + describe('logout', () => { + it('throws when email missing', async () => { + await expect(authService.logout({ body: {} } as any)).rejects.toThrow('User not found'); + }); + + it('throws when user not in DB', async () => { + mockFindInner.mockReturnValue(null); + await expect( + authService.logout({ body: { email: 'missing@x.com' } } as any) + ).rejects.toThrow(); + }); + + it('removes user and returns 200', async () => { + mockFindInner.mockReturnValue({ email: 'a@b.com', user_id: 'u1' }); + const r = await authService.logout({ body: { email: 'a@b.com' } } as any); + expect(r.status).toBe(200); + expect(mockAuthUpdate).toHaveBeenCalled(); + }); + }); +}); diff --git a/api/tests/unit/services/contentMapper.service.test.ts b/api/tests/unit/services/contentMapper.service.test.ts index d48502d5f..25b0d1535 100644 --- a/api/tests/unit/services/contentMapper.service.test.ts +++ b/api/tests/unit/services/contentMapper.service.test.ts @@ -14,21 +14,79 @@ const { mockFieldMapperUpdate, mockUuidv4, mockFsPromises, -} = vi.hoisted(() => ({ - mockHttps: vi.fn(), - mockGetAuthToken: vi.fn(), - mockGetProjectUtil: vi.fn(), - mockFetchAllPaginatedData: vi.fn(), - mockProjectRead: vi.fn(), - mockProjectUpdate: vi.fn(), - mockProjectWrite: vi.fn(), - mockContentTypesMapperRead: vi.fn(), - mockContentTypesMapperUpdate: vi.fn(), - mockFieldMapperRead: vi.fn(), - mockFieldMapperUpdate: vi.fn(), - mockUuidv4: vi.fn(() => 'uuid-123'), - mockFsPromises: { lstat: vi.fn(), readFile: vi.fn(), realpath: vi.fn() }, -})); + mockContentTypesDb, + mockFieldDb, + getContentTypesMapperDbMock, + getFieldMapperDbMock, + mockEntryMapperDb, + mockUidMapperDb, + getEntryMapperDbMock, + getUidMapperDbMock, +} = vi.hoisted(() => { + const mockContentTypesMapperRead = vi.fn(); + const mockContentTypesMapperUpdate = vi.fn(); + const mockFieldMapperRead = vi.fn(); + const mockFieldMapperUpdate = vi.fn(); + const mockContentTypesChainGet = vi.fn(); + const mockFieldMapperChainGet = vi.fn(); + const mockContentTypesDb = { + read: mockContentTypesMapperRead, + update: mockContentTypesMapperUpdate, + write: vi.fn(), + chain: { get: mockContentTypesChainGet }, + data: { ContentTypesMappers: [] as unknown[] }, + }; + const mockFieldDb = { + read: mockFieldMapperRead, + update: mockFieldMapperUpdate, + write: vi.fn(), + chain: { get: mockFieldMapperChainGet }, + data: { field_mapper: [] as unknown[] }, + }; + const mockEntryMapperRead = vi.fn(); + const mockEntryMapperUpdate = vi.fn(); + const mockEntryMapperChainGet = vi.fn(); + const mockEntryMapperDb = { + read: mockEntryMapperRead, + update: mockEntryMapperUpdate, + write: vi.fn(), + chain: { get: mockEntryMapperChainGet }, + data: { entry_mapper: [] as unknown[] }, + }; + const mockUidMapperRead = vi.fn(); + const mockUidMapperUpdate = vi.fn(); + const mockUidMapperChainGet = vi.fn(); + const mockUidMapperDb = { + read: mockUidMapperRead, + update: mockUidMapperUpdate, + write: vi.fn(), + chain: { get: mockUidMapperChainGet }, + data: { entry: {} as Record, assets: {} as Record }, + }; + return { + mockHttps: vi.fn(), + mockGetAuthToken: vi.fn(), + mockGetProjectUtil: vi.fn(), + mockFetchAllPaginatedData: vi.fn(), + mockProjectRead: vi.fn(), + mockProjectUpdate: vi.fn(), + mockProjectWrite: vi.fn(), + mockContentTypesMapperRead, + mockContentTypesMapperUpdate, + mockFieldMapperRead, + mockFieldMapperUpdate, + mockUuidv4: vi.fn(() => 'uuid-123'), + mockFsPromises: { lstat: vi.fn(), readFile: vi.fn(), realpath: vi.fn() }, + mockContentTypesDb, + mockFieldDb, + getContentTypesMapperDbMock: vi.fn(() => mockContentTypesDb), + getFieldMapperDbMock: vi.fn(() => mockFieldDb), + mockEntryMapperDb, + mockUidMapperDb, + getEntryMapperDbMock: vi.fn(() => mockEntryMapperDb), + getUidMapperDbMock: vi.fn(() => mockUidMapperDb), + }; +}); vi.mock('../../../src/utils/https.utils.js', () => ({ default: mockHttps })); vi.mock('../../../src/utils/auth.utils.js', () => ({ default: mockGetAuthToken })); @@ -55,42 +113,35 @@ vi.mock('../../../src/models/project-lowdb.js', () => { }; }); -vi.mock('../../../src/models/contentTypesMapper-lowdb.js', () => { - const mockChainGet = vi.fn(); - return { - default: { - read: mockContentTypesMapperRead, - update: mockContentTypesMapperUpdate, - write: vi.fn(), - chain: { get: mockChainGet }, - data: { ContentTypesMappers: [] }, - }, - ContentTypesMapper: {}, - }; -}); +vi.mock('../../../src/models/contentTypesMapper-lowdb.js', () => ({ + default: getContentTypesMapperDbMock, + getContentTypesMapperDb: getContentTypesMapperDbMock, + ContentTypesMapper: {}, +})); -vi.mock('../../../src/models/FieldMapper.js', () => { - const mockChainGet = vi.fn(); +vi.mock('../../../src/models/FieldMapper.js', () => ({ + default: getFieldMapperDbMock, +})); + +vi.mock('../../../src/models/EntryMapper.js', () => ({ + default: getEntryMapperDbMock, +})); + +vi.mock('../../../src/models/uidMapper.js', () => ({ + default: getUidMapperDbMock, +})); + +vi.mock('fs', () => { + const mkdirSync = vi.fn(); return { - default: { - read: mockFieldMapperRead, - update: mockFieldMapperUpdate, - write: vi.fn(), - chain: { get: mockChainGet }, - data: { field_mapper: [] }, - }, + default: { promises: mockFsPromises, mkdirSync }, + mkdirSync, + promises: mockFsPromises, }; }); -vi.mock('fs', () => ({ - default: { promises: mockFsPromises }, - promises: mockFsPromises, -})); - import { contentMapperService } from '../../../src/services/contentMapper.service.js'; import ProjectModelLowdb from '../../../src/models/project-lowdb.js'; -import ContentTypesMapperModelLowdb from '../../../src/models/contentTypesMapper-lowdb.js'; -import FieldMapperModel from '../../../src/models/FieldMapper.js'; const createChain = (opts: { find?: unknown; @@ -109,10 +160,17 @@ const createChain = (opts: { describe('contentMapper.service', () => { beforeEach(() => { vi.clearAllMocks(); + (ProjectModelLowdb.chain.get as ReturnType).mockReset(); + (mockContentTypesDb.chain.get as ReturnType).mockReset(); + (mockFieldDb.chain.get as ReturnType).mockReset(); + (mockEntryMapperDb.chain.get as ReturnType).mockReset(); + (mockUidMapperDb.chain.get as ReturnType).mockReset(); mockGetAuthToken.mockResolvedValue('cs-auth-token'); mockProjectRead.mockResolvedValue(undefined); mockContentTypesMapperRead.mockResolvedValue(undefined); mockFieldMapperRead.mockResolvedValue(undefined); + (mockEntryMapperDb.read as ReturnType).mockResolvedValue(undefined); + (mockUidMapperDb.read as ReturnType).mockResolvedValue(undefined); mockProjectUpdate.mockImplementation(async (fn: (d: any) => void) => { const data = ProjectModelLowdb.data as any; if (!data.projects) data.projects = []; @@ -120,23 +178,35 @@ describe('contentMapper.service', () => { fn(data); }); mockContentTypesMapperUpdate.mockImplementation(async (fn: (d: any) => void) => { - const data = ContentTypesMapperModelLowdb.data as any; + const data = mockContentTypesDb.data as any; if (!data.ContentTypesMappers) data.ContentTypesMappers = []; while (data.ContentTypesMappers.length < 2) data.ContentTypesMappers.push({}); fn(data); }); mockFieldMapperUpdate.mockImplementation(async (fn: (d: any) => void) => { - const data = FieldMapperModel.data as any; + const data = mockFieldDb.data as any; if (!data.field_mapper) data.field_mapper = []; fn(data); }); + (mockEntryMapperDb.update as ReturnType).mockImplementation(async (fn: (d: any) => void) => { + const data = mockEntryMapperDb.data as any; + if (!data.entry_mapper) data.entry_mapper = []; + fn(data); + }); + (mockUidMapperDb.update as ReturnType).mockImplementation(async (fn: (d: any) => void) => { + fn(mockUidMapperDb.data as any); + }); mockFetchAllPaginatedData.mockResolvedValue([]); ProjectModelLowdb.data = { projects: [] }; - ContentTypesMapperModelLowdb.data = { ContentTypesMappers: [] }; - FieldMapperModel.data = { field_mapper: [] }; + mockContentTypesDb.data = { ContentTypesMappers: [] }; + mockFieldDb.data = { field_mapper: [] }; + mockEntryMapperDb.data = { entry_mapper: [] }; + mockUidMapperDb.data = { entry: {}, assets: {} }; (ProjectModelLowdb.chain.get as ReturnType).mockReturnValue(createChain({ find: null, findIndex: -1 })); - (ContentTypesMapperModelLowdb.chain.get as ReturnType).mockReturnValue(createChain({ find: null, findIndex: -1 })); - (FieldMapperModel.chain.get as ReturnType).mockReturnValue(createChain({ find: null, findIndex: -1 })); + (mockContentTypesDb.chain.get as ReturnType).mockReturnValue(createChain({ find: null, findIndex: -1 })); + (mockFieldDb.chain.get as ReturnType).mockReturnValue(createChain({ find: null, findIndex: -1 })); + (mockEntryMapperDb.chain.get as ReturnType).mockReturnValue(createChain({ find: null, findIndex: -1 })); + (mockUidMapperDb.chain.get as ReturnType).mockReturnValue(createChain({ find: null, findIndex: -1 })); }); describe('putTestData', () => { @@ -171,9 +241,9 @@ describe('contentMapper.service', () => { ProjectModelLowdb.data.projects = [project]; mockProjectWrite.mockResolvedValue(undefined); - (ProjectModelLowdb.chain.get as ReturnType) - .mockReturnValueOnce(createChain({ findIndex: 0 })) - .mockReturnValueOnce(createChain({ find: project })); + (ProjectModelLowdb.chain.get as ReturnType).mockReturnValue( + createChain({ find: project, findIndex: 0 }) + ); const req = { params: { projectId: 'proj-1' }, @@ -209,7 +279,7 @@ describe('contentMapper.service', () => { const contentMapper = { id: 'ct-1', projectId: 'proj-1', otherCmsTitle: 'Blog' }; (ProjectModelLowdb.chain.get as ReturnType).mockReturnValue(createChain({ find: project })); - (ContentTypesMapperModelLowdb.chain.get as ReturnType).mockReturnValue( + (mockContentTypesDb.chain.get as ReturnType).mockReturnValue( createChain({ find: contentMapper }) ); @@ -229,7 +299,7 @@ describe('contentMapper.service', () => { const contentMapper = { id: 'ct-1', projectId: 'proj-1', otherCmsTitle: 'Blog' }; (ProjectModelLowdb.chain.get as ReturnType).mockReturnValue(createChain({ find: project })); - (ContentTypesMapperModelLowdb.chain.get as ReturnType).mockReturnValue( + (mockContentTypesDb.chain.get as ReturnType).mockReturnValue( createChain({ find: contentMapper }) ); @@ -246,7 +316,7 @@ describe('contentMapper.service', () => { describe('getFieldMapping', () => { it('should throw when content type not found', async () => { - (ContentTypesMapperModelLowdb.chain.get as ReturnType).mockReturnValue( + (mockContentTypesDb.chain.get as ReturnType).mockReturnValue( createChain({ find: null }) ); @@ -261,10 +331,10 @@ describe('contentMapper.service', () => { const contentType = { id: 'ct-1', projectId: 'proj-1', fieldMapping: ['f1'] }; const fieldData = { id: 'f1', otherCmsField: 'title', contentstackField: 'title' }; - (ContentTypesMapperModelLowdb.chain.get as ReturnType) + (mockContentTypesDb.chain.get as ReturnType) .mockReturnValue(createChain({ find: contentType })); - (FieldMapperModel.chain.get as ReturnType) + (mockFieldDb.chain.get as ReturnType) .mockReturnValue(createChain({ find: fieldData })); const req = { @@ -281,9 +351,9 @@ describe('contentMapper.service', () => { const contentType = { id: 'ct-1', projectId: 'proj-1', fieldMapping: ['f1'] }; const fieldData = { id: 'f1', otherCmsField: 'Title', contentstackField: 'title' }; - (ContentTypesMapperModelLowdb.chain.get as ReturnType) + (mockContentTypesDb.chain.get as ReturnType) .mockReturnValue(createChain({ find: contentType })); - (FieldMapperModel.chain.get as ReturnType) + (mockFieldDb.chain.get as ReturnType) .mockReturnValue(createChain({ find: fieldData })); const req = { @@ -461,9 +531,9 @@ describe('contentMapper.service', () => { it('should return 400 when field has invalid contentstackFieldType', async () => { mockGetProjectUtil.mockResolvedValue(0); ProjectModelLowdb.data.projects = [{ status: 1, current_step: 3 }]; - ContentTypesMapperModelLowdb.data.ContentTypesMappers = [{ id: 'ct-1', status: 1 }]; + mockContentTypesDb.data.ContentTypesMappers = [{ id: 'ct-1', status: 1 }]; - (ContentTypesMapperModelLowdb.chain.get as ReturnType) + (mockContentTypesDb.chain.get as ReturnType) .mockReturnValueOnce(createChain({ findIndex: 0 })) .mockReturnValue(createChain({ find: { id: 'ct-1', projectId: 'proj-1', status: 1 } })); @@ -486,12 +556,12 @@ describe('contentMapper.service', () => { it('should update content type successfully', async () => { mockGetProjectUtil.mockResolvedValue(0); ProjectModelLowdb.data.projects = [{ status: 1, current_step: 3 }]; - ContentTypesMapperModelLowdb.data.ContentTypesMappers = [{ id: 'ct-1', projectId: 'proj-1', status: 1 }]; - FieldMapperModel.data.field_mapper = [ + mockContentTypesDb.data.ContentTypesMappers = [{ id: 'ct-1', projectId: 'proj-1', status: 1 }]; + mockFieldDb.data.field_mapper = [ { id: 'f1', contentTypeId: 'ct-1', contentstackFieldType: 'text', contentstackFieldUid: 'f1' }, ]; - (ContentTypesMapperModelLowdb.chain.get as ReturnType) + (mockContentTypesDb.chain.get as ReturnType) .mockReturnValueOnce(createChain({ findIndex: 0 })) .mockReturnValue(createChain({ find: { id: 'ct-1', projectId: 'proj-1', status: 1 } })); @@ -550,13 +620,13 @@ describe('contentMapper.service', () => { advanced: { initial: {} }, }; - ContentTypesMapperModelLowdb.data.ContentTypesMappers = [contentTypeData]; - FieldMapperModel.data.field_mapper = [fieldData]; + mockContentTypesDb.data.ContentTypesMappers = [contentTypeData]; + mockFieldDb.data.field_mapper = [fieldData]; - (ContentTypesMapperModelLowdb.chain.get as ReturnType) + (mockContentTypesDb.chain.get as ReturnType) .mockReturnValueOnce(createChain({ find: contentTypeData })) .mockReturnValueOnce(createChain({ findIndex: 0 })); - (FieldMapperModel.chain.get as ReturnType).mockReturnValue(createChain({ find: fieldData })); + (mockFieldDb.chain.get as ReturnType).mockReturnValue(createChain({ find: fieldData })); const req = { params: { orgId: 'org-1', projectId: 'proj-1', contentTypeId: 'ct-1' }, @@ -591,14 +661,14 @@ describe('contentMapper.service', () => { }; const fieldData = { id: 'f1', projectId: 'proj-1', backupFieldType: 'text' }; - ContentTypesMapperModelLowdb.data.ContentTypesMappers = [{ ...contentType, contentstackTitle: 'Old' }]; - FieldMapperModel.data.field_mapper = [fieldData]; + mockContentTypesDb.data.ContentTypesMappers = [{ ...contentType, contentstackTitle: 'Old' }]; + mockFieldDb.data.field_mapper = [fieldData]; (ProjectModelLowdb.chain.get as ReturnType).mockReturnValue(createChain({ find: project })); - (ContentTypesMapperModelLowdb.chain.get as ReturnType) + (mockContentTypesDb.chain.get as ReturnType) .mockReturnValueOnce(createChain({ find: contentType })) .mockReturnValueOnce(createChain({ findIndex: 0 })); - (FieldMapperModel.chain.get as ReturnType) + (mockFieldDb.chain.get as ReturnType) .mockReturnValueOnce(createChain({ find: fieldData })) .mockReturnValueOnce(createChain({ findIndex: 0 })); @@ -626,10 +696,10 @@ describe('contentMapper.service', () => { (ProjectModelLowdb.chain.get as ReturnType) .mockReturnValueOnce(createChain({ find: project })) .mockReturnValueOnce(createChain({ findIndex: 0 })); - (ContentTypesMapperModelLowdb.chain.get as ReturnType) + (mockContentTypesDb.chain.get as ReturnType) .mockReturnValueOnce(createChain({ find: contentType })) .mockReturnValueOnce(createChain({ findIndex: 0 })); - (FieldMapperModel.chain.get as ReturnType).mockReturnValue( + (mockFieldDb.chain.get as ReturnType).mockReturnValue( createChain({ findIndex: 0 }) ); @@ -659,10 +729,10 @@ describe('contentMapper.service', () => { (ProjectModelLowdb.chain.get as ReturnType) .mockReturnValueOnce(createChain({ find: project })) .mockReturnValueOnce(createChain({ findIndex: 0 })); - (ContentTypesMapperModelLowdb.chain.get as ReturnType) + (mockContentTypesDb.chain.get as ReturnType) .mockReturnValueOnce(createChain({ find: contentType })) .mockReturnValueOnce(createChain({ findIndex: 0 })); - (FieldMapperModel.chain.get as ReturnType).mockReturnValue( + (mockFieldDb.chain.get as ReturnType).mockReturnValue( createChain({ findIndex: 0 }) ); diff --git a/api/tests/unit/services/projects.service.test.ts b/api/tests/unit/services/projects.service.test.ts index 364da1e68..12c980226 100644 --- a/api/tests/unit/services/projects.service.test.ts +++ b/api/tests/unit/services/projects.service.test.ts @@ -11,17 +11,51 @@ const { mockHttps, mockGetAuthToken, mockFindIndexValue, -} = vi.hoisted(() => ({ - mockProjectRead: vi.fn(), - mockProjectUpdate: vi.fn(), - mockProjectWrite: vi.fn(), - mockFindValue: vi.fn(), - mockFilterValue: vi.fn(), - mockGetProjectUtil: vi.fn(), - mockHttps: vi.fn(), - mockGetAuthToken: vi.fn(), - mockFindIndexValue: vi.fn(), -})); + mockContentTypesDb, + mockFieldDb, + getContentTypesMapperDbMock, + getFieldMapperDbMock, +} = vi.hoisted(() => { + const mockCtChainGet = vi.fn().mockReturnValue({ + filter: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue([]) }), + find: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue(null) }), + findIndex: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue(-1) }), + }); + const mockFieldChainGet = vi.fn().mockReturnValue({ + filter: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue([]) }), + find: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue(null) }), + findIndex: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue(-1) }), + }); + const mockContentTypesDb = { + read: vi.fn().mockResolvedValue(undefined), + update: vi.fn(), + write: vi.fn(), + chain: { get: mockCtChainGet }, + data: { ContentTypesMappers: [] as unknown[] }, + }; + const mockFieldDb = { + read: vi.fn().mockResolvedValue(undefined), + update: vi.fn(), + write: vi.fn(), + chain: { get: mockFieldChainGet }, + data: { field_mapper: [] as unknown[] }, + }; + return { + mockProjectRead: vi.fn(), + mockProjectUpdate: vi.fn(), + mockProjectWrite: vi.fn(), + mockFindValue: vi.fn(), + mockFilterValue: vi.fn(), + mockGetProjectUtil: vi.fn(), + mockHttps: vi.fn(), + mockGetAuthToken: vi.fn(), + mockFindIndexValue: vi.fn(), + mockContentTypesDb, + mockFieldDb, + getContentTypesMapperDbMock: vi.fn(() => mockContentTypesDb), + getFieldMapperDbMock: vi.fn(() => mockFieldDb), + }; +}); vi.mock('../../../src/models/project-lowdb.js', () => ({ default: { @@ -54,34 +88,11 @@ vi.mock('../../../src/config/index.js', () => ({ }, })); vi.mock('../../../src/models/contentTypesMapper-lowdb.js', () => ({ - default: { - read: vi.fn().mockResolvedValue(undefined), - update: vi.fn(), - write: vi.fn(), - chain: { - get: vi.fn().mockReturnValue({ - filter: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue([]) }), - find: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue(null) }), - findIndex: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue(-1) }), - }), - }, - data: { ContentTypesMappers: [] }, - }, + default: getContentTypesMapperDbMock, + getContentTypesMapperDb: getContentTypesMapperDbMock, })); vi.mock('../../../src/models/FieldMapper.js', () => ({ - default: { - read: vi.fn().mockResolvedValue(undefined), - update: vi.fn(), - write: vi.fn(), - chain: { - get: vi.fn().mockReturnValue({ - filter: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue([]) }), - find: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue(null) }), - findIndex: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue(-1) }), - }), - }, - data: { field_mapper: [] }, - }, + default: getFieldMapperDbMock, })); vi.mock('../../../src/services/contentMapper.service.js', () => ({ contentMapperService: { @@ -577,8 +588,7 @@ describe('projects.service', () => { const mockModel = await import('../../../src/models/project-lowdb.js'); (mockModel.default as any).data = { projects: [project] }; - const ctMock = await import('../../../src/models/contentTypesMapper-lowdb.js'); - (ctMock.default as any).chain.get.mockReturnValue({ + (mockContentTypesDb.chain.get as ReturnType).mockReturnValue({ find: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue({ id: 'ct-1', fieldMapping: [] }) }), findIndex: vi.fn().mockReturnValue({ value: vi.fn().mockReturnValue(0) }), }); diff --git a/api/tests/unit/utils/asset-update.utils.test.ts b/api/tests/unit/utils/asset-update.utils.test.ts new file mode 100644 index 000000000..56de03d2d --- /dev/null +++ b/api/tests/unit/utils/asset-update.utils.test.ts @@ -0,0 +1,396 @@ +import path from 'node:path'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + mockProjectRead, + mockChainGet, + mockExistsSync, + mockReadFileSync, + mockWriteFileSync, + mockMkdirSync, + mockReaddirSync, + mockRmSync, + mockAppendFileSync, +} = vi.hoisted(() => ({ + mockProjectRead: vi.fn(), + mockChainGet: vi.fn(), + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), + mockWriteFileSync: vi.fn(), + mockMkdirSync: vi.fn(), + mockReaddirSync: vi.fn(), + mockRmSync: vi.fn(), + mockAppendFileSync: vi.fn(), +})); + +vi.mock('../../../src/models/project-lowdb.js', () => ({ + default: { + read: mockProjectRead, + chain: { get: mockChainGet }, + }, +})); + +vi.mock('node:fs', () => ({ + default: { + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + mkdirSync: mockMkdirSync, + readdirSync: mockReaddirSync, + rmSync: mockRmSync, + appendFileSync: mockAppendFileSync, + }, +})); + +const project = (opts: Partial<{ iteration: number; destination_stack_id: string | undefined }>) => { + const p = { + id: 'p1', + iteration: opts.iteration ?? 1, + destination_stack_id: 'stack1' as string | undefined, + }; + if ('destination_stack_id' in opts) { + p.destination_stack_id = opts.destination_stack_id; + } + return p; +}; + +describe('asset-update.utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockProjectRead.mockResolvedValue(undefined); + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue(project({})), + }), + }); + }); + + it('removeExistingAssets returns early when no stackId', async () => { + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue(project({ destination_stack_id: '' })), + }), + }); + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1'); + expect(mockExistsSync).not.toHaveBeenCalled(); + }); + + it('removeExistingAssets returns when assets index.json missing', async () => { + mockExistsSync.mockReturnValue(false); + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1'); + expect(mockReadFileSync).not.toHaveBeenCalled(); + }); + + it('removeExistingAssets returns when index file empty', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(' '); + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1'); + }); + + it('removeExistingAssets iteration 1 saves metadata only', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ a1: { filename: 'f', file_size: '1', url: 'u' } })); + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1', '/tmp/a.log'); + expect(mockMkdirSync).toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it('removeExistingAssets iteration 2+ skips dedup when no previous uid map', async () => { + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue(project({ iteration: 2 })), + }), + }); + mockExistsSync.mockImplementation((p: string) => { + if (String(p).endsWith('index.json')) return true; + return false; + }); + mockReadFileSync.mockReturnValue(JSON.stringify({ a1: { filename: 'f', file_size: '1' } })); + + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1'); + }); + + it('removeExistingAssets iteration 2+ runs dedup when prev maps exist', async () => { + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue(project({ iteration: 2 })), + }), + }); + let indexRead = false; + mockExistsSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json')) return true; + if (s.includes('uid-mapper.json')) return true; + if (s.includes(`${path.sep}entries${path.sep}`)) return true; + if (s.includes('files')) return true; + return false; + }); + mockReadFileSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json') && !indexRead) { + indexRead = true; + return JSON.stringify({ + asset1: { filename: 'f', file_size: '1' }, + }); + } + if (s.includes('uid-mapper.json')) { + return JSON.stringify({ assets: { asset1: 'cs-uid-1' } }); + } + if (s.includes('asset-metadata.json')) { + return JSON.stringify({ + asset1: { filename: 'f', file_size: '1', url: '' }, + }); + } + if (s.endsWith('.json') && s.includes('entries')) { + return JSON.stringify({ ref: { uid: 'asset1' } }); + } + return '{}'; + }); + const dirent = (name: string, dir: boolean) => ({ name, isDirectory: () => dir }); + mockReaddirSync + .mockReturnValueOnce([dirent('ct', true)]) + .mockReturnValueOnce([dirent('en', true)]) + .mockReturnValueOnce(['e.json']); + + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1'); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it('removeExistingAssets returns when index JSON parse fails', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('{ not json'); + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1'); + }); + + it('removeExistingAssets iteration 2+ exits when no unchanged assets to dedupe', async () => { + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue(project({ iteration: 2 })), + }), + }); + let call = 0; + mockExistsSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json')) return true; + if (s.includes('uid-mapper.json')) return true; + if (s.includes('asset-metadata.json')) return true; + return false; + }); + mockReadFileSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json') && call === 0) { + call += 1; + return JSON.stringify({ a1: { filename: 'new-name', file_size: '1' } }); + } + if (s.includes('uid-mapper.json')) { + return JSON.stringify({ assets: { a1: 'cs-1' } }); + } + if (s.includes('asset-metadata.json')) { + return JSON.stringify({ + a1: { filename: 'old-name', file_size: '1', url: '' }, + }); + } + return '{}'; + }); + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1'); + }); + + it('removeExistingAssets tolerates corrupt previous asset-metadata JSON', async () => { + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue(project({ iteration: 2 })), + }), + }); + mockExistsSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json')) return true; + if (s.includes('uid-mapper.json')) return true; + if (s.includes('asset-metadata.json')) return true; + return false; + }); + mockReadFileSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json')) { + return JSON.stringify({ x1: { filename: 'f', file_size: '1' } }); + } + if (s.includes('uid-mapper.json')) { + return JSON.stringify({ assets: { x1: 'cs-1' } }); + } + if (s.includes('asset-metadata.json')) { + return '{ bad json'; + } + return '{}'; + }); + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1'); + }); + + it('removeExistingAssets tolerates corrupt uid-mapper JSON', async () => { + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue(project({ iteration: 2 })), + }), + }); + mockExistsSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json')) return true; + if (s.includes('uid-mapper.json')) return true; + return false; + }); + mockReadFileSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json')) { + return JSON.stringify({ a1: { filename: 'f', file_size: '1' } }); + } + if (s.includes('uid-mapper.json')) { + return 'not-json'; + } + return '{}'; + }); + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1'); + }); + + it('removeExistingAssets removes folders and updates index when dedup applies', async () => { + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue(project({ iteration: 2 })), + }), + }); + let indexPass = 0; + mockExistsSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json')) return true; + if (s.includes('uid-mapper.json')) return true; + if (s.includes('asset-metadata.json')) return true; + if (s.includes(`${path.sep}entries${path.sep}`)) return true; + if (s.endsWith(`${path.sep}files`) || s.includes(`${path.sep}files${path.sep}`)) return true; + if (s.includes(`${path.sep}files${path.sep}a1`)) return true; + return false; + }); + mockReadFileSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json')) { + indexPass += 1; + return JSON.stringify({ a1: { filename: 'f', file_size: '1' } }); + } + if (s.includes('uid-mapper.json')) { + return JSON.stringify({ assets: { a1: 'cs-uid-1' } }); + } + if (s.includes('asset-metadata.json')) { + return JSON.stringify({ + a1: { filename: 'f', file_size: '1', url: '' }, + }); + } + if (s.endsWith('.json') && s.includes('entries')) { + return JSON.stringify({ nested: { uid: 'a1' } }); + } + return '{}'; + }); + const dirent = (name: string, dir: boolean) => ({ name, isDirectory: () => dir }); + mockReaddirSync + .mockReturnValueOnce([dirent('ct', true)]) + .mockReturnValueOnce([dirent('en', true)]) + .mockReturnValueOnce(['e.json']); + + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1'); + expect(mockRmSync).toHaveBeenCalled(); + }); + + it('removeExistingAssets skips entry JSON files that fail to parse', async () => { + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue(project({ iteration: 2 })), + }), + }); + mockExistsSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json')) return true; + if (s.includes('uid-mapper.json')) return true; + if (s.includes('asset-metadata.json')) return true; + if (s.includes(`${path.sep}entries${path.sep}`)) return true; + return false; + }); + mockReadFileSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json')) { + return JSON.stringify({ z1: { filename: 'f', file_size: '1' } }); + } + if (s.includes('uid-mapper.json')) { + return JSON.stringify({ assets: { z1: 'cs-z' } }); + } + if (s.includes('asset-metadata.json')) { + return JSON.stringify({ z1: { filename: 'f', file_size: '1', url: '' } }); + } + if (s.endsWith('bad.json')) { + return '{'; + } + return '{}'; + }); + const dirent = (name: string, dir: boolean) => ({ name, isDirectory: () => dir }); + mockReaddirSync + .mockReturnValueOnce([dirent('ct', true)]) + .mockReturnValueOnce([dirent('en', true)]) + .mockReturnValueOnce(['bad.json']); + + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1'); + }); + + it('removeExistingAssets replaces nested asset uids and skips empty entry JSON', async () => { + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue(project({ iteration: 2 })), + }), + }); + let entryReadCount = 0; + mockExistsSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json')) return true; + if (s.includes('uid-mapper.json')) return true; + if (s.includes('asset-metadata.json')) return true; + if (s.includes(`${path.sep}entries${path.sep}`)) return true; + if (s.includes(`${path.sep}files`)) return false; + return false; + }); + mockReadFileSync.mockImplementation((p: string) => { + const s = String(p); + if (s.endsWith('index.json')) { + return JSON.stringify({ a1: { filename: 'f', file_size: '1' } }); + } + if (s.includes('uid-mapper.json')) { + return JSON.stringify({ assets: { a1: 'cs-nested' } }); + } + if (s.includes('asset-metadata.json')) { + return JSON.stringify({ + a1: { filename: 'f', file_size: '1', url: '' }, + }); + } + if (s.endsWith('empty.json')) { + return ' \n'; + } + if (s.endsWith('nested.json')) { + return JSON.stringify({ level: { deeper: { uid: 'a1' } } }); + } + return '{}'; + }); + const dirent = (name: string, dir: boolean) => ({ name, isDirectory: () => dir }); + mockReaddirSync + .mockReturnValueOnce([dirent('ct', true)]) + .mockReturnValueOnce([dirent('en', true)]) + .mockReturnValueOnce(['empty.json', 'nested.json']); + + const { removeExistingAssets } = await import('../../../src/utils/asset-update.utils.js'); + await removeExistingAssets('p1'); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); +}); diff --git a/api/tests/unit/utils/auth.utils.test.ts b/api/tests/unit/utils/auth.utils.test.ts index 26a006ed9..659b24103 100644 --- a/api/tests/unit/utils/auth.utils.test.ts +++ b/api/tests/unit/utils/auth.utils.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockRead, mockChain } = vi.hoisted(() => { +const { mockRead, mockChain, mockExistsSync, mockReadFileSync, mockDecryptAppConfig } = vi.hoisted(() => { const mockChain = { get: vi.fn().mockReturnThis(), findIndex: vi.fn().mockReturnThis(), @@ -9,6 +9,24 @@ const { mockRead, mockChain } = vi.hoisted(() => { return { mockRead: vi.fn(), mockChain, + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), + mockDecryptAppConfig: vi.fn((c: Record) => c), + }; +}); + +vi.mock('fs', () => ({ + default: { + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + }, +})); + +vi.mock('../../../src/utils/crypto.utils.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + decryptAppConfig: (c: Record) => mockDecryptAppConfig(c), }; }); @@ -26,12 +44,21 @@ vi.mock('../../../src/utils/custom-errors.utils.js', async (importOriginal) => { }); import getAuthToken from '../../../src/utils/auth.utils.js'; +import { + getAccessToken, + getAppOrganizationUID, + getAppOrganization, + getAppConfig, +} from '../../../src/utils/auth.utils.js'; import AuthenticationModel from '../../../src/models/authentication.js'; describe('auth.utils', () => { beforeEach(() => { vi.clearAllMocks(); mockRead.mockResolvedValue(undefined); + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('{}'); + mockDecryptAppConfig.mockImplementation((c: Record) => c); }); it('should return authtoken for a valid user', async () => { @@ -60,4 +87,62 @@ describe('auth.utils', () => { await expect(getAuthToken('NA', 'user-123')).rejects.toThrow(); }); + + describe('getAccessToken', () => { + it('returns access_token for valid user', async () => { + mockChain.value.mockReturnValue(0); + (AuthenticationModel as any).data = { + users: [{ region: 'EU', user_id: 'u2', access_token: 'at-1' }], + }; + await expect(getAccessToken('EU', 'u2')).resolves.toBe('at-1'); + }); + + it('throws when user missing or no access_token', async () => { + mockChain.value.mockReturnValue(-1); + (AuthenticationModel as any).data = { users: [] }; + await expect(getAccessToken('NA', 'x')).rejects.toThrow(); + }); + }); + + describe('loadAppConfig consumers', () => { + it('getAppOrganizationUID returns uid', () => { + mockReadFileSync.mockReturnValue( + JSON.stringify({ organization: { uid: 'org-uid', name: 'N' } }) + ); + expect(getAppOrganizationUID()).toBe('org-uid'); + }); + + it('getAppOrganizationUID throws when uid missing', () => { + mockReadFileSync.mockReturnValue(JSON.stringify({ organization: { name: 'N' } })); + expect(() => getAppOrganizationUID()).toThrow('Organization UID not found'); + }); + + it('getAppOrganization returns uid and name', () => { + mockReadFileSync.mockReturnValue( + JSON.stringify({ organization: { uid: 'o1', name: 'Org' } }) + ); + expect(getAppOrganization()).toEqual({ uid: 'o1', name: 'Org' }); + }); + + it('getAppOrganization throws when org incomplete', () => { + mockReadFileSync.mockReturnValue(JSON.stringify({ organization: { uid: 'o1' } })); + expect(() => getAppOrganization()).toThrow('Organization details not found'); + }); + + it('getAppConfig returns config when oauthData present', () => { + const cfg = { oauthData: { client_id: 'c' } }; + mockReadFileSync.mockReturnValue(JSON.stringify(cfg)); + expect(getAppConfig()).toMatchObject(cfg); + }); + + it('getAppConfig throws when oauthData missing', () => { + mockReadFileSync.mockReturnValue(JSON.stringify({})); + expect(() => getAppConfig()).toThrow('SSO is not configured'); + }); + + it('loadAppConfig throws when app.json missing', () => { + mockExistsSync.mockReturnValue(false); + expect(() => getAppOrganizationUID()).toThrow('app.json file not found'); + }); + }); }); diff --git a/api/tests/unit/utils/batch-processor.utils.test.ts b/api/tests/unit/utils/batch-processor.utils.test.ts index 65855d596..f2d28da8a 100644 --- a/api/tests/unit/utils/batch-processor.utils.test.ts +++ b/api/tests/unit/utils/batch-processor.utils.test.ts @@ -1,7 +1,11 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { BatchProcessor, processBatches } from '../../../src/utils/batch-processor.utils.js'; describe('batch-processor.utils', () => { + afterEach(() => { + delete (globalThis as unknown as { gc?: () => void }).gc; + }); + describe('BatchProcessor', () => { it('should process all items in correct batch sizes', async () => { const processor = new BatchProcessor({ @@ -84,6 +88,18 @@ describe('batch-processor.utils', () => { expect(elapsed).toBeGreaterThanOrEqual(80); }); + + it('calls global.gc when available after each batch', async () => { + const gc = vi.fn(); + (globalThis as unknown as { gc: () => void }).gc = gc; + const processor = new BatchProcessor({ + batchSize: 1, + concurrency: 1, + delayBetweenBatches: 0, + }); + await processor.processBatches([1, 2], async (item) => item); + expect(gc).toHaveBeenCalled(); + }); }); describe('processBatches utility function', () => { diff --git a/api/tests/unit/utils/config-handler.util.test.ts b/api/tests/unit/utils/config-handler.util.test.ts new file mode 100644 index 000000000..abf5562cc --- /dev/null +++ b/api/tests/unit/utils/config-handler.util.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockSet = vi.fn(); + +vi.mock('@contentstack/cli-utilities', () => ({ + configHandler: { + set: mockSet, + }, +})); + +describe('config-handler.util', () => { + beforeEach(() => { + mockSet.mockClear(); + }); + + it('setOAuthConfig writes expected keys', async () => { + const { setOAuthConfig } = await import('../../../src/utils/config-handler.util.js'); + const userData = { + access_token: 'at', + refresh_token: 'rt', + updated_at: '2024-01-01', + email: 'a@b.com', + user_id: 'u1', + organization_uid: 'org1', + }; + setOAuthConfig(userData); + expect(mockSet).toHaveBeenCalledWith('oauthAccessToken', 'at'); + expect(mockSet).toHaveBeenCalledWith('oauthRefreshToken', 'rt'); + expect(mockSet).toHaveBeenCalledWith('authorisationType', 'OAUTH'); + }); + + it('setOAuthConfig uses created_at when updated_at missing', async () => { + const { setOAuthConfig } = await import('../../../src/utils/config-handler.util.js'); + setOAuthConfig({ + access_token: 'a', + refresh_token: 'b', + created_at: '2023-01-01', + email: 'e', + user_id: 'u', + organization_uid: 'o', + }); + expect(mockSet).toHaveBeenCalledWith('oauthDateTime', '2023-01-01'); + }); + + it('setBasicAuthConfig writes authtoken and BASIC', async () => { + const { setBasicAuthConfig } = await import('../../../src/utils/config-handler.util.js'); + setBasicAuthConfig({ authtoken: 'tok', email: 'e@e.com' }); + expect(mockSet).toHaveBeenCalledWith('authtoken', 'tok'); + expect(mockSet).toHaveBeenCalledWith('authorisationType', 'BASIC'); + }); + + it('setOAuthConfig uses Date when neither updated_at nor created_at is set', async () => { + const { setOAuthConfig } = await import('../../../src/utils/config-handler.util.js'); + setOAuthConfig({ + access_token: 'a', + refresh_token: 'b', + email: 'e', + user_id: 'u', + organization_uid: 'o', + }); + expect(mockSet).toHaveBeenCalledWith('oauthDateTime', expect.any(Date)); + }); + + it('setOAuthConfig tolerates empty object', async () => { + const { setOAuthConfig } = await import('../../../src/utils/config-handler.util.js'); + setOAuthConfig({}); + expect(mockSet).toHaveBeenCalledWith('oauthAccessToken', undefined); + }); + + it('setBasicAuthConfig tolerates empty object', async () => { + const { setBasicAuthConfig } = await import('../../../src/utils/config-handler.util.js'); + setBasicAuthConfig({}); + expect(mockSet).toHaveBeenCalledWith('authtoken', undefined); + expect(mockSet).toHaveBeenCalledWith('email', undefined); + }); +}); diff --git a/api/tests/unit/utils/content-type-checker.utils.test.ts b/api/tests/unit/utils/content-type-checker.utils.test.ts new file mode 100644 index 000000000..d308a6f96 --- /dev/null +++ b/api/tests/unit/utils/content-type-checker.utils.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockRead = vi.fn(); +const mockExistsSync = vi.fn(); + +vi.mock('fs', () => ({ + default: { existsSync: mockExistsSync }, + existsSync: mockExistsSync, +})); + +vi.mock('../../../src/models/contentTypesMapper-lowdb.js', () => ({ + default: vi.fn(() => ({ + read: mockRead, + data: { ContentTypesMappers: [{ otherCmsUid: 'ct-uid' }] }, + })), +})); + +describe('content-type-checker.utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRead.mockResolvedValue(undefined); + mockExistsSync.mockReturnValue(true); + }); + + it('isContentTypeAlreadyCreated returns false when currentIteration <= 1', async () => { + const { isContentTypeAlreadyCreated } = await import( + '../../../src/utils/content-type-checker.utils.js' + ); + await expect(isContentTypeAlreadyCreated('p1', 'x', 1)).resolves.toBe(false); + expect(mockExistsSync).not.toHaveBeenCalled(); + }); + + it('isContentTypeAlreadyCreated returns true when prior iteration has matching uid', async () => { + const { isContentTypeAlreadyCreated } = await import( + '../../../src/utils/content-type-checker.utils.js' + ); + await expect(isContentTypeAlreadyCreated('p1', 'ct-uid', 3)).resolves.toBe(true); + expect(mockRead).toHaveBeenCalled(); + }); + + it('getPreviouslyCreatedContentTypes returns empty when iteration <= 1', async () => { + const { getPreviouslyCreatedContentTypes } = await import( + '../../../src/utils/content-type-checker.utils.js' + ); + await expect(getPreviouslyCreatedContentTypes('p1', 1)).resolves.toEqual([]); + }); + + it('shouldSkipContentTypeCreation delegates to isContentTypeAlreadyCreated', async () => { + const mod = await import('../../../src/utils/content-type-checker.utils.js'); + await expect(mod.shouldSkipContentTypeCreation('p1', 'ct-uid', 1)).resolves.toBe(false); + }); + + it('isContentTypeAlreadyCreated skips iterations with no directory', async () => { + mockExistsSync.mockReturnValue(false); + const { isContentTypeAlreadyCreated } = await import( + '../../../src/utils/content-type-checker.utils.js' + ); + await expect(isContentTypeAlreadyCreated('p1', 'ct-uid', 3)).resolves.toBe(false); + }); + + it('isContentTypeAlreadyCreated continues when read throws', async () => { + mockExistsSync.mockReturnValue(true); + mockRead.mockRejectedValueOnce(new Error('read fail')); + const { isContentTypeAlreadyCreated } = await import( + '../../../src/utils/content-type-checker.utils.js' + ); + await expect(isContentTypeAlreadyCreated('p1', 'missing', 3)).resolves.toBe(false); + }); + + it('getPreviouslyCreatedContentTypes collects uids from prior iterations', async () => { + const { getPreviouslyCreatedContentTypes } = await import( + '../../../src/utils/content-type-checker.utils.js' + ); + await expect(getPreviouslyCreatedContentTypes('p1', 3)).resolves.toContain('ct-uid'); + }); + + it('getPreviouslyCreatedContentTypes continues when iteration read fails', async () => { + mockExistsSync.mockReturnValue(true); + mockRead.mockRejectedValue(new Error('boom')); + const { getPreviouslyCreatedContentTypes } = await import( + '../../../src/utils/content-type-checker.utils.js' + ); + await expect(getPreviouslyCreatedContentTypes('p1', 2)).resolves.toEqual([]); + }); +}); diff --git a/api/tests/unit/utils/crypto.utils.test.ts b/api/tests/unit/utils/crypto.utils.test.ts new file mode 100644 index 000000000..da4ec8f86 --- /dev/null +++ b/api/tests/unit/utils/crypto.utils.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +describe('crypto.utils', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetModules(); + process.env.MANIFEST_ENCRYPT_KEY = 'test-key-32-chars-long-string!!'; + process.env.MANIFEST_ENCRYPT_SALT = 'testsalt'; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('decrypt returns plain values unchanged when not prefixed', async () => { + const { decrypt } = await import('../../../src/utils/crypto.utils.js'); + expect(decrypt('plain')).toBe('plain'); + expect(decrypt('')).toBe(''); + expect(decrypt(' no-enc-prefix')).toBe(' no-enc-prefix'); + }); + + it('decryptAppConfig returns config unchanged when oauthData/pkce absent', async () => { + const { decryptAppConfig } = await import('../../../src/utils/crypto.utils.js'); + const cfg = { foo: 'bar' }; + expect(decryptAppConfig(cfg)).toBe(cfg); + }); + + it('getEncryptKey throws when MANIFEST_ENCRYPT_KEY missing', async () => { + vi.resetModules(); + delete process.env.MANIFEST_ENCRYPT_KEY; + process.env.MANIFEST_ENCRYPT_SALT = 'testsalt'; + const { decrypt } = await import('../../../src/utils/crypto.utils.js'); + expect(() => decrypt('enc:00112233445566778899aabb:00112233445566778899aabb:445566')).toThrow( + 'MANIFEST_ENCRYPT_KEY' + ); + }); + + it('decrypt throws on invalid enc: format (wrong segment count)', async () => { + const { decrypt } = await import('../../../src/utils/crypto.utils.js'); + expect(() => decrypt('enc:only:two')).toThrow('Invalid encrypted value format'); + }); + + it('getEncryptSalt throws when MANIFEST_ENCRYPT_SALT missing', async () => { + vi.resetModules(); + process.env.MANIFEST_ENCRYPT_KEY = 'test-key-32-chars-long-string!!'; + delete process.env.MANIFEST_ENCRYPT_SALT; + const { decrypt } = await import('../../../src/utils/crypto.utils.js'); + expect(() => decrypt('enc:aa:bb:ccdd')).toThrow('MANIFEST_ENCRYPT_SALT'); + }); + + it('decryptAppConfig runs oauthData and pkce decrypt branches for plain strings', async () => { + const { decryptAppConfig } = await import('../../../src/utils/crypto.utils.js'); + const cfg = { + oauthData: { + client_id: 'id-plain', + client_secret: 'sec-plain', + }, + pkce: { + code_verifier: 'ver-plain', + code_challenge: 'chal-plain', + }, + }; + const out = decryptAppConfig({ ...cfg }); + expect(out.oauthData?.client_id).toBe('id-plain'); + expect(out.pkce?.code_verifier).toBe('ver-plain'); + }); + + it('decryptAppConfig decrypts client_id only when client_secret absent', async () => { + const { decryptAppConfig } = await import('../../../src/utils/crypto.utils.js'); + const out = decryptAppConfig({ + oauthData: { client_id: 'only-id' }, + } as Record); + expect((out as any).oauthData.client_secret).toBeUndefined(); + expect((out as any).oauthData.client_id).toBe('only-id'); + }); + + it('decryptAppConfig handles pkce with only code_challenge', async () => { + const { decryptAppConfig } = await import('../../../src/utils/crypto.utils.js'); + const out = decryptAppConfig({ + pkce: { code_challenge: 'chal-only' }, + } as Record); + expect((out as any).pkce.code_challenge).toBe('chal-only'); + }); + + it('decryptAppConfig handles pkce with only code_verifier', async () => { + const { decryptAppConfig } = await import('../../../src/utils/crypto.utils.js'); + const out = decryptAppConfig({ + pkce: { code_verifier: 'ver-only' }, + } as Record); + expect((out as any).pkce.code_verifier).toBe('ver-only'); + }); + + it('decryptAppConfig skips inner oauth fields when oauthData is empty object', async () => { + const { decryptAppConfig } = await import('../../../src/utils/crypto.utils.js'); + const out = decryptAppConfig({ oauthData: {} } as Record); + expect(out.oauthData).toEqual({}); + }); +}); diff --git a/api/tests/unit/utils/custom-errors.utils.test.ts b/api/tests/unit/utils/custom-errors.utils.test.ts index 2b4c45a36..cc1d9cff2 100644 --- a/api/tests/unit/utils/custom-errors.utils.test.ts +++ b/api/tests/unit/utils/custom-errors.utils.test.ts @@ -58,6 +58,11 @@ describe('Custom Error Classes', () => { expect(error.message).toBe('DB error'); expect(error).toBeInstanceOf(AppError); }); + + it('should accept a custom message', () => { + const error = new DatabaseError('custom-db'); + expect(error.message).toBe('custom-db'); + }); }); describe('ValidationError', () => { @@ -67,6 +72,11 @@ describe('Custom Error Classes', () => { expect(error.message).toBe('User validation error'); expect(error).toBeInstanceOf(AppError); }); + + it('should accept a custom message', () => { + const error = new ValidationError('bad field'); + expect(error.message).toBe('bad field'); + }); }); describe('InternalServerError', () => { @@ -76,6 +86,11 @@ describe('Custom Error Classes', () => { expect(error.message).toBeTruthy(); expect(error).toBeInstanceOf(AppError); }); + + it('should accept a custom message', () => { + const error = new InternalServerError('custom-internal'); + expect(error.message).toBe('custom-internal'); + }); }); describe('UnauthorizedError', () => { @@ -97,6 +112,11 @@ describe('Custom Error Classes', () => { expect(error.statusCode).toBe(500); expect(error).toBeInstanceOf(AppError); }); + + it('should accept a custom message', () => { + const error = new S3Error('bucket failed'); + expect(error.message).toBe('bucket failed'); + }); }); describe('ExceptionFunction', () => { diff --git a/api/tests/unit/utils/entry-duplicate.utils.test.ts b/api/tests/unit/utils/entry-duplicate.utils.test.ts new file mode 100644 index 000000000..428db859e --- /dev/null +++ b/api/tests/unit/utils/entry-duplicate.utils.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockProjectRead = vi.fn(); +const mockChainGet = vi.fn(); +const mockEntryRead = vi.fn(); +const mockEntryUpdate = vi.fn(); + +vi.mock('../../../src/models/project-lowdb.js', () => ({ + default: { + read: mockProjectRead, + chain: { get: mockChainGet }, + }, +})); + +vi.mock('../../../src/models/EntryMapper.js', () => ({ + default: vi.fn(() => ({ + read: mockEntryRead, + update: mockEntryUpdate, + })), +})); + +describe('entry-duplicate.utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockProjectRead.mockResolvedValue(undefined); + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue({ id: 'p1', iteration: 2 }), + }), + }); + mockEntryRead.mockResolvedValue(undefined); + }); + + it('marks duplicate entries sharing contentTypeId, language, and entryName', async () => { + const rows = [ + { contentTypeId: 'ct', language: 'en', entryName: 'e1', isDuplicateEntry: false }, + { contentTypeId: 'ct', language: 'en', entryName: 'e1', isDuplicateEntry: false }, + ]; + mockEntryUpdate.mockImplementation(async (fn: (d: { entry_mapper: typeof rows }) => void) => { + fn({ entry_mapper: rows }); + }); + + const { isDuplicateEntry } = await import('../../../src/utils/entry-duplicate.utils.js'); + await isDuplicateEntry('p1'); + + expect(rows[0].isDuplicateEntry).toBe(true); + expect(rows[1].isDuplicateEntry).toBe(true); + }); + + it('leaves unique entries unchanged', async () => { + const rows = [ + { contentTypeId: 'ct', language: 'en', entryName: 'a', isDuplicateEntry: false }, + { contentTypeId: 'ct', language: 'en', entryName: 'b', isDuplicateEntry: false }, + ]; + mockEntryUpdate.mockImplementation(async (fn: (d: { entry_mapper: typeof rows }) => void) => { + fn({ entry_mapper: rows }); + }); + + const { isDuplicateEntry } = await import('../../../src/utils/entry-duplicate.utils.js'); + await isDuplicateEntry('p1'); + + expect(rows.every((r) => !r.isDuplicateEntry)).toBe(true); + }); +}); diff --git a/api/tests/unit/utils/entry-update.utils.test.ts b/api/tests/unit/utils/entry-update.utils.test.ts new file mode 100644 index 000000000..dc9e39ed9 --- /dev/null +++ b/api/tests/unit/utils/entry-update.utils.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + mockProjectRead, + mockChainGet, + mockEntryRead, + mockEntryChainGet, + mockExistsSync, + mockReadFileSync, + mockWriteFileSync, + mockMkdirSync, + mockReaddirSync, + mockAppendFileSync, +} = vi.hoisted(() => ({ + mockProjectRead: vi.fn(), + mockChainGet: vi.fn(), + mockEntryRead: vi.fn(), + mockEntryChainGet: vi.fn(), + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), + mockWriteFileSync: vi.fn(), + mockMkdirSync: vi.fn(), + mockReaddirSync: vi.fn(), + mockAppendFileSync: vi.fn(), +})); + +vi.mock('../../../src/models/project-lowdb.js', () => ({ + default: { + read: mockProjectRead, + chain: { get: mockChainGet }, + }, +})); + +vi.mock('../../../src/models/EntryMapper.js', () => ({ + default: vi.fn(() => ({ + read: mockEntryRead, + chain: { get: mockEntryChainGet }, + })), +})); + +vi.mock('node:fs', () => ({ + default: { + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + mkdirSync: mockMkdirSync, + appendFileSync: mockAppendFileSync, + readdirSync: mockReaddirSync, + }, +})); + +describe('entry-update.utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockProjectRead.mockResolvedValue(undefined); + mockEntryRead.mockResolvedValue(undefined); + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue({ + id: 'p1', + iteration: 1, + destination_stack_id: 'stack1', + }), + }), + }); + mockEntryChainGet.mockReturnValue({ + value: () => [ + { otherCmsEntryUid: 'legacy-key', isUpdate: true, contentstackEntryUid: 'cs-uid' }, + ], + }); + }); + + it('removeEntriesFromDatabase returns null when stackId missing', async () => { + mockChainGet.mockReturnValue({ + find: vi.fn().mockReturnValue({ + value: vi.fn().mockReturnValue({ id: 'p1', iteration: 1 }), + }), + }); + const { removeEntriesFromDatabase } = await import('../../../src/utils/entry-update.utils.js'); + await expect(removeEntriesFromDatabase('p1')).resolves.toBeNull(); + }); + + it('removeEntriesFromDatabase returns null when no entry_mapper items', async () => { + mockEntryChainGet.mockReturnValue({ value: () => [] }); + const { removeEntriesFromDatabase } = await import('../../../src/utils/entry-update.utils.js'); + await expect(removeEntriesFromDatabase('p1')).resolves.toBeNull(); + }); + + it('removeEntriesFromDatabase returns null when entries directory missing', async () => { + mockExistsSync.mockReturnValue(false); + const { removeEntriesFromDatabase } = await import('../../../src/utils/entry-update.utils.js'); + await expect(removeEntriesFromDatabase('p1')).resolves.toBeNull(); + }); + + it('removeEntriesFromDatabase walks dirs, updates json, writes config', async () => { + mockExistsSync.mockReturnValue(true); + const dirent = (name: string, isDir: boolean) => ({ + name, + isDirectory: () => isDir, + }); + mockReaddirSync + .mockReturnValueOnce([dirent('ct1', true)]) + .mockReturnValueOnce([dirent('en', true)]) + .mockReturnValueOnce(['page.json']); + mockReadFileSync.mockReturnValue( + JSON.stringify({ 'legacy-key': { title: 'Hello' }, keep: { x: 1 } }) + ); + + const { removeEntriesFromDatabase } = await import('../../../src/utils/entry-update.utils.js'); + const result = await removeEntriesFromDatabase('p1', '/tmp/mig.log'); + + expect(result).toMatch(/updated-entries\.json$/); + expect(mockWriteFileSync).toHaveBeenCalled(); + expect(mockMkdirSync).toHaveBeenCalled(); + }); + + it('enrichConfigWithAssetMapping covers iteration 1 (no old path branch)', async () => { + mockExistsSync.mockReturnValue(false); + const { enrichConfigWithAssetMapping } = await import('../../../src/utils/entry-update.utils.js'); + enrichConfigWithAssetMapping('/c.json', 'proj', 1); + }); + + it('enrichConfigWithAssetMapping loads old and new uid-mappers when files exist', async () => { + mockExistsSync.mockImplementation((p: string) => + String(p).includes('uid-mapper.json') + ); + mockReadFileSync.mockReturnValue(JSON.stringify({ assets: { x: 'y' } })); + + const { enrichConfigWithAssetMapping } = await import('../../../src/utils/entry-update.utils.js'); + enrichConfigWithAssetMapping('/c.json', 'proj', 2, '/log'); + }); + + it('enrichConfigWithAssetMapping catches JSON errors on old mapper', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + let calls = 0; + mockExistsSync.mockImplementation(() => true); + mockReadFileSync.mockImplementation(() => { + calls += 1; + if (calls === 1) throw new Error('bad json'); + return JSON.stringify({ assets: {} }); + }); + + const { enrichConfigWithAssetMapping } = await import('../../../src/utils/entry-update.utils.js'); + enrichConfigWithAssetMapping('/c.json', 'proj', 2); + log.mockRestore(); + }); +}); diff --git a/api/tests/unit/utils/index.test.ts b/api/tests/unit/utils/index.test.ts index eefac615f..70caaf735 100644 --- a/api/tests/unit/utils/index.test.ts +++ b/api/tests/unit/utils/index.test.ts @@ -51,6 +51,14 @@ describe('utils/index', () => { it('should return false for boolean', () => { expect(isEmpty(false)).toBe(false); }); + + it('should return true for empty array', () => { + expect(isEmpty([])).toBe(true); + }); + + it('should return false for symbol', () => { + expect(isEmpty(Symbol('x'))).toBe(false); + }); }); describe('safePromise', () => { @@ -92,5 +100,15 @@ describe('utils/index', () => { const log = getLogMessage('testMethod', 'test message'); expect(log).not.toHaveProperty('error'); }); + + it('omits user spread when user is null', () => { + const log = getLogMessage('testMethod', 'test message', null as unknown as Record); + expect(log).not.toHaveProperty('user'); + }); + + it('omits error spread when error is falsy', () => { + const log = getLogMessage('testMethod', 'test message', {}, 0 as unknown as undefined); + expect(log).not.toHaveProperty('error'); + }); }); }); diff --git a/api/tests/unit/utils/pagination.utils.test.ts b/api/tests/unit/utils/pagination.utils.test.ts index 27ba63429..c729b088d 100644 --- a/api/tests/unit/utils/pagination.utils.test.ts +++ b/api/tests/unit/utils/pagination.utils.test.ts @@ -1,11 +1,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockHttps } = vi.hoisted(() => ({ mockHttps: vi.fn() })); +const { mockHttps, mockSsoRequest } = vi.hoisted(() => ({ + mockHttps: vi.fn(), + mockSsoRequest: vi.fn(), +})); vi.mock('../../../src/utils/https.utils.js', () => ({ default: mockHttps, })); +vi.mock('../../../src/utils/sso-request.utils.js', () => ({ + requestWithSsoTokenRefresh: (...args: unknown[]) => mockSsoRequest(...args), +})); + vi.mock('../../../src/utils/index.js', async (importOriginal) => { const actual = await importOriginal(); return { @@ -20,6 +27,7 @@ import fetchAllPaginatedData from '../../../src/utils/pagination.utils.js'; describe('pagination.utils', () => { beforeEach(() => { vi.clearAllMocks(); + mockSsoRequest.mockResolvedValue([null, { data: { items: [] } }]); }); it('should fetch a single page of data', async () => { @@ -80,4 +88,21 @@ describe('pagination.utils', () => { fetchAllPaginatedData('https://api.example.com/data', {}, 100, 'testFunc', 'items') ).rejects.toThrow('is not iterable'); }); + + it('uses requestWithSsoTokenRefresh when is_sso token payload is passed', async () => { + mockSsoRequest.mockResolvedValue([null, { data: { items: [{ id: 'a' }] } }]); + + const result = await fetchAllPaginatedData( + 'https://api.example.com/data', + {}, + 100, + 'ssoFunc', + 'items', + { region: 'NA', user_id: 'u1', is_sso: true } + ); + + expect(mockSsoRequest).toHaveBeenCalled(); + expect(mockHttps).not.toHaveBeenCalled(); + expect(result).toEqual([{ id: 'a' }]); + }); }); diff --git a/api/tests/unit/utils/sso-request.utils.test.ts b/api/tests/unit/utils/sso-request.utils.test.ts new file mode 100644 index 000000000..ce75e4662 --- /dev/null +++ b/api/tests/unit/utils/sso-request.utils.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockHttps, mockRefresh } = vi.hoisted(() => ({ + mockHttps: vi.fn(), + mockRefresh: vi.fn(), +})); + +vi.mock('../../../src/utils/https.utils.js', () => ({ default: mockHttps })); +vi.mock('../../../src/services/auth.service.js', () => ({ + refreshOAuthToken: mockRefresh, +})); +vi.mock('../../../src/utils/logger.js', () => ({ + default: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, +})); + +describe('sso-request.utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns first [err,res] when request succeeds', async () => { + const { requestWithSsoTokenRefresh } = await import('../../../src/utils/sso-request.utils.js'); + mockHttps.mockResolvedValueOnce({ status: 200, data: {} }); + const out = await requestWithSsoTokenRefresh( + { region: 'NA', user_id: 'u1', is_sso: true }, + { url: 'https://x', method: 'GET' } + ); + expect(out[0]).toBeNull(); + expect(out[1]?.status).toBe(200); + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + it('returns [err,res] when not SSO without refresh', async () => { + const { requestWithSsoTokenRefresh } = await import('../../../src/utils/sso-request.utils.js'); + mockHttps.mockRejectedValueOnce({ response: { status: 401, data: { error_code: 105 } } }); + const out = await requestWithSsoTokenRefresh( + { region: 'NA', user_id: 'u1', is_sso: false }, + { url: 'https://x', method: 'GET' } + ); + expect(out[0]).toBeDefined(); + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + it('refreshes token and retries on 401 for SSO', async () => { + const { requestWithSsoTokenRefresh } = await import('../../../src/utils/sso-request.utils.js'); + const err401 = { response: { status: 401, data: { error_code: 105 } } }; + mockHttps.mockRejectedValueOnce(err401).mockResolvedValueOnce({ status: 200, data: { ok: true } }); + mockRefresh.mockResolvedValue('new-access'); + + const out = await requestWithSsoTokenRefresh( + { region: 'NA', user_id: 'u1', is_sso: true }, + { url: 'https://x', method: 'GET', headers: {} } + ); + + expect(mockRefresh).toHaveBeenCalledWith('u1'); + expect(mockHttps).toHaveBeenCalledTimes(2); + expect(out[1]?.data?.ok).toBe(true); + }); + + it('returns original error when refresh throws', async () => { + const { requestWithSsoTokenRefresh } = await import('../../../src/utils/sso-request.utils.js'); + const err401 = { response: { status: 401, data: { code: 105 } } }; + mockHttps.mockRejectedValueOnce(err401); + mockRefresh.mockRejectedValue(new Error('refresh failed')); + + const out = await requestWithSsoTokenRefresh( + { region: 'NA', user_id: 'u1', is_sso: true }, + { url: 'https://x', method: 'GET' } + ); + expect(out[0]).toBe(err401); + }); + + it('refreshes on 401 even when error body has no code', async () => { + const { requestWithSsoTokenRefresh } = await import('../../../src/utils/sso-request.utils.js'); + mockHttps + .mockRejectedValueOnce({ response: { status: 401, data: {} } }) + .mockResolvedValueOnce({ status: 200, data: { ok: 1 } }); + mockRefresh.mockResolvedValue('tok2'); + + const out = await requestWithSsoTokenRefresh( + { region: 'NA', user_id: 'u1', is_sso: true }, + { url: 'https://x', method: 'GET' } + ); + expect(mockRefresh).toHaveBeenCalled(); + expect(out[1]?.data?.ok).toBe(1); + }); + + it('does not refresh when error is not a token error', async () => { + const { requestWithSsoTokenRefresh } = await import('../../../src/utils/sso-request.utils.js'); + const err403 = { response: { status: 403, data: {} } }; + mockHttps.mockRejectedValueOnce(err403); + const out = await requestWithSsoTokenRefresh( + { region: 'NA', user_id: 'u1', is_sso: true }, + { url: 'https://x', method: 'GET' } + ); + expect(out[0]).toBe(err403); + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + it('refreshes when error_code is 105 even if HTTP status is not 401', async () => { + const { requestWithSsoTokenRefresh } = await import('../../../src/utils/sso-request.utils.js'); + mockHttps + .mockRejectedValueOnce({ response: { status: 500, data: { error_code: 105 } } }) + .mockResolvedValueOnce({ status: 200, data: { ok: 2 } }); + mockRefresh.mockResolvedValue('tok3'); + + const out = await requestWithSsoTokenRefresh( + { region: 'NA', user_id: 'u1', is_sso: true }, + { url: 'https://x', method: 'GET' } + ); + expect(mockRefresh).toHaveBeenCalled(); + expect(out[1]?.data?.ok).toBe(2); + }); +}); diff --git a/ui/src/components/ContentMapper/entryMapper.tsx b/ui/src/components/ContentMapper/entryMapper.tsx index 8d913de12..29fa5f0bc 100644 --- a/ui/src/components/ContentMapper/entryMapper.tsx +++ b/ui/src/components/ContentMapper/entryMapper.tsx @@ -126,15 +126,11 @@ const EntryMapper = ({selectedContentTypeId, tableHeight}: {selectedContentTypeI try { const { data } = await getContentTypes(projectId || '', 0, 5000, ''); //org id will always present - //setIsLoading(false); + setContentTypes(data?.contentTypes); - //setCount(data?.contentTypes?.length); - //setFilteredContentTypes(data?.contentTypes); setSelectedContentType(data?.contentTypes?.[0]); - //setTotalCounts(data?.contentTypes?.[0]?.fieldMapping?.length); setOtherCmsTitle(data?.contentTypes?.[0]?.otherCmsTitle); setContentTypeUid(data?.contentTypes?.[0]?.id); - // fetchFields(data?.contentTypes?.[0]?.id, searchText || ''); fetchEntries(data?.contentTypes?.[0]?.id, searchText ?? ''); setOtherCmsUid(data?.contentTypes?.[0]?.otherCmsUid); setIsContentType(data?.contentTypes?.[0]?.type === "content_type"); @@ -387,20 +383,17 @@ const EntryMapper = ({selectedContentTypeId, tableHeight}: {selectedContentTypeI loading={loading} canSearch={true} totalCounts={Math.max(0, tableData?.length)} - // data={tableData?.length > 0 ? [...tableData] : []} data={[...tableData]} columns={columns} uniqueKey={'id'} isRowSelect={true} fullRowSelect={true} itemStatusMap={itemStatusMap} - //searchPlaceholder={tableSearchPlaceholder} fetchTableData={fetchData} loadMoreItems={loadMoreItems} tableHeight={tableHeight} equalWidthColumns={true} columnSelector={false} - // initialRowSelectedData={initialRowSelectedData} initialSelectedRowIds={rowIds} itemSize={80} getSelectedRow={handleSelectedEntries} diff --git a/ui/src/components/LegacyCms/Actions/LoadUploadFile.tsx b/ui/src/components/LegacyCms/Actions/LoadUploadFile.tsx index 040456e15..dc89239b3 100644 --- a/ui/src/components/LegacyCms/Actions/LoadUploadFile.tsx +++ b/ui/src/components/LegacyCms/Actions/LoadUploadFile.tsx @@ -57,7 +57,7 @@ const FileComponent = ( { fileDetails, fileFormatId }: Props ) => const [localPath, setLocalPath] = useState(fileDetails?.localPath || ''); const dispatch = useDispatch(); const currentPath = newMigrationData?.legacy_cms?.uploadedFile?.file_details?.localPath || fileDetails?.localPath || ''; - + const iteration = newMigrationData?.iteration || 1; const handleEditFile = async () => { setIsEditing(true); @@ -83,11 +83,7 @@ const FileComponent = ( { fileDetails, fileFormatId }: Props ) => } } }; - dispatch(updateNewMigrationData(updatedMigrationData)); - const fileFormatData = { - "file_path": localPath, - } }; @@ -121,7 +117,7 @@ const FileComponent = ( { fileDetails, fileFormatId }: Props ) => )}
- {( + { iteration > 1 && (
diff --git a/ui/src/components/MigrationFlowHeader/index.tsx b/ui/src/components/MigrationFlowHeader/index.tsx index eec19e9f3..b091ff1ef 100644 --- a/ui/src/components/MigrationFlowHeader/index.tsx +++ b/ui/src/components/MigrationFlowHeader/index.tsx @@ -143,8 +143,6 @@ const MigrationFlowHeader = ({ isFileValidated : isStep4AndNotMigrated || isStepInvalid - // isExecutionStarted || - // destinationStackMigrated } > {newMigrationData?.stepValue || 'Save and Continue'} diff --git a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx index 1e20359e6..809c659e5 100644 --- a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx +++ b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx @@ -127,7 +127,7 @@ const HorizontalStepper = forwardRef( !newMigrationData?.migration_execution?.migrationCompleted && !newMigrationData?.migration_execution?.migrationStarted; - if (isRestarted && stepsCompleted.length > 0) { + if (isRestarted && stepsCompleted?.length > 0) { setStepsCompleted([]); setShowStep(0); } @@ -137,7 +137,7 @@ const HorizontalStepper = forwardRef( newMigrationData?.legacy_cms?.currentStep, newMigrationData?.migration_execution?.migrationCompleted, newMigrationData?.migration_execution?.migrationStarted, - stepsCompleted.length + stepsCompleted?.length ]); useEffect(() => { diff --git a/upload-api/migration-contentful/libs/createInitialMapper.js b/upload-api/migration-contentful/libs/createInitialMapper.js index dd8262173..37f3f9555 100644 --- a/upload-api/migration-contentful/libs/createInitialMapper.js +++ b/upload-api/migration-contentful/libs/createInitialMapper.js @@ -75,7 +75,7 @@ const createInitialMapper = async (cleanLocalPath, affix) => { for (const file of files) { const data = readFile( - path.resolve(process.cwd(), `${config.data}/${config.contentful.contentful}/${file}`) + path.resolve(process.cwd(), `${config?.data}/${config?.contentful?.contentful}/${file}`) ); const title = file.split('.')[0]; const contentfulID = data?.[0]?.contentfulID; diff --git a/upload-api/migration-contentful/libs/extractEntries.js b/upload-api/migration-contentful/libs/extractEntries.js index 03c441240..3e9529ed8 100644 --- a/upload-api/migration-contentful/libs/extractEntries.js +++ b/upload-api/migration-contentful/libs/extractEntries.js @@ -29,9 +29,6 @@ const extractEntries = (cleanLocalPath) => { let entryTitle = entry?.fields?.title?.[locale]; entryTitle = !entryTitle ? entry?.fields?.name?.[locale] : entryTitle; if (!entryTitle) continue; - console.info(`entryTitle: ${entryTitle}`); - console.info(`contentTypeId: ${contentTypeId}`); - // if (!entryTitle) continue; if (!entriesByContentType[contentTypeId]) { entriesByContentType[contentTypeId] = []; } diff --git a/upload-api/migration-drupal/libs/extractEntries.js b/upload-api/migration-drupal/libs/extractEntries.js index 0dc6dc1a1..66ab2365e 100644 --- a/upload-api/migration-drupal/libs/extractEntries.js +++ b/upload-api/migration-drupal/libs/extractEntries.js @@ -67,15 +67,15 @@ async function extractEntries(connection, prefix) { const [rows] = await connection.promise().query(query); for (const row of rows) { - const bundle = row.type; + const bundle = row?.type; if (!bundle) { continue; } const otherCmsEntryUid = entrySourceUidCorrector({ - id: `content_type_entries_title_${row.nid}`, + id: `content_type_entries_title_${row?.nid}`, prefix, }); - const entryName = row.title ? String(row.title) : `Node ${row.nid}`; + const entryName = row?.title ? String(row?.title) : `Node ${row?.nid}`; if (!byBundle[bundle]) { byBundle[bundle] = []; @@ -83,14 +83,14 @@ async function extractEntries(connection, prefix) { byBundle[bundle].push({ contentTypeUid: bundle, entryName, - language: row.langcode || '', + language: row?.langcode || '', otherCmsEntryUid, otherCmsCTName: bundle, isUpdate: false, }); } - const total = Object.values(byBundle).reduce((n, arr) => n + arr.length, 0); + const total = Object?.values(byBundle)?.reduce((n, arr) => n + arr?.length, 0); console.info( `extractEntries (Drupal): ${total} entries across ${Object.keys(byBundle).length} bundle(s)` ); diff --git a/upload-api/migration-sitecore/libs/extractEntries.js b/upload-api/migration-sitecore/libs/extractEntries.js index 6e5f49d34..450f1de13 100644 --- a/upload-api/migration-sitecore/libs/extractEntries.js +++ b/upload-api/migration-sitecore/libs/extractEntries.js @@ -13,12 +13,12 @@ const idToString = (id) => { if (Array.isArray(id)) return idToString(id[0]); if (typeof id === 'object') { - const candidate = id.id ?? id.guid ?? id.value ?? id.$id ?? id._id; + const candidate = id?.id ?? id?.guid ?? id?.value ?? id?.$id ?? id?._id; if (typeof candidate === 'string' || typeof candidate === 'number' || typeof candidate === 'bigint') { return String(candidate); } - if (typeof id.toString === 'function' && id.toString !== Object.prototype.toString) { + if (typeof id?.toString === 'function' && id?.toString !== Object?.prototype?.toString) { const str = id.toString(); if (typeof str === 'string' && str && str !== '[object Object]') return str; } @@ -143,7 +143,7 @@ const extractEntries = async (newPath) => { const AllentryArray = Array.isArray(entryPresent) ? entryPresent : [entryPresent]; const entriesArray = []; - if (AllentryArray && AllentryArray.length > 0) { + if (AllentryArray && AllentryArray?.length > 0) { //console.info(`🚀 ~ extractEntries ~ AllentryArray:`, AllentryArray); for(const entry of AllentryArray){ const locales = entry?.locale && Object?.keys(entry?.locale); diff --git a/upload-api/src/helper/index.ts b/upload-api/src/helper/index.ts index 370bf338e..5642d31de 100644 --- a/upload-api/src/helper/index.ts +++ b/upload-api/src/helper/index.ts @@ -209,36 +209,30 @@ function deleteFolderSync(folderPath: string): void { } } -async function updateConfigFile(filePath?: string) { +async function updateConfigFile(filePath?: string): Promise { try { const configFilePath = path.join(process.cwd(), 'src', 'config', 'index.json'); - const config:any = JSON.parse(await fs.promises.readFile(configFilePath, 'utf8')); - - + const config: any = JSON.parse(await fs.promises.readFile(configFilePath, 'utf8')); + // If filePath is provided and not empty, update the config file if (filePath && typeof filePath === 'string' && filePath.trim() !== '') { const resolvedFilePath = path.resolve(filePath.trim()); - - // Read current config + const updatedConfig = { ...config, localPath: resolvedFilePath }; - - // Write updated config back to file + const configContent = JSON.stringify(updatedConfig, null, 2); await fs.promises.writeFile(configFilePath, configContent, 'utf8'); - - // Return updated config + return updatedConfig; } - - // If no filePath provided, just return current config + return config; } catch (error) { - console.error('Error updating config file:', error); - // Return current config as fallback - + logger.error('Error updating config file', { err: error }); + return undefined; } } diff --git a/upload-api/src/routes/index.ts b/upload-api/src/routes/index.ts index 2bd420920..40c0ed384 100644 --- a/upload-api/src/routes/index.ts +++ b/upload-api/src/routes/index.ts @@ -14,6 +14,7 @@ import { fileOperationLimiter, updateConfigFile } from '../helper'; import handleFileProcessing from '../services/fileProcessing'; import createMapper from '../services/createMapper'; import { sanitizeId, sanitizeFilename, isPathWithinBase } from '../utils/sanitize-path.utils'; +import logger from '../utils/logger'; const router: Router = express.Router(); // Use memory storage to avoid saving the file locally @@ -98,9 +99,16 @@ router.get( const app_token: string | string[] = req?.headers?.app_token ?? ''; const affix: string = sanitizeId(req?.headers?.affix ?? 'csm'); const config = await updateConfigFile(); - const cmsType = config?.cmsType?.toLowerCase(); + if (!config) { + logger.error('Failed to load application config'); + return res.status(500).json({ + status: 500, + message: 'Failed to load application configuration' + }); + } + const cmsType = config.cmsType?.toLowerCase(); - if (config?.isLocalPath) { + if (config.isLocalPath) { const localPath = config?.localPath || ''; // Check if localPath indicates a SQL/MySQL connection (case-insensitive) @@ -416,9 +424,15 @@ router.get( ); router.get('/config', async function (req: Request, res: Response) { - // Strip mysql password before sending config to the client const config = await updateConfigFile(); - const { password, ...safeMysql } = config?.mysql || {}; + if (!config) { + logger.error('Failed to load application config'); + return res.status(500).json({ + status: 500, + message: 'Failed to load application configuration' + }); + } + const { password, ...safeMysql } = config.mysql || {}; const safeConfig = { ...config, mysql: safeMysql diff --git a/upload-api/src/services/fileProcessing.ts b/upload-api/src/services/fileProcessing.ts index 84a8f942f..8c8e58d28 100644 --- a/upload-api/src/services/fileProcessing.ts +++ b/upload-api/src/services/fileProcessing.ts @@ -11,6 +11,14 @@ const handleFileProcessing = async ( name: string ) => { const config = await updateConfigFile(); + if (!config) { + logger.error('Failed to load application config'); + return { + status: HTTP_CODES.SERVER_ERROR, + message: HTTP_TEXTS.INTERNAL_ERROR, + file_details: undefined + }; + } if (fileExt === 'zip') { const zip = new JSZip(); await zip.loadAsync(zipBuffer); diff --git a/upload-api/tests/unit/config/index.config.test.ts b/upload-api/tests/unit/config/index.config.test.ts index 73f05093f..7b42d3503 100644 --- a/upload-api/tests/unit/config/index.config.test.ts +++ b/upload-api/tests/unit/config/index.config.test.ts @@ -6,7 +6,7 @@ describe('config/index', () => { }); it('should export default configuration object', async () => { - const config = (await import('../../../src/config/index')).default; + const config = (await import('../../../src/config/index.json')).default; expect(config).toHaveProperty('plan'); expect(config).toHaveProperty('cmsType'); @@ -18,41 +18,17 @@ describe('config/index', () => { }); it('should have plan with dropdown optionLimit', async () => { - const config = (await import('../../../src/config/index')).default; + const config = (await import('../../../src/config/index.json')).default; expect(config.plan.dropdown.optionLimit).toBe(100); }); it('should have isLocalPath as true', async () => { - const config = (await import('../../../src/config/index')).default; + const config = (await import('../../../src/config/index.json')).default; expect(config.isLocalPath).toBe(true); }); - it('should use CMS_TYPE env var when set', async () => { - vi.stubEnv('CMS_TYPE', 'sitecore'); - const config = (await import('../../../src/config/index')).default; - expect(config.cmsType).toBe('sitecore'); - }); - - it('should use CONTAINER_PATH env var when set', async () => { - vi.stubEnv('CONTAINER_PATH', '/custom/path'); - const config = (await import('../../../src/config/index')).default; - expect(config.localPath).toBe('/custom/path'); - }); - - it('should use DRUPAL_ASSETS_BASE_URL env var when set', async () => { - vi.stubEnv('DRUPAL_ASSETS_BASE_URL', 'https://example.com'); - const config = (await import('../../../src/config/index')).default; - expect(config.assetsConfig.base_url).toBe('https://example.com'); - }); - - it('should use DRUPAL_ASSETS_PUBLIC_PATH env var when set', async () => { - vi.stubEnv('DRUPAL_ASSETS_PUBLIC_PATH', '/custom/files'); - const config = (await import('../../../src/config/index')).default; - expect(config.assetsConfig.public_path).toBe('/custom/files'); - }); - it('should have default AWS data', async () => { - const config = (await import('../../../src/config/index')).default; + const config = (await import('../../../src/config/index.json')).default; expect(config.awsData).toEqual({ awsRegion: 'us-east-2', awsAccessKeyId: '', @@ -64,7 +40,7 @@ describe('config/index', () => { }); it('should have default MySQL configuration', async () => { - const config = (await import('../../../src/config/index')).default; + const config = (await import('../../../src/config/index.json')).default; expect(config.mysql.host).toBe('host_name'); expect(config.mysql.user).toBe('user_name'); expect(config.mysql.database).toBe('database_name'); diff --git a/upload-api/tests/unit/routes/index.routes.test.ts b/upload-api/tests/unit/routes/index.routes.test.ts index 0d82a1072..a6da27796 100644 --- a/upload-api/tests/unit/routes/index.routes.test.ts +++ b/upload-api/tests/unit/routes/index.routes.test.ts @@ -45,6 +45,7 @@ vi.mock('../../../src/services/aws/client', () => ({ vi.mock('../../../src/helper', () => ({ fileOperationLimiter: (_req: any, _res: any, next: any) => next(), deleteFolderSync: vi.fn(), + updateConfigFile: vi.fn().mockImplementation(() => Promise.resolve(mockConfig)), })); vi.mock('../../../src/services/fileProcessing', () => ({ @@ -55,7 +56,7 @@ vi.mock('../../../src/services/createMapper', () => ({ default: (...args: any[]) => mockCreateMapper(...args), })); -vi.mock('../../../src/config/index', () => ({ default: mockConfig })); +vi.mock('../../../src/config/index.json', () => ({ default: mockConfig })); vi.mock('@aws-sdk/client-s3', () => ({ GetObjectCommand: vi.fn().mockImplementation(function (this: any, p: any) { Object.assign(this, p); }), diff --git a/upload-api/tests/unit/services/aws-client.test.ts b/upload-api/tests/unit/services/aws-client.test.ts index 3bab12d4a..d41d880f4 100644 --- a/upload-api/tests/unit/services/aws-client.test.ts +++ b/upload-api/tests/unit/services/aws-client.test.ts @@ -7,7 +7,7 @@ const { mockS3Client } = vi.hoisted(() => ({ }), })); -vi.mock('../../../src/config', () => ({ +vi.mock('../../../src/config/index.json', () => ({ default: { awsData: { awsRegion: 'us-east-2', diff --git a/upload-api/tests/unit/services/fileProcessing.test.ts b/upload-api/tests/unit/services/fileProcessing.test.ts index 2f5be5d54..2513a590b 100644 --- a/upload-api/tests/unit/services/fileProcessing.test.ts +++ b/upload-api/tests/unit/services/fileProcessing.test.ts @@ -1,11 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockValidator, mockSaveZip, mockSaveJson, mockParseXmlToJson } = vi.hoisted(() => ({ - mockValidator: vi.fn(), - mockSaveZip: vi.fn(), - mockSaveJson: vi.fn(), - mockParseXmlToJson: vi.fn(), -})); +const { mockValidator, mockSaveZip, mockSaveJson, mockParseXmlToJson, mockUpdateConfigFile } = + vi.hoisted(() => ({ + mockValidator: vi.fn(), + mockSaveZip: vi.fn(), + mockSaveJson: vi.fn(), + mockParseXmlToJson: vi.fn(), + mockUpdateConfigFile: vi.fn(), + })); vi.mock('../../../src/validators/index', () => ({ default: mockValidator })); vi.mock('../../../src/helper/index', () => ({ @@ -15,13 +17,14 @@ vi.mock('../../../src/helper/index', () => ({ fileOperationLimiter: vi.fn(), deleteFolderSync: vi.fn(), getFileName: vi.fn(), + updateConfigFile: (...args: unknown[]) => mockUpdateConfigFile(...args), })); vi.mock('../../../src/utils/logger', () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, })); -vi.mock('../../../src/config/index', () => ({ +vi.mock('../../../src/config/index.json', () => ({ default: { cmsType: 'wordpress', mysql: { host: 'localhost', user: 'root', password: 'pw', database: 'db', port: '3306' }, @@ -40,6 +43,13 @@ import handleFileProcessing from '../../../src/services/fileProcessing'; describe('handleFileProcessing', () => { beforeEach(() => { vi.clearAllMocks(); + mockUpdateConfigFile.mockResolvedValue({ + cmsType: 'wordpress', + mysql: { host: 'localhost', user: 'root', password: 'pw', database: 'db', port: '3306' }, + assetsConfig: { base_url: 'http://test.com', public_path: '/files' }, + isLocalPath: true, + localPath: '', + }); }); describe('zip files', () => { From b955cf1e388d714cca852a3d2716dc5905ee46ea Mon Sep 17 00:00:00 2001 From: yashin4112 Date: Wed, 22 Apr 2026 14:42:47 +0530 Subject: [PATCH 03/48] refactor: enhance error management in field attacher utility; add unit tests for content type creation logic --- api/src/services/migration.service.ts | 4 +- api/src/utils/field-attacher.utils.ts | 21 +++--- api/tests/unit/helper/index.test.ts | 14 ++++ .../utils/content-type-checker.utils.test.ts | 31 ++++++++- .../unit/utils/field-attacher.utils.test.ts | 69 +++++++++++++++++-- .../unit/utils/sanitize-path.utils.test.ts | 35 +++++++++- 6 files changed, 157 insertions(+), 17 deletions(-) diff --git a/api/src/services/migration.service.ts b/api/src/services/migration.service.ts index f0bad4bc3..aa6330086 100644 --- a/api/src/services/migration.service.ts +++ b/api/src/services/migration.service.ts @@ -836,8 +836,8 @@ const startMigration = async (req: Request): Promise => { const contentTypes = await fieldAttacher({ orgId, - projectId, - destinationStackId: project?.destination_stack_id, + projectId: safeFinalProjectId, + destinationStackId: safeFinalStackId, region, user_id, is_sso, diff --git a/api/src/utils/field-attacher.utils.ts b/api/src/utils/field-attacher.utils.ts index f5eb95ea6..cd77dc392 100644 --- a/api/src/utils/field-attacher.utils.ts +++ b/api/src/utils/field-attacher.utils.ts @@ -3,16 +3,21 @@ import getContentTypesMapperDb from "../models/contentTypesMapper-lowdb.js"; import getFieldMapperDb from "../models/FieldMapper.js"; import { contenTypeMaker } from "./content-type-creator.utils.js"; import { shouldSkipContentTypeCreation } from "./content-type-checker.utils.js"; +import { sanitizeProjectId } from "./sanitize-path.utils.js"; export const fieldAttacher = async ({ projectId, orgId, destinationStackId, region, user_id, is_sso }: any) => { + const safeProjectId = sanitizeProjectId(projectId); + if (!safeProjectId) { + throw new Error("Invalid project identifier"); + } await ProjectModelLowdb.read(); const projectData: any = ProjectModelLowdb.chain.get("projects").find({ - id: projectId, + id: safeProjectId, org_id: orgId, }).value() const iteration = projectData?.iteration || 1; - const ContentTypesMapperModelLowdb = getContentTypesMapperDb(projectId, iteration); - const FieldMapperModel = getFieldMapperDb(projectId, iteration); + const ContentTypesMapperModelLowdb = getContentTypesMapperDb(safeProjectId, iteration); + const FieldMapperModel = getFieldMapperDb(safeProjectId, iteration); await ContentTypesMapperModelLowdb.read(); await FieldMapperModel.read(); const contentTypes = []; @@ -20,27 +25,27 @@ export const fieldAttacher = async ({ projectId, orgId, destinationStackId, regi for await (const contentId of projectData?.content_mapper ?? []) { const contentType: any = ContentTypesMapperModelLowdb.chain .get("ContentTypesMappers") - .find({ id: contentId, projectId: projectId }) + .find({ id: contentId, projectId: safeProjectId }) .value(); if (contentType?.fieldMapping?.length) { contentType.fieldMapping = contentType?.fieldMapping?.map((fieldUid: any) => { const field = FieldMapperModel.chain .get("field_mapper") - .find({ id: fieldUid, contentTypeId: contentId, projectId: projectId }) + .find({ id: fieldUid, contentTypeId: contentId, projectId: safeProjectId }) .value() return field; }) } if (iteration === 1) { - await contenTypeMaker({ contentType, destinationStackId, projectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) + await contenTypeMaker({ contentType, destinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) } else { - const shouldSkip = await shouldSkipContentTypeCreation(projectId, contentType?.otherCmsUid, iteration); + const shouldSkip = await shouldSkipContentTypeCreation(safeProjectId, contentType?.otherCmsUid, iteration); if (!shouldSkip) { console.info(`Creating new content type: ${contentType.otherCmsUid}`); - await contenTypeMaker({ contentType, destinationStackId, projectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) + await contenTypeMaker({ contentType, destinationStackId, projectId: safeProjectId, newStack: projectData?.stackDetails?.isNewStack, keyMapper: projectData?.mapperKeys, region, user_id, is_sso }) } else { console.info(`Skipping content type creation: ${contentType.otherCmsUid} (already exists from previous iteration)`); } diff --git a/api/tests/unit/helper/index.test.ts b/api/tests/unit/helper/index.test.ts index 021e01e2f..dad32ffb7 100644 --- a/api/tests/unit/helper/index.test.ts +++ b/api/tests/unit/helper/index.test.ts @@ -127,6 +127,20 @@ describe('helper createDbConnection', () => { expect(mockDestroy).toHaveBeenCalled(); }); + it('destroys connection if callback arrives after timeout', async () => { + vi.useFakeTimers(); + let cbRef: ((err: Error | null) => void) | undefined; + mockConnect.mockImplementation((cb: (err: Error | null) => void) => { + cbRef = cb; + }); + const p = createDbConnection(config, 'proj', 'stack', 50); + vi.advanceTimersByTime(50); + await expect(p).rejects.toThrow('timed out'); + + cbRef?.(null); + expect(mockDestroy).toHaveBeenCalledTimes(2); + }); + it('getDbConnection throws when connection is null', async () => { mockCreateConnection.mockImplementation(() => { throw new Error('fail'); diff --git a/api/tests/unit/utils/content-type-checker.utils.test.ts b/api/tests/unit/utils/content-type-checker.utils.test.ts index d308a6f96..df4573a04 100644 --- a/api/tests/unit/utils/content-type-checker.utils.test.ts +++ b/api/tests/unit/utils/content-type-checker.utils.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockRead = vi.fn(); const mockExistsSync = vi.fn(); +const mockContentTypesData = vi.hoisted(() => ({ + value: { ContentTypesMappers: [{ otherCmsUid: 'ct-uid' }] } as any, +})); vi.mock('fs', () => ({ default: { existsSync: mockExistsSync }, @@ -11,7 +14,7 @@ vi.mock('fs', () => ({ vi.mock('../../../src/models/contentTypesMapper-lowdb.js', () => ({ default: vi.fn(() => ({ read: mockRead, - data: { ContentTypesMappers: [{ otherCmsUid: 'ct-uid' }] }, + data: mockContentTypesData.value, })), })); @@ -20,6 +23,7 @@ describe('content-type-checker.utils', () => { vi.clearAllMocks(); mockRead.mockResolvedValue(undefined); mockExistsSync.mockReturnValue(true); + mockContentTypesData.value = { ContentTypesMappers: [{ otherCmsUid: 'ct-uid' }] }; }); it('isContentTypeAlreadyCreated returns false when currentIteration <= 1', async () => { @@ -82,4 +86,29 @@ describe('content-type-checker.utils', () => { ); await expect(getPreviouslyCreatedContentTypes('p1', 2)).resolves.toEqual([]); }); + + it('getPreviouslyCreatedContentTypes skips iterations with missing directories', async () => { + mockExistsSync.mockReturnValue(false); + const { getPreviouslyCreatedContentTypes } = await import( + '../../../src/utils/content-type-checker.utils.js' + ); + await expect(getPreviouslyCreatedContentTypes('p1', 3)).resolves.toEqual([]); + expect(mockRead).not.toHaveBeenCalled(); + }); + + it('getPreviouslyCreatedContentTypes returns empty when mapper data is missing', async () => { + mockContentTypesData.value = undefined; + const { getPreviouslyCreatedContentTypes } = await import( + '../../../src/utils/content-type-checker.utils.js' + ); + await expect(getPreviouslyCreatedContentTypes('p1', 2)).resolves.toEqual([]); + }); + + it('getPreviouslyCreatedContentTypes ignores entries without otherCmsUid', async () => { + mockContentTypesData.value = { ContentTypesMappers: [{}, { otherCmsUid: 'ct-uid-2' }] }; + const { getPreviouslyCreatedContentTypes } = await import( + '../../../src/utils/content-type-checker.utils.js' + ); + await expect(getPreviouslyCreatedContentTypes('p1', 2)).resolves.toEqual(['ct-uid-2']); + }); }); diff --git a/api/tests/unit/utils/field-attacher.utils.test.ts b/api/tests/unit/utils/field-attacher.utils.test.ts index c4fcc32b9..a7f6d493e 100644 --- a/api/tests/unit/utils/field-attacher.utils.test.ts +++ b/api/tests/unit/utils/field-attacher.utils.test.ts @@ -8,6 +8,7 @@ const { mockContentTypesChain, mockFieldMapperChain, mockContenTypeMaker, + mockShouldSkipContentTypeCreation, } = vi.hoisted(() => { const mockProjectChain = { get: vi.fn().mockReturnThis(), @@ -32,6 +33,7 @@ const { mockContentTypesChain, mockFieldMapperChain, mockContenTypeMaker: vi.fn(), + mockShouldSkipContentTypeCreation: vi.fn(), }; }); @@ -43,23 +45,27 @@ vi.mock('../../../src/models/project-lowdb.js', () => ({ })); vi.mock('../../../src/models/contentTypesMapper-lowdb.js', () => ({ - default: { + default: () => ({ read: mockContentTypesRead, chain: mockContentTypesChain, - }, + }), })); vi.mock('../../../src/models/FieldMapper.js', () => ({ - default: { + default: () => ({ read: mockFieldMapperRead, chain: mockFieldMapperChain, - }, + }), })); vi.mock('../../../src/utils/content-type-creator.utils.js', () => ({ contenTypeMaker: (...args: any[]) => mockContenTypeMaker(...args), })); +vi.mock('../../../src/utils/content-type-checker.utils.js', () => ({ + shouldSkipContentTypeCreation: (...args: any[]) => mockShouldSkipContentTypeCreation(...args), +})); + import { fieldAttacher } from '../../../src/utils/field-attacher.utils.js'; describe('field-attacher.utils', () => { @@ -69,6 +75,7 @@ describe('field-attacher.utils', () => { mockContentTypesRead.mockResolvedValue(undefined); mockFieldMapperRead.mockResolvedValue(undefined); mockContenTypeMaker.mockResolvedValue(undefined); + mockShouldSkipContentTypeCreation.mockResolvedValue(false); }); it('should return empty array when project has no content_mapper', async () => { @@ -221,4 +228,58 @@ describe('field-attacher.utils', () => { expect(mockContenTypeMaker).toHaveBeenCalledTimes(2); expect(result).toHaveLength(2); }); + + it('should create content type in later iterations when skip check returns false', async () => { + const contentType = { id: 'ct-1', otherCmsUid: 'blog', fieldMapping: [] }; + + mockProjectChain.value.mockReturnValue({ + id: 'proj-1', + org_id: 'org-1', + iteration: 2, + content_mapper: ['ct-1'], + stackDetails: { isNewStack: false }, + mapperKeys: {}, + }); + mockContentTypesChain.value.mockReturnValue(contentType); + mockShouldSkipContentTypeCreation.mockResolvedValue(false); + + const result = await fieldAttacher({ + projectId: 'proj-1', + orgId: 'org-1', + destinationStackId: 'stack-1', + region: 'NA', + user_id: 'user-1', + is_sso: true, + }); + + expect(mockShouldSkipContentTypeCreation).toHaveBeenCalledWith('proj-1', 'blog', 2); + expect(mockContenTypeMaker).toHaveBeenCalledTimes(1); + expect(mockContenTypeMaker).toHaveBeenCalledWith(expect.objectContaining({ is_sso: true })); + expect(result).toHaveLength(1); + }); + + it('should skip content type creation in later iterations when skip check returns true', async () => { + mockProjectChain.value.mockReturnValue({ + id: 'proj-1', + org_id: 'org-1', + iteration: 2, + content_mapper: ['ct-1'], + stackDetails: { isNewStack: false }, + mapperKeys: {}, + }); + mockContentTypesChain.value.mockReturnValue({ id: 'ct-1', otherCmsUid: 'blog', fieldMapping: [] }); + mockShouldSkipContentTypeCreation.mockResolvedValue(true); + + const result = await fieldAttacher({ + projectId: 'proj-1', + orgId: 'org-1', + destinationStackId: 'stack-1', + region: 'NA', + user_id: 'user-1', + }); + + expect(mockShouldSkipContentTypeCreation).toHaveBeenCalledWith('proj-1', 'blog', 2); + expect(mockContenTypeMaker).not.toHaveBeenCalled(); + expect(result).toHaveLength(1); + }); }); diff --git a/api/tests/unit/utils/sanitize-path.utils.test.ts b/api/tests/unit/utils/sanitize-path.utils.test.ts index bb60825f7..2613dc368 100644 --- a/api/tests/unit/utils/sanitize-path.utils.test.ts +++ b/api/tests/unit/utils/sanitize-path.utils.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from 'vitest'; -import { sanitizeStackId, getSafePath } from '../../../src/utils/sanitize-path.utils.js'; +import { describe, it, expect, vi } from 'vitest'; +import { + sanitizeStackId, + getSafePath, + assertResolvedPathUnderBase, +} from '../../../src/utils/sanitize-path.utils.js'; import path from 'path'; describe('sanitize-path.utils', () => { @@ -86,5 +90,32 @@ describe('sanitize-path.utils', () => { expect(result).toContain('file.log'); expect(path.isAbsolute(result)).toBe(true); }); + + it('should return fallback when path resolution throws', () => { + const resolveSpy = vi.spyOn(path, 'resolve').mockImplementationOnce(() => { + throw new Error('resolve failed'); + }); + + const result = getSafePath('file.log', '/tmp/logs'); + + expect(result).toBe(path.join('/tmp/logs', 'default.log')); + resolveSpy.mockRestore(); + }); + }); + + describe('assertResolvedPathUnderBase', () => { + it('should not throw for paths inside base directory', () => { + expect(() => + assertResolvedPathUnderBase('/tmp/logs', '/tmp/logs/subdir/file.log') + ).not.toThrow(); + }); + + it('should throw for paths outside base directory', () => { + expect(() => + assertResolvedPathUnderBase('/tmp/logs', '/tmp/other/file.log') + ).toThrow( + 'Invalid path: resolved location is outside the allowed base directory' + ); + }); }); }); From 94bb371b4e3fb4a868804a501e27532159b92ef9 Mon Sep 17 00:00:00 2001 From: Aishwarya Date: Mon, 20 Apr 2026 18:07:47 +0530 Subject: [PATCH 04/48] refactor: optimize global field reference handling in convertToSchemaFormate function --- api/src/utils/content-type-creator.utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/src/utils/content-type-creator.utils.ts b/api/src/utils/content-type-creator.utils.ts index 6bf9514f9..ee3028390 100644 --- a/api/src/utils/content-type-creator.utils.ts +++ b/api/src/utils/content-type-creator.utils.ts @@ -753,10 +753,11 @@ export const convertToSchemaFormate = ({ field, advanced = false, marketPlacePat case 'global_field': { + const globalFieldRefs = remapReferenceUids(field?.refrenceTo ?? [], keyMapper); return { "data_type": "global_field", "display_name": field?.title, - "reference_to": remapReferenceUids(field?.refrenceTo ?? [], keyMapper), + "reference_to": globalFieldRefs?.length === 1 ? globalFieldRefs?.[0] : globalFieldRefs, "uid": cleanedUid, "mandatory": field?.advanced?.mandatory ?? false, "multiple": field?.advanced?.multiple ?? false, From 20af43d0af7317d9b6552ac98615a44c73d31f56 Mon Sep 17 00:00:00 2001 From: Aishwarya Date: Tue, 21 Apr 2026 12:22:10 +0530 Subject: [PATCH 05/48] fix: correct error message formatting in LoadStacks component by adjusting data reference --- ui/src/components/DestinationStack/Actions/LoadStacks.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/DestinationStack/Actions/LoadStacks.tsx b/ui/src/components/DestinationStack/Actions/LoadStacks.tsx index e52284c24..a176e27ea 100644 --- a/ui/src/components/DestinationStack/Actions/LoadStacks.tsx +++ b/ui/src/components/DestinationStack/Actions/LoadStacks.tsx @@ -184,7 +184,7 @@ const LoadStacks = (props: LoadFileFormatProps) => { return true; } else { - const errorMessage = formatErrorMessage(resp?.data?.data); + const errorMessage = formatErrorMessage(resp?.data); return errorMessage; } } catch (error: any) { From 87b988d95fbeadbb05dd8885f09089e9104e5435 Mon Sep 17 00:00:00 2001 From: Aishwarya Date: Wed, 22 Apr 2026 11:57:35 +0530 Subject: [PATCH 06/48] refactor: implement default widget ID inference and enhance field mapping resolution in Contentful service --- api/src/services/contentful.service.ts | 82 +++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/api/src/services/contentful.service.ts b/api/src/services/contentful.service.ts index 37af61b8b..709d27c4b 100644 --- a/api/src/services/contentful.service.ts +++ b/api/src/services/contentful.service.ts @@ -129,6 +129,78 @@ function buildContentfulFieldLocalizedByContentType( return byCt; } +/** + * When the export omits `widgetId`, infer defaults aligned with + * upload-api/migration-contentful/libs/contentTypeMapper.js. + */ +function inferContentfulDefaultWidgetId(fieldType: string | undefined): string | undefined { + switch (fieldType) { + case "Symbol": + return "singleLine"; + case "Text": + return "multipleLine"; + case "Integer": + case "Number": + return "numberEditor"; + case "RichText": + return "richTextEditor"; + case "Boolean": + return "boolean"; + default: + return undefined; + } +} +function getContentfulFieldFromPackage( + contentTypesFromPackage: any[] | undefined, + ctId: string, + fieldId: string +): any | undefined { + const ct = contentTypesFromPackage?.find((c: any) => c?.sys?.id === ctId); + return ct?.fields?.find((f: any) => f?.id === fieldId); +} +/** + * Picks one fieldMapping row when several share the same `uid` (e.g. bootstrap `title`/`url` rows + * from createInitialMapper plus the real Contentful field). Mapper `otherCmsType` is Contentful + * `widgetId` from the migration pipeline. + */ +function resolveFieldMappingRow( + fieldMapping: any[] | undefined, + contentTypesFromPackage: any[] | undefined, + ctId: string, + fieldId: string +): any | undefined { + const candidates = fieldMapping?.filter((item: any) => item?.uid === fieldId) ?? []; + if (candidates?.length === 0) return undefined; + if (candidates?.length === 1) return candidates?.[0]; + const cfField = getContentfulFieldFromPackage(contentTypesFromPackage, ctId, fieldId); + const widgetId = cfField?.widgetId ?? inferContentfulDefaultWidgetId(cfField?.type); + if (widgetId) { + const byWidget = candidates?.filter((c: any) => c?.otherCmsType === widgetId); + if (byWidget?.length >= 1) return byWidget?.[0]; + } + const typeToCs: Record = { + RichText: "json", + Boolean: "boolean", + Date: "isodate", + }; + const expectCs = cfField?.type ? typeToCs[cfField.type as string] : undefined; + if (expectCs) { + const byCs = candidates?.filter((c: any) => c?.contentstackFieldType === expectCs); + if (byCs?.length >= 1) return byCs?.[0]; + } + if (cfField?.type === "Boolean") { + const byBool = candidates?.filter((c: any) => c?.contentstackFieldType === "boolean"); + if (byBool?.length >= 1) return byBool?.[0]; + } + // Legacy bootstrap rows use otherCmsType "text" while real Symbol/Text fields use widget ids + // (e.g. singleLine). Prefer non-"text" otherCmsType when the schema is Symbol/Text. + if (cfField && ["Symbol", "Text"]?.includes(cfField?.type)) { + const nonBootstrap = candidates?.filter((c: any) => c?.otherCmsType !== "text"); + if (nonBootstrap?.length >= 1) return nonBootstrap?.[0]; + } + return candidates?.[0]; +} + const transformCloudinaryObject = (input: any) => { const result: any = []; if (!Array.isArray(input)) { @@ -845,7 +917,13 @@ const createEntry = async (packagePath: any, destination_stack_id: string, proje entryData[name][lang] ??= {}; entryData[name][lang][id] ??= {}; locales.push(lang); - const fieldData = currentCT?.fieldMapping?.find?.((item: any) => key === item?.uid); + const fieldData = resolveFieldMappingRow( + currentCT?.fieldMapping, + content, + name, + key + ); + const newId = fieldData?.contentstackFieldUid ?? `${key}`?.replace?.(/[^a-zA-Z0-9]+/g, "_"); entryData[name][lang][id][newId] = processField( langValue, @@ -906,7 +984,7 @@ const createEntry = async (packagePath: any, destination_stack_id: string, proje for (const [key, value] of Object?.entries?.(fields)) { const langs = Object?.keys(value as object); if (langs?.length !== 1) continue; - const fd = ct?.fieldMapping?.find?.((item: any) => key === item?.uid); + const fd = resolveFieldMappingRow(ct?.fieldMapping, content, name, key); const localizedInCf = cfFieldLocalizedByCt.get(name)?.get(key); const explicitlyNonLocalized = localizedInCf === false || From 97ce36cf0a38e0d9dcab0ec10414ec3a371f2c4d Mon Sep 17 00:00:00 2001 From: Aishwarya Date: Fri, 24 Apr 2026 13:13:25 +0530 Subject: [PATCH 07/48] feat: add AutoMappedMergeConfirmModal component and enhance SaveChangesModal with async handling for step changes --- .../AutoMappedMergeConfirmModal/index.tsx | 43 ++ .../Common/SaveChangesModal/index.tsx | 12 +- .../ContentMapper/contentMapper.interface.ts | 2 + ui/src/components/ContentMapper/index.scss | 80 +++- ui/src/components/ContentMapper/index.tsx | 433 ++++++++++++++++-- .../HorizontalStepper/HorizontalStepper.tsx | 20 +- ui/src/context/app/app.interface.ts | 4 +- ui/src/pages/Migration/index.tsx | 48 +- ui/src/utilities/constants.ts | 9 +- ui/tests/unit/utilities/constants.test.ts | 2 + 10 files changed, 605 insertions(+), 48 deletions(-) create mode 100644 ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx diff --git a/ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx b/ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx new file mode 100644 index 000000000..f223a555f --- /dev/null +++ b/ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx @@ -0,0 +1,43 @@ +import { + ModalBody, + ModalHeader, + ModalFooter, + ButtonGroup, + Button +} from '@contentstack/venus-components'; + +interface Props { + closeModal: () => void; + onContinue: () => void | Promise; +} + +const AutoMappedMergeConfirmModal = (props: Props) => { + return ( + <> + props.closeModal()} + /> + + All auto-mapped content types will be merged into your exisitng content types and global fields. You can cancel to stay on this step or continue to the next step. + + + + + + + + + ); +}; + +export default AutoMappedMergeConfirmModal; diff --git a/ui/src/components/Common/SaveChangesModal/index.tsx b/ui/src/components/Common/SaveChangesModal/index.tsx index 67b3104d2..6c299da5e 100644 --- a/ui/src/components/Common/SaveChangesModal/index.tsx +++ b/ui/src/components/Common/SaveChangesModal/index.tsx @@ -13,7 +13,7 @@ interface Props { otherCmsTitle?: string; saveContentType?: () => void; openContentType?: () => void; - changeStep?: () => void; + changeStep?: () => void | Promise; dropdownStateChange: () => void; } @@ -47,24 +47,24 @@ const SaveChangesModal = (props: Props) => { ))} @@ -3214,6 +3541,34 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref:
    {filteredContentTypes?.map?.((content: ContentType, index: number) => { const icon = STATUS_ICON_Mapping[content?.status] || ''; + const existingCTSide = asContentTypeListArray( + newMigrationData?.content_mapping?.existingCT + ); + const existingGlobalSide = asContentTypeListArray( + newMigrationData?.content_mapping?.existingGlobal + ); + let rowDestinationModels: ContentTypeList[] = getDestinationModelsForRow( + content, + existingCTSide, + existingGlobalSide + ); + if ( + !rowDestinationModels?.length && + contentModels?.length && + selectedContentType?.contentstackUid === content?.contentstackUid && + ((content?.type === 'content_type' && isContentType) || + (content?.type !== 'content_type' && !isContentType)) + ) { + rowDestinationModels = contentModels; + } + const showAutoMappedBadge = + !isNewStack && + isContentTypeAutoMapped( + content?.contentstackUid, + rowDestinationModels, + combinedContentTypeMapping, + uidAutoMapSuppressedForSourceUids + ); const format = (str: string) => { const frags = str?.split('_'); @@ -3251,14 +3606,38 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref:
    - + + {showAutoMappedBadge && ( + + e.preventDefault()} + > + + + + )} {icon && ( )} - + diff --git a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx index 809c659e5..81fe883be 100644 --- a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx +++ b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx @@ -44,6 +44,7 @@ export type stepperProps = { stepTitleClassName?: string; testId?: string; handleSaveCT?: () => void; + handleUpdateAutoMappedContentMapping?: () => Promise; changeDropdownState: () => void; projectData: MigrationResponse; isProjectMapped: boolean; @@ -194,13 +195,26 @@ const HorizontalStepper = forwardRef( if (newMigrationData?.content_mapping?.isDropDownChanged) { setIsModalOpen(true); return cbModal({ - component: (props: ModalObj) => ( + component: (modalProps: ModalObj) => ( setTabStep(idx)} + changeStep={async () => { + try { + await handleUpdateAutoMappedContentMapping?.(); + } catch { + Notification({ + notificationContent: { + text: 'Could not save content type mapping. Please try again.' + }, + type: 'error' + }); + return; + } + setTabStep(idx); + }} dropdownStateChange={handleDropdownChange} /> ), diff --git a/ui/src/context/app/app.interface.ts b/ui/src/context/app/app.interface.ts index dc34b0c00..48c4e6ba7 100644 --- a/ui/src/context/app/app.interface.ts +++ b/ui/src/context/app/app.interface.ts @@ -191,8 +191,8 @@ export interface IDestinationStack { csLocale: string[]; } export interface IContentMapper { - existingGlobal: ContentTypeList[] | (() => ContentTypeList[]); - existingCT: ContentTypeList[] | (() => ContentTypeList[]); + existingGlobal: ContentTypeList[]; + existingCT: ContentTypeList[]; content_type_mapping: ContentTypeMap; isDropDownChanged?: boolean; otherCmsTitle?: string; diff --git a/ui/src/pages/Migration/index.tsx b/ui/src/pages/Migration/index.tsx index 6e8fecda4..2ffc3c3bb 100644 --- a/ui/src/pages/Migration/index.tsx +++ b/ui/src/pages/Migration/index.tsx @@ -61,6 +61,7 @@ import ContentMapper from '../../components/ContentMapper'; import TestMigration from '../../components/TestMigration'; import MigrationExecution from '../../components/MigrationExecution'; import SaveChangesModal from '../../components/Common/SaveChangesModal'; +import AutoMappedMergeConfirmModal from '../../components/Common/AutoMappedMergeConfirmModal'; import { getMigratedStacks } from '../../services/api/project.service'; import { getConfig } from '../../services/api/upload.service'; import { useWarnOnRefresh } from '../../hooks/useWarnOnrefresh'; @@ -750,6 +751,22 @@ const Migration = () => { * Calls when click Continue button on Content Mapper step and handles to proceed to Test Migration */ const handleOnClickContentMapper = async (event: MouseEvent) => { + const persistAutoMappedContentMapper = async (): Promise => { + try { + await saveRef?.current?.handleUpdateAutoMappedContentMapping?.(); + return true; + } catch { + Notification({ + notificationContent: { + text: 'Could not save content type mapping. Please try again.' + }, + notificationProps: { position: 'bottom-center', hideProgressBar: true }, + type: 'error' + }); + return false; + } + }; + if (newMigrationData?.content_mapping?.isDropDownChanged) { setIsModalOpen(true); @@ -761,6 +778,7 @@ const Migration = () => { otherCmsTitle={newMigrationData?.content_mapping?.otherCmsTitle} saveContentType={saveRef?.current?.handleSaveContentType} changeStep={async () => { + if (!(await persistAutoMappedContentMapper())) return; const url = `/projects/${projectId}/migration/steps/4`; navigate(url, { replace: true }); @@ -776,14 +794,35 @@ const Migration = () => { } }); } else { - - const res = await updateCurrentStepData(selectedOrganisation.value, projectId); + const finishContentMapperNavigation = async () => { + if (!(await persistAutoMappedContentMapper())) return; + await updateCurrentStepData(selectedOrganisation.value, projectId); setIsLoading(false); - event.preventDefault(); + event?.preventDefault?.(); handleStepChange(3); const url = `/projects/${projectId}/migration/steps/4`; navigate(url, { replace: true }); + }; + + if (saveRef?.current?.shouldPromptShowAutoMappedMerge?.()) { + return cbModal({ + component: (props: ModalObj) => ( + { + props.closeModal(); + await finishContentMapperNavigation(); + }} + /> + ), + modalProps: { + size: 'xsmall', + shouldCloseOnOverlayClick: false + } + }); + } + await finishContentMapperNavigation(); } }; @@ -919,6 +958,9 @@ const Migration = () => { ref={stepperRef} steps={createStepper(projectData ?? defaultMigrationResponse, handleStepChange)} handleSaveCT={saveRef?.current?.handleSaveContentType} + handleUpdateAutoMappedContentMapping={() => + saveRef?.current?.handleUpdateAutoMappedContentMapping?.() ?? Promise.resolve() + } changeDropdownState={changeDropdownState} projectData={projectData || defaultMigrationResponse} isProjectMapped={isProjectMapper} diff --git a/ui/src/utilities/constants.ts b/ui/src/utilities/constants.ts index 403f7a508..6f831b92e 100644 --- a/ui/src/utilities/constants.ts +++ b/ui/src/utilities/constants.ts @@ -105,14 +105,13 @@ export const CONTENT_MAPPING_STATUS: ObjectType = { '1': 'Mapped', '2': 'Updated', '3': 'Failed', - '4': 'All' - // '4': 'Auto-Dump' + '4': 'All', + '5': 'Auto-mapped', }; export const STATUS_ICON_Mapping: { [key: string]: string } = { '1': 'CheckedCircle', '2': 'SuccessInverted', - '3': 'ErrorInverted' - // '4': 'completed' + '3': 'ErrorInverted', }; export const VALIDATION_DOCUMENTATION_URL: { [key: string]: string } = { @@ -199,3 +198,5 @@ export const EXECUTION_LOGS_UI_TEXT = { export const EXECUTION_LOGS_ERROR_TEXT = { ERROR: 'Error in Getting Migration Logs' } + +export const AUTO_MAPPED_PILL_ITEMS = [{ id: 'auto-mapped', text: 'Auto-mapped' }]; \ No newline at end of file diff --git a/ui/tests/unit/utilities/constants.test.ts b/ui/tests/unit/utilities/constants.test.ts index ee2c057f0..2a15360f3 100644 --- a/ui/tests/unit/utilities/constants.test.ts +++ b/ui/tests/unit/utilities/constants.test.ts @@ -111,6 +111,7 @@ describe('utilities/constants', () => { expect(CONTENT_MAPPING_STATUS['1']).toBe('Mapped'); expect(CONTENT_MAPPING_STATUS['2']).toBe('Updated'); expect(CONTENT_MAPPING_STATUS['3']).toBe('Failed'); + expect(CONTENT_MAPPING_STATUS['5']).toBe('Auto-mapped'); expect(CONTENT_MAPPING_STATUS['4']).toBe('All'); }); @@ -118,6 +119,7 @@ describe('utilities/constants', () => { expect(STATUS_ICON_Mapping['1']).toBe('CheckedCircle'); expect(STATUS_ICON_Mapping['2']).toBe('SuccessInverted'); expect(STATUS_ICON_Mapping['3']).toBe('ErrorInverted'); + expect(STATUS_ICON_Mapping['5']).toBe('Link'); }); it('should export VALIDATION_DOCUMENTATION_URL', () => { From b55574b66ff110c941bd427eac9284a00b945fac Mon Sep 17 00:00:00 2001 From: Aishwarya Date: Fri, 24 Apr 2026 13:17:46 +0530 Subject: [PATCH 08/48] fix: remove incorrect mapping for STATUS_ICON_Mapping in constants test --- ui/tests/unit/utilities/constants.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/tests/unit/utilities/constants.test.ts b/ui/tests/unit/utilities/constants.test.ts index 2a15360f3..4a77aefc7 100644 --- a/ui/tests/unit/utilities/constants.test.ts +++ b/ui/tests/unit/utilities/constants.test.ts @@ -119,7 +119,6 @@ describe('utilities/constants', () => { expect(STATUS_ICON_Mapping['1']).toBe('CheckedCircle'); expect(STATUS_ICON_Mapping['2']).toBe('SuccessInverted'); expect(STATUS_ICON_Mapping['3']).toBe('ErrorInverted'); - expect(STATUS_ICON_Mapping['5']).toBe('Link'); }); it('should export VALIDATION_DOCUMENTATION_URL', () => { From 16460ef4294319bdcadbd0494c670f0464eb3e3f Mon Sep 17 00:00:00 2001 From: Aishwarya Date: Fri, 24 Apr 2026 13:31:13 +0530 Subject: [PATCH 09/48] chore: update uuid package from version 9.0.1 to 14.0.0 across multiple package.json and package-lock.json files --- api/package-lock.json | 97 +++++++++++++++++++--- api/package.json | 3 +- upload-api/migration-aem/package-lock.json | 11 ++- upload-api/migration-aem/package.json | 3 +- 4 files changed, 91 insertions(+), 23 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index 8cd0a9007..e52bb2558 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -40,7 +40,7 @@ "p-limit": "^6.2.0", "php-serialize": "^5.1.3", "socket.io": "^4.7.5", - "uuid": "^9.0.1", + "uuid": "^14.0.0", "winston": "^3.11.0" }, "devDependencies": { @@ -54,7 +54,6 @@ "@types/lodash": "^4.17.0", "@types/node": "^20.10.4", "@types/supertest": "^6.0.3", - "@types/uuid": "^9.0.8", "@types/wordpress__block-library": "^2.6.3", "@types/wordpress__block-serialization-spec-parser": "^3.1.3", "@types/wordpress__blocks": "^12.5.18", @@ -407,6 +406,19 @@ "node": ">=16" } }, + "node_modules/@contentstack/cli-audit/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@contentstack/cli-auth": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@contentstack/cli-auth/-/cli-auth-1.8.0.tgz", @@ -648,6 +660,19 @@ "node": ">=10" } }, + "node_modules/@contentstack/cli-cm-import/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@contentstack/cli-cm-migrate-rte": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/@contentstack/cli-cm-migrate-rte/-/cli-cm-migrate-rte-1.6.4.tgz", @@ -917,6 +942,19 @@ "node": ">=12" } }, + "node_modules/@contentstack/cli-cm-migrate-rte/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@contentstack/cli-cm-migrate-rte/node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -1143,6 +1181,19 @@ "node": ">=10" } }, + "node_modules/@contentstack/cli-utilities/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@contentstack/cli-variants": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@contentstack/cli-variants/-/cli-variants-1.4.1.tgz", @@ -1169,6 +1220,19 @@ "node": ">=10" } }, + "node_modules/@contentstack/cli/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@contentstack/json-rte-serializer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@contentstack/json-rte-serializer/-/json-rte-serializer-3.0.5.tgz", @@ -4054,13 +4118,6 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/wordpress__block-library": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/@types/wordpress__block-library/-/wordpress__block-library-2.6.3.tgz", @@ -4528,6 +4585,20 @@ "react-dom": "^18.0.0" } }, + "node_modules/@wordpress/components/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/compose": { "version": "6.35.0", "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-6.35.0.tgz", @@ -17573,16 +17644,16 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/validate-npm-package-name": { diff --git a/api/package.json b/api/package.json index ce1ce4b96..b8d8fcb65 100644 --- a/api/package.json +++ b/api/package.json @@ -58,7 +58,7 @@ "p-limit": "^6.2.0", "php-serialize": "^5.1.3", "socket.io": "^4.7.5", - "uuid": "^9.0.1", + "uuid": "^14.0.0", "winston": "^3.11.0", "@emnapi/core": "1.9.1", "@emnapi/runtime" : "1.9.1", @@ -75,7 +75,6 @@ "@types/lodash": "^4.17.0", "@types/node": "^20.10.4", "@types/supertest": "^6.0.3", - "@types/uuid": "^9.0.8", "@types/wordpress__block-library": "^2.6.3", "@types/wordpress__block-serialization-spec-parser": "^3.1.3", "@types/wordpress__blocks": "^12.5.18", diff --git a/upload-api/migration-aem/package-lock.json b/upload-api/migration-aem/package-lock.json index 64234fd05..aa7d86c01 100644 --- a/upload-api/migration-aem/package-lock.json +++ b/upload-api/migration-aem/package-lock.json @@ -11,11 +11,10 @@ "dependencies": { "fs-readdir-recursive": "^1.1.0", "genson-js": "^0.0.8", - "uuid": "^9.0.1" + "uuid": "^14.0.0" }, "devDependencies": { "@types/fs-readdir-recursive": "^1.1.3", - "@types/uuid": "^9.0.8", "nodemon": "^3.1.10", "rimraf": "^6.0.1", "ts-node": "^10.9.2", @@ -775,16 +774,16 @@ "peer": true }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/upload-api/migration-aem/package.json b/upload-api/migration-aem/package.json index bab9fc6e4..14529a4d3 100644 --- a/upload-api/migration-aem/package.json +++ b/upload-api/migration-aem/package.json @@ -15,7 +15,6 @@ "license": "ISC", "devDependencies": { "@types/fs-readdir-recursive": "^1.1.3", - "@types/uuid": "^9.0.8", "nodemon": "^3.1.10", "rimraf": "^6.0.1", "ts-node": "^10.9.2", @@ -24,6 +23,6 @@ "dependencies": { "fs-readdir-recursive": "^1.1.0", "genson-js": "^0.0.8", - "uuid": "^9.0.1" + "uuid": "^14.0.0" } } \ No newline at end of file From 68c467bcb66187630abbf92bdac11334da9cdea4 Mon Sep 17 00:00:00 2001 From: Aishwarya Date: Fri, 24 Apr 2026 17:21:51 +0530 Subject: [PATCH 10/48] refactor: improve RteJsonConverter and block name resolution logic for better handling of missing and media blocks --- api/src/services/wordpress.service.ts | 42 ++++++++++++++++++++------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/api/src/services/wordpress.service.ts b/api/src/services/wordpress.service.ts index 628c980a4..e8f6debeb 100644 --- a/api/src/services/wordpress.service.ts +++ b/api/src/services/wordpress.service.ts @@ -106,7 +106,10 @@ const getFieldName = (key: string ) => { } const RteJsonConverter = (html: string) => { - const dom = new JSDOM(html); + const cleanedHtml = html + ?.replace(/]*>/g, "") + ?.replace(/<\/figure>/g, ""); + const dom = new JSDOM(cleanedHtml); const htmlDoc = dom.window.document.querySelector("body"); return htmlToJson(htmlDoc); @@ -127,13 +130,30 @@ function getLastUid(uid : string) { const resolvedBlockName = (block: any) => { - if (block?.attrs?.metadata?.name) return block?.attrs?.metadata?.name; - if (block?.blockName === WORDPRESS_MISSSING_BLOCKS) { - return block?.attrs?.originalName || 'body'; + // 1. If metadata name exists, use it first + if (block?.attrs?.metadata?.name) { + return block.attrs.metadata.name; } - if (MEDIA_BLOCK_NAMES?.includes?.(block?.blockName)) return 'media'; + + // 2. Handle missing/invalid WordPress blocks + const isMissingBlock = + block?.blockName === WORDPRESS_MISSSING_BLOCKS || + (block?.blockName === null && + block?.innerHTML !== ' '); + if (isMissingBlock) { + // fallback to originalName, otherwise use body + + return block?.attrs?.originalName ?? "body"; + } + + // 3. Handle media-related blocks + if (MEDIA_BLOCK_NAMES?.includes?.(block?.blockName)) { + return "media"; + } + + // 4. Default fallback return block?.blockName; -} +}; async function createSchema(fields: any, blockJson : any, title: string, uid: string, assetData: any, duplicateBlockMappings?: Record) { const schema : any = { @@ -360,7 +380,7 @@ function processNestedGroup(child: any, childField: any, allFields: any[]): Reco nestedChildrenObject[nestedChildKey] = [formattedNestedChild]; } } else { - nestedChildrenObject[nestedChildKey] = formattedNestedChild; + formattedNestedChild && (nestedChildrenObject[nestedChildKey] = formattedNestedChild); } } } catch (nestedError) { @@ -438,9 +458,12 @@ function formatChildByType(child: any, field: any, assetData: any) { htmlContent = collectHtmlFromInnerBlocks(child); } if (!htmlContent) { - htmlContent = child?.blockName ? child?.innerHTML : child; + htmlContent = (child?.blockName || child?.innerHTML) + ? child?.innerHTML + : child; } - formatted = RteJsonConverter(htmlContent); + const hasMeaningfulHtml = stripHtmlTags(htmlContent)?.trim()?.length > 0; + formatted = hasMeaningfulHtml && RteJsonConverter(htmlContent); break; } @@ -601,7 +624,6 @@ async function saveEntry(fields: any, entry: any, file_path: string, assetData customLogger(project?.id, destinationStackId,'info', `Processed blocks for entry ${uid}`); - // Pass individual content to createSchema entryData[uid] = await createSchema(fields, blocksJson, item?.title, uid, assetData, duplicateBlockMappings); From 4ebc6439ca78a82d76edba6ba280b146b690a85a Mon Sep 17 00:00:00 2001 From: Aishwarya Date: Mon, 27 Apr 2026 11:14:07 +0530 Subject: [PATCH 11/48] Revert "feat: add AutoMappedMergeConfirmModal component and enhance SaveChangesModal with async handling for step changes" This reverts commit fe3bfed302148f6bf9be9463442e70cebd6a9a7a. --- .../AutoMappedMergeConfirmModal/index.tsx | 43 -- .../Common/SaveChangesModal/index.tsx | 12 +- .../ContentMapper/contentMapper.interface.ts | 2 - ui/src/components/ContentMapper/index.scss | 80 +--- ui/src/components/ContentMapper/index.tsx | 429 ++---------------- .../HorizontalStepper/HorizontalStepper.tsx | 20 +- ui/src/context/app/app.interface.ts | 4 +- ui/src/pages/Migration/index.tsx | 48 +- ui/src/utilities/constants.ts | 9 +- ui/tests/unit/utilities/constants.test.ts | 1 - 10 files changed, 48 insertions(+), 600 deletions(-) delete mode 100644 ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx diff --git a/ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx b/ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx deleted file mode 100644 index f223a555f..000000000 --- a/ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - ModalBody, - ModalHeader, - ModalFooter, - ButtonGroup, - Button -} from '@contentstack/venus-components'; - -interface Props { - closeModal: () => void; - onContinue: () => void | Promise; -} - -const AutoMappedMergeConfirmModal = (props: Props) => { - return ( - <> - props.closeModal()} - /> - - All auto-mapped content types will be merged into your exisitng content types and global fields. You can cancel to stay on this step or continue to the next step. - - - - - - - - - ); -}; - -export default AutoMappedMergeConfirmModal; diff --git a/ui/src/components/Common/SaveChangesModal/index.tsx b/ui/src/components/Common/SaveChangesModal/index.tsx index 6c299da5e..67b3104d2 100644 --- a/ui/src/components/Common/SaveChangesModal/index.tsx +++ b/ui/src/components/Common/SaveChangesModal/index.tsx @@ -13,7 +13,7 @@ interface Props { otherCmsTitle?: string; saveContentType?: () => void; openContentType?: () => void; - changeStep?: () => void | Promise; + changeStep?: () => void; dropdownStateChange: () => void; } @@ -47,24 +47,24 @@ const SaveChangesModal = (props: Props) => { ))} @@ -3541,34 +3218,6 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref:
      {filteredContentTypes?.map?.((content: ContentType, index: number) => { const icon = STATUS_ICON_Mapping[content?.status] || ''; - const existingCTSide = asContentTypeListArray( - newMigrationData?.content_mapping?.existingCT - ); - const existingGlobalSide = asContentTypeListArray( - newMigrationData?.content_mapping?.existingGlobal - ); - let rowDestinationModels: ContentTypeList[] = getDestinationModelsForRow( - content, - existingCTSide, - existingGlobalSide - ); - if ( - !rowDestinationModels?.length && - contentModels?.length && - selectedContentType?.contentstackUid === content?.contentstackUid && - ((content?.type === 'content_type' && isContentType) || - (content?.type !== 'content_type' && !isContentType)) - ) { - rowDestinationModels = contentModels; - } - const showAutoMappedBadge = - !isNewStack && - isContentTypeAutoMapped( - content?.contentstackUid, - rowDestinationModels, - combinedContentTypeMapping, - uidAutoMapSuppressedForSourceUids - ); const format = (str: string) => { const frags = str?.split('_'); @@ -3606,38 +3255,14 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref:
    - - {showAutoMappedBadge && ( - - e.preventDefault()} - > - - - - )} + {icon && ( )} - + diff --git a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx index 81fe883be..809c659e5 100644 --- a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx +++ b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx @@ -44,7 +44,6 @@ export type stepperProps = { stepTitleClassName?: string; testId?: string; handleSaveCT?: () => void; - handleUpdateAutoMappedContentMapping?: () => Promise; changeDropdownState: () => void; projectData: MigrationResponse; isProjectMapped: boolean; @@ -195,26 +194,13 @@ const HorizontalStepper = forwardRef( if (newMigrationData?.content_mapping?.isDropDownChanged) { setIsModalOpen(true); return cbModal({ - component: (modalProps: ModalObj) => ( + component: (props: ModalObj) => ( { - try { - await handleUpdateAutoMappedContentMapping?.(); - } catch { - Notification({ - notificationContent: { - text: 'Could not save content type mapping. Please try again.' - }, - type: 'error' - }); - return; - } - setTabStep(idx); - }} + changeStep={() => setTabStep(idx)} dropdownStateChange={handleDropdownChange} /> ), diff --git a/ui/src/context/app/app.interface.ts b/ui/src/context/app/app.interface.ts index 48c4e6ba7..dc34b0c00 100644 --- a/ui/src/context/app/app.interface.ts +++ b/ui/src/context/app/app.interface.ts @@ -191,8 +191,8 @@ export interface IDestinationStack { csLocale: string[]; } export interface IContentMapper { - existingGlobal: ContentTypeList[]; - existingCT: ContentTypeList[]; + existingGlobal: ContentTypeList[] | (() => ContentTypeList[]); + existingCT: ContentTypeList[] | (() => ContentTypeList[]); content_type_mapping: ContentTypeMap; isDropDownChanged?: boolean; otherCmsTitle?: string; diff --git a/ui/src/pages/Migration/index.tsx b/ui/src/pages/Migration/index.tsx index 2ffc3c3bb..6e8fecda4 100644 --- a/ui/src/pages/Migration/index.tsx +++ b/ui/src/pages/Migration/index.tsx @@ -61,7 +61,6 @@ import ContentMapper from '../../components/ContentMapper'; import TestMigration from '../../components/TestMigration'; import MigrationExecution from '../../components/MigrationExecution'; import SaveChangesModal from '../../components/Common/SaveChangesModal'; -import AutoMappedMergeConfirmModal from '../../components/Common/AutoMappedMergeConfirmModal'; import { getMigratedStacks } from '../../services/api/project.service'; import { getConfig } from '../../services/api/upload.service'; import { useWarnOnRefresh } from '../../hooks/useWarnOnrefresh'; @@ -751,22 +750,6 @@ const Migration = () => { * Calls when click Continue button on Content Mapper step and handles to proceed to Test Migration */ const handleOnClickContentMapper = async (event: MouseEvent) => { - const persistAutoMappedContentMapper = async (): Promise => { - try { - await saveRef?.current?.handleUpdateAutoMappedContentMapping?.(); - return true; - } catch { - Notification({ - notificationContent: { - text: 'Could not save content type mapping. Please try again.' - }, - notificationProps: { position: 'bottom-center', hideProgressBar: true }, - type: 'error' - }); - return false; - } - }; - if (newMigrationData?.content_mapping?.isDropDownChanged) { setIsModalOpen(true); @@ -778,7 +761,6 @@ const Migration = () => { otherCmsTitle={newMigrationData?.content_mapping?.otherCmsTitle} saveContentType={saveRef?.current?.handleSaveContentType} changeStep={async () => { - if (!(await persistAutoMappedContentMapper())) return; const url = `/projects/${projectId}/migration/steps/4`; navigate(url, { replace: true }); @@ -794,35 +776,14 @@ const Migration = () => { } }); } else { - const finishContentMapperNavigation = async () => { - if (!(await persistAutoMappedContentMapper())) return; - await updateCurrentStepData(selectedOrganisation.value, projectId); + + const res = await updateCurrentStepData(selectedOrganisation.value, projectId); setIsLoading(false); - event?.preventDefault?.(); + event.preventDefault(); handleStepChange(3); const url = `/projects/${projectId}/migration/steps/4`; navigate(url, { replace: true }); - }; - - if (saveRef?.current?.shouldPromptShowAutoMappedMerge?.()) { - return cbModal({ - component: (props: ModalObj) => ( - { - props.closeModal(); - await finishContentMapperNavigation(); - }} - /> - ), - modalProps: { - size: 'xsmall', - shouldCloseOnOverlayClick: false - } - }); - } - await finishContentMapperNavigation(); } }; @@ -958,9 +919,6 @@ const Migration = () => { ref={stepperRef} steps={createStepper(projectData ?? defaultMigrationResponse, handleStepChange)} handleSaveCT={saveRef?.current?.handleSaveContentType} - handleUpdateAutoMappedContentMapping={() => - saveRef?.current?.handleUpdateAutoMappedContentMapping?.() ?? Promise.resolve() - } changeDropdownState={changeDropdownState} projectData={projectData || defaultMigrationResponse} isProjectMapped={isProjectMapper} diff --git a/ui/src/utilities/constants.ts b/ui/src/utilities/constants.ts index 6f831b92e..403f7a508 100644 --- a/ui/src/utilities/constants.ts +++ b/ui/src/utilities/constants.ts @@ -105,13 +105,14 @@ export const CONTENT_MAPPING_STATUS: ObjectType = { '1': 'Mapped', '2': 'Updated', '3': 'Failed', - '4': 'All', - '5': 'Auto-mapped', + '4': 'All' + // '4': 'Auto-Dump' }; export const STATUS_ICON_Mapping: { [key: string]: string } = { '1': 'CheckedCircle', '2': 'SuccessInverted', - '3': 'ErrorInverted', + '3': 'ErrorInverted' + // '4': 'completed' }; export const VALIDATION_DOCUMENTATION_URL: { [key: string]: string } = { @@ -198,5 +199,3 @@ export const EXECUTION_LOGS_UI_TEXT = { export const EXECUTION_LOGS_ERROR_TEXT = { ERROR: 'Error in Getting Migration Logs' } - -export const AUTO_MAPPED_PILL_ITEMS = [{ id: 'auto-mapped', text: 'Auto-mapped' }]; \ No newline at end of file diff --git a/ui/tests/unit/utilities/constants.test.ts b/ui/tests/unit/utilities/constants.test.ts index 4a77aefc7..ee2c057f0 100644 --- a/ui/tests/unit/utilities/constants.test.ts +++ b/ui/tests/unit/utilities/constants.test.ts @@ -111,7 +111,6 @@ describe('utilities/constants', () => { expect(CONTENT_MAPPING_STATUS['1']).toBe('Mapped'); expect(CONTENT_MAPPING_STATUS['2']).toBe('Updated'); expect(CONTENT_MAPPING_STATUS['3']).toBe('Failed'); - expect(CONTENT_MAPPING_STATUS['5']).toBe('Auto-mapped'); expect(CONTENT_MAPPING_STATUS['4']).toBe('All'); }); From 589f577a4477211069e1354c7390e3b433aeca47 Mon Sep 17 00:00:00 2001 From: Aishwarya Date: Mon, 27 Apr 2026 11:23:27 +0530 Subject: [PATCH 12/48] chore: update axios package from version 1.15.0 to 1.15.2 across multiple package.json and package-lock.json files --- api/package-lock.json | 5 +- api/package.json | 2 +- package-lock.json | 6 +- package.json | 4 +- ui/package-lock.json | 63 +++++++---- ui/package.json | 3 +- upload-api/package-lock.json | 210 ++++++++++++++++++++++++++++++++--- upload-api/package.json | 5 +- 8 files changed, 254 insertions(+), 44 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index e52bb2558..2d3753ff5 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -17,7 +17,7 @@ "@emnapi/runtime": "1.9.1", "@emnapi/wasi-threads": "1.2.0", "@wordpress/block-serialization-default-parser": "^5.39.0", - "axios": "^1.15.0", + "axios": "^1.15.2", "cheerio": "^1.2.0", "chokidar": "^3.6.0", "cors": "^2.8.5", @@ -8187,6 +8187,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", diff --git a/api/package.json b/api/package.json index b8d8fcb65..e1bd7a07d 100644 --- a/api/package.json +++ b/api/package.json @@ -35,7 +35,7 @@ "@contentstack/json-rte-serializer": "^3.0.5", "@contentstack/marketplace-sdk": "^1.5.0", "@wordpress/block-serialization-default-parser": "^5.39.0", - "axios": "^1.15.0", + "axios": "^1.15.2", "cheerio": "^1.2.0", "chokidar": "^3.6.0", "cors": "^2.8.5", diff --git a/package-lock.json b/package-lock.json index ec240e212..76d3ff4e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -475,9 +475,9 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", diff --git a/package.json b/package.json index 7cd8d1202..3889d5776 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,9 @@ "validate-branch-name": "^1.3.0" }, "overrides": { - "axios": ">=1.15.0", + "axios": ">=1.15.2", "nth-check": ">=2.0.1", - "postcss": ">=8.4.31", + "postcss": ">=8.5.10", "serialize-javascript": ">=6.0.2", "@babel/runtime": ">=7.26.10", "lodash": "^4.18.1", diff --git a/ui/package-lock.json b/ui/package-lock.json index c2d1d164e..26eda5fa6 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -15,7 +15,7 @@ "@types/react-dom": "^18.2.13", "@types/react-redux": "^7.1.33", "@vitejs/plugin-react-swc": "^4.2.3", - "axios": "^1.15.0", + "axios": "^1.15.2", "final-form": "^4.20.10", "html-react-parser": "^4.2.9", "jwt-decode": "^4.0.0", @@ -29,6 +29,7 @@ "sass": "^1.68.0", "socket.io-client": "^4.7.5", "typescript": "^4.9.5", + "uuid": "^14.0.0", "vite": "^7.3.2", "vite-tsconfig-paths": "^6.1.1" }, @@ -273,6 +274,15 @@ "uuid": "^8.3.2" } }, + "node_modules/@contentstack/json-rte-serializer/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@contentstack/venus-components": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@contentstack/venus-components/-/venus-components-3.0.4.tgz", @@ -463,6 +473,15 @@ "tiny-warning": "^1.0.3" } }, + "node_modules/@contentstack/venus-components/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -3193,9 +3212,9 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -4060,13 +4079,10 @@ } }, "node_modules/dompurify": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", - "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", + "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -4875,15 +4891,16 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -6827,9 +6844,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "funding": [ { "type": "opencollective", @@ -6844,6 +6861,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9037,11 +9055,16 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/velocity-animate": { diff --git a/ui/package.json b/ui/package.json index 8731dea78..47ab83777 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,7 +10,7 @@ "@types/react-dom": "^18.2.13", "@types/react-redux": "^7.1.33", "@vitejs/plugin-react-swc": "^4.2.3", - "axios": "^1.15.0", + "axios": "^1.15.2", "final-form": "^4.20.10", "html-react-parser": "^4.2.9", "jwt-decode": "^4.0.0", @@ -24,6 +24,7 @@ "sass": "^1.68.0", "socket.io-client": "^4.7.5", "typescript": "^4.9.5", + "uuid": "^14.0.0", "vite": "^7.3.2", "vite-tsconfig-paths": "^6.1.1" }, diff --git a/upload-api/package-lock.json b/upload-api/package-lock.json index ac574aa9f..b12a243c3 100644 --- a/upload-api/package-lock.json +++ b/upload-api/package-lock.json @@ -14,7 +14,7 @@ "@typescript-eslint/parser": "^8.56.1", "@wordpress/block-library": "^9.39.0", "@wordpress/blocks": "^15.12.0", - "axios": "^1.15.0", + "axios": "^1.15.2", "chalk": "^4.1.2", "cheerio": "^1.2.0", "cors": "^2.8.5", @@ -38,6 +38,7 @@ "mysql2": "^3.16.2", "php-serialize": "^5.1.3", "prettier": "^3.3.3", + "uuid": "^14.0.0", "winston": "^3.7.2", "xml2js": "^0.6.2" }, @@ -71,11 +72,10 @@ "dependencies": { "fs-readdir-recursive": "^1.1.0", "genson-js": "^0.0.8", - "uuid": "^9.0.1" + "uuid": "^14.0.0" }, "devDependencies": { "@types/fs-readdir-recursive": "^1.1.3", - "@types/uuid": "^9.0.8", "nodemon": "^3.1.10", "rimraf": "^6.0.1", "ts-node": "^10.9.2", @@ -1678,6 +1678,19 @@ "version": "1.14.1", "license": "0BSD" }, + "node_modules/@contentstack/cli-utilities/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@contentstack/management": { "version": "1.27.4", "license": "MIT", @@ -4597,11 +4610,6 @@ "version": "1.3.5", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "dev": true, - "license": "MIT" - }, "node_modules/@types/wordpress__block-library": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/@types/wordpress__block-library/-/wordpress__block-library-2.6.3.tgz", @@ -5329,6 +5337,19 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/@wordpress/block-editor/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/block-library": { "version": "9.39.0", "resolved": "https://registry.npmjs.org/@wordpress/block-library/-/block-library-9.39.0.tgz", @@ -5585,6 +5606,19 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/@wordpress/block-library/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/block-serialization-default-parser": { "version": "5.39.0", "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-5.39.0.tgz", @@ -5702,6 +5736,19 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/@wordpress/blocks/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/commands": { "version": "1.39.0", "resolved": "https://registry.npmjs.org/@wordpress/commands/-/commands-1.39.0.tgz", @@ -5924,6 +5971,19 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/@wordpress/commands/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/components": { "version": "27.6.0", "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-27.6.0.tgz", @@ -6266,6 +6326,20 @@ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true }, + "node_modules/@wordpress/components/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/compose": { "version": "7.39.0", "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-7.39.0.tgz", @@ -6414,6 +6488,19 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/@wordpress/core-data/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/data": { "version": "9.28.0", "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-9.28.0.tgz", @@ -6845,6 +6932,19 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/@wordpress/dataviews/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/date": { "version": "5.39.0", "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-5.39.0.tgz", @@ -7259,6 +7359,19 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" }, + "node_modules/@wordpress/image-cropper/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/interactivity": { "version": "6.39.0", "resolved": "https://registry.npmjs.org/@wordpress/interactivity/-/interactivity-6.39.0.tgz", @@ -7715,6 +7828,19 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/@wordpress/patterns/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/preferences": { "version": "4.39.0", "resolved": "https://registry.npmjs.org/@wordpress/preferences/-/preferences-4.39.0.tgz", @@ -7938,6 +8064,19 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/@wordpress/preferences/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/primitives": { "version": "4.39.0", "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-4.39.0.tgz", @@ -8235,6 +8374,19 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/@wordpress/reusable-blocks/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/rich-text": { "version": "7.39.0", "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.39.0.tgz", @@ -8549,6 +8701,19 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/@wordpress/server-side-render/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/shortcode": { "version": "4.39.0", "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.39.0.tgz", @@ -8790,6 +8955,19 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/@wordpress/upload-media/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@wordpress/url": { "version": "4.39.0", "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.39.0.tgz", @@ -9205,9 +9383,9 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -11241,7 +11419,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.11", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -16307,14 +16487,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "9.0.1", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/upload-api/package.json b/upload-api/package.json index 395012b7e..7b42dcea8 100644 --- a/upload-api/package.json +++ b/upload-api/package.json @@ -50,7 +50,7 @@ "@typescript-eslint/parser": "^8.56.1", "@wordpress/block-library": "^9.39.0", "@wordpress/blocks": "^15.12.0", - "axios": "^1.15.0", + "axios": "^1.15.2", "chalk": "^4.1.2", "cheerio": "^1.2.0", "cors": "^2.8.5", @@ -74,12 +74,13 @@ "mysql2": "^3.16.2", "php-serialize": "^5.1.3", "prettier": "^3.3.3", + "uuid": "^14.0.0", "winston": "^3.7.2", "xml2js": "^0.6.2" }, "overrides": { "@contentstack/cli-utilities": { - "axios": ">=1.15.0" + "axios": ">=1.15.2" }, "tmp": ">=0.2.4", "minimatch": ">=10.2.3", From 2d3b70a41f770816f9923849f42be37853e5f9af Mon Sep 17 00:00:00 2001 From: Shradha Nahar Date: Wed, 22 Apr 2026 14:46:54 +0530 Subject: [PATCH 13/48] fix(ui): avoid mapper footer overlap with smart field select menu placement --- ui/src/components/ContentMapper/index.scss | 2 +- ui/src/components/ContentMapper/index.tsx | 117 ++++++++++++++++----- 2 files changed, 91 insertions(+), 28 deletions(-) diff --git a/ui/src/components/ContentMapper/index.scss b/ui/src/components/ContentMapper/index.scss index 79eda4b80..de99218de 100644 --- a/ui/src/components/ContentMapper/index.scss +++ b/ui/src/components/ContentMapper/index.scss @@ -489,6 +489,6 @@ div .table-row { .select { .tippy-wrapper { - display: flex; + display: inline; } } \ No newline at end of file diff --git a/ui/src/components/ContentMapper/index.tsx b/ui/src/components/ContentMapper/index.tsx index 43a14153c..c0b87ee96 100644 --- a/ui/src/components/ContentMapper/index.tsx +++ b/ui/src/components/ContentMapper/index.tsx @@ -1,12 +1,13 @@ // Libraries import { + useCallback, useEffect, useState, useRef, useImperativeHandle, forwardRef, - type ComponentProps, } from 'react'; +import { flushSync } from 'react-dom'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useParams } from 'react-router-dom'; import { @@ -66,6 +67,7 @@ import { FieldHistoryObj } from './contentMapper.interface'; import { ItemStatusMapProp } from '@contentstack/venus-components/build/components/Table/types'; +import type { ISelectProps } from '@contentstack/venus-components/build/components/Select/Select.d'; import { ModalObj } from '../Modal/modal.interface'; import { UpdatedSettings } from '../AdvancePropertise/advanceProperties.interface'; @@ -86,13 +88,82 @@ import './index.scss'; import { NoDataFound, SCHEMA_PREVIEW } from '../../common/assets'; import EntryMapper from './entryMapper'; -/** Renders the menu in the document body so `menuPlacement="auto"` matches the control when inside scroll/overflow containers (e.g. InfiniteScrollTable). */ -const CONTENT_MAPPER_SELECT_MENU_PORTAL = - typeof document !== 'undefined' ? document.body : undefined; +const FIELD_MAP_MENU_VIEW_MARGIN = 8; +const FIELD_MAP_MENU_HYSTERESIS = 36; +const FIELD_MAP_MENU_BOTTOM_SLACK = 16; +const FIELD_MAP_MENU_ROW_PX = 34; +const FIELD_MAP_MENU_LIST_CHROME = 28; +const FIELD_MAP_MENU_MIN = 52; +const FIELD_MAP_MENU_MAX_ROWS = 24; +const FIELD_MAP_MENU_CAP = 283; + +function estimateFieldMapMenuHeight( + options: ISelectProps['options'], + maxMenuHeight: number | undefined +): number { + const cap = + typeof maxMenuHeight === 'number' && maxMenuHeight > 0 + ? maxMenuHeight + : FIELD_MAP_MENU_CAP; + const n = Array.isArray(options) ? options.length : 0; + const rows = Math.min(Math.max(n, 1), FIELD_MAP_MENU_MAX_ROWS); + return Math.min(cap, Math.max(FIELD_MAP_MENU_MIN, rows * FIELD_MAP_MENU_ROW_PX + FIELD_MAP_MENU_LIST_CHROME)); +} -const contentMapperSelectMenuStyles = { - menuPortal: (base: Record) => ({ ...base, zIndex: 10001 }), -}; +/** Venus Select for mapping rows: menu opens up when space below (vs `.mapper-footer`) is tight. */ +function FieldMappingSelect(props: ISelectProps) { + const { onMenuOpen, onMenuClose, maxMenuHeight, options, ...rest } = props; + const wrapRef = useRef(null); + const optionsRef = useRef(options); + const maxMenuHeightRef = useRef(maxMenuHeight); + optionsRef.current = options; + maxMenuHeightRef.current = maxMenuHeight; + const [menuPlacement, setMenuPlacement] = useState<'top' | 'bottom'>('bottom'); + + const handleMenuOpen = useCallback(() => { + const el = wrapRef.current; + if (!el) { + onMenuOpen?.(); + return; + } + const rect = el.getBoundingClientRect(); + let spaceBelow = window.innerHeight - rect.bottom - FIELD_MAP_MENU_VIEW_MARGIN; + const footer = document.querySelector('.mapper-footer'); + if (footer instanceof HTMLElement) { + spaceBelow = Math.min( + spaceBelow, + Math.max(0, footer.getBoundingClientRect().top - rect.bottom - FIELD_MAP_MENU_VIEW_MARGIN) + ); + } + const spaceAbove = rect.top - FIELD_MAP_MENU_VIEW_MARGIN; + const need = estimateFieldMapMenuHeight(optionsRef.current, maxMenuHeightRef.current); + const openTop = + spaceBelow + FIELD_MAP_MENU_BOTTOM_SLACK < need && + spaceAbove >= need && + spaceAbove > spaceBelow + FIELD_MAP_MENU_HYSTERESIS; + + flushSync(() => setMenuPlacement(openTop ? 'top' : 'bottom')); + onMenuOpen?.(); + }, [onMenuOpen]); + + const handleMenuClose = useCallback(() => { + setMenuPlacement('bottom'); + onMenuClose?.(); + }, [onMenuClose]); + + return ( +
    + handleValueChange(selectedOption, data?.uid, data?.contentstackFieldUid)} - placeholder="Select Field" - version={'v2'} - maxWidth="290px" - isClearable={false} - options={option} - menuPlacement="auto" - menuPortalTarget={CONTENT_MAPPER_SELECT_MENU_PORTAL} - styles={contentMapperSelectMenuStyles} - isDisabled={ + handleValueChange(selectedOption, data?.uid, data?.contentstackFieldUid)} + placeholder="Select Field" + version={'v2'} + maxWidth="290px" + isClearable={false} + options={option} + isDisabled={ !(data?.contentstackFieldType === 'single_line_text' || data?.contentstackFieldType === 'multi_line_text' || data?.contentstackFieldType === 'html' || data?.contentstackFieldType === 'json') || data?.otherCmsType === undefined || @@ -2451,10 +2519,8 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: position="top" disabled={!selectValueIsExistingField} > - Date: Mon, 27 Apr 2026 17:25:38 +0530 Subject: [PATCH 15/48] fix: implement asset metadata saving from index.json for iteration 1 --- api/src/services/migration.service.ts | 23 ++++++++++++++++++++++- api/src/utils/asset-update.utils.ts | 4 ++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/api/src/services/migration.service.ts b/api/src/services/migration.service.ts index aa6330086..1d4e37933 100644 --- a/api/src/services/migration.service.ts +++ b/api/src/services/migration.service.ts @@ -17,6 +17,7 @@ import { STEPPER_STEPS, CMS, GET_AUDIT_DATA, + MIGRATION_DATA_CONFIG, } from '../constants/index.js'; import { BadRequestError, @@ -49,7 +50,7 @@ import { aemService } from './aem.service.js'; import { requestWithSsoTokenRefresh } from '../utils/sso-request.utils.js'; import { utilsUpdateCli } from './updateEntryCli.service.js'; import { enrichConfigWithAssetMapping, removeEntriesFromDatabase } from '../utils/entry-update.utils.js'; -import { removeExistingAssets } from '../utils/asset-update.utils.js'; +import { removeExistingAssets, saveAssetMetadata } from '../utils/asset-update.utils.js'; /** * Creates a test stack. @@ -1085,6 +1086,26 @@ const startMigration = async (req: Request): Promise => { let configFilePath: string | null = null; let safeDeltaMigrationLogPath: string | undefined; + const assetsDir = path.join( + process.cwd(), MIGRATION_DATA_CONFIG.DATA, project?.destination_stack_id, + MIGRATION_DATA_CONFIG.ASSETS_DIR_NAME + ); + const indexPath = path.join(assetsDir, MIGRATION_DATA_CONFIG.ASSETS_SCHEMA_FILE); + let indexData: Record; + try { + const raw = fs.readFileSync(indexPath, "utf-8"); + if (!raw?.trim()){ + console.error(`Assets index.json is empty at ${indexPath}`); + return; + } + indexData = JSON.parse(raw); + } catch (error) { + console.error(`Failed to parse assets index.json at ${indexPath}:`, error instanceof Error ? error.message : String(error)); + return; + } + + saveAssetMetadata(indexData, projectId, iteration, safeDeltaMigrationLogPath); + if (iteration > 1) { const logsBase = path.resolve(process.cwd(), 'logs'); const safePid = sanitizeProjectId(projectId); diff --git a/api/src/utils/asset-update.utils.ts b/api/src/utils/asset-update.utils.ts index d1eb8e48a..59d683b3f 100644 --- a/api/src/utils/asset-update.utils.ts +++ b/api/src/utils/asset-update.utils.ts @@ -57,7 +57,7 @@ const replaceAssetRefsInObject = ( * Saves asset metadata from index.json to database/{projectId}/{iteration}/asset-metadata.json. * Used for validation in subsequent iterations. */ -const saveAssetMetadata = ( +export const saveAssetMetadata = ( indexData: Record, projectId: string, iteration: number, @@ -192,7 +192,7 @@ export const removeExistingAssets = async (projectId: string, loggerPath?: strin MIGRATION_DATA_CONFIG.ASSETS_DIR_NAME ); const indexPath = path.join(assetsDir, MIGRATION_DATA_CONFIG.ASSETS_SCHEMA_FILE); - + console.info('Index path:', indexPath); if (!fs.existsSync(indexPath)) { writeLogEntry(`Assets index.json not found at ${indexPath}, skipping.`, "removeExistingAssets", loggerPath); return; From 75a17d12139bac194c36c3914e956504d772123d Mon Sep 17 00:00:00 2001 From: Aishwarya Date: Fri, 24 Apr 2026 13:13:25 +0530 Subject: [PATCH 16/48] feat: add AutoMappedMergeConfirmModal component and enhance SaveChangesModal with async handling for step changes --- .../AutoMappedMergeConfirmModal/index.tsx | 43 ++ .../Common/SaveChangesModal/index.tsx | 12 +- .../ContentMapper/contentMapper.interface.ts | 2 + ui/src/components/ContentMapper/index.scss | 80 +++- ui/src/components/ContentMapper/index.tsx | 429 ++++++++++++++++-- .../HorizontalStepper/HorizontalStepper.tsx | 20 +- ui/src/context/app/app.interface.ts | 4 +- ui/src/pages/Migration/index.tsx | 48 +- ui/src/utilities/constants.ts | 9 +- ui/tests/unit/utilities/constants.test.ts | 2 + 10 files changed, 601 insertions(+), 48 deletions(-) create mode 100644 ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx diff --git a/ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx b/ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx new file mode 100644 index 000000000..f223a555f --- /dev/null +++ b/ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx @@ -0,0 +1,43 @@ +import { + ModalBody, + ModalHeader, + ModalFooter, + ButtonGroup, + Button +} from '@contentstack/venus-components'; + +interface Props { + closeModal: () => void; + onContinue: () => void | Promise; +} + +const AutoMappedMergeConfirmModal = (props: Props) => { + return ( + <> + props.closeModal()} + /> + + All auto-mapped content types will be merged into your exisitng content types and global fields. You can cancel to stay on this step or continue to the next step. + + + + + + + + + ); +}; + +export default AutoMappedMergeConfirmModal; diff --git a/ui/src/components/Common/SaveChangesModal/index.tsx b/ui/src/components/Common/SaveChangesModal/index.tsx index 67b3104d2..6c299da5e 100644 --- a/ui/src/components/Common/SaveChangesModal/index.tsx +++ b/ui/src/components/Common/SaveChangesModal/index.tsx @@ -13,7 +13,7 @@ interface Props { otherCmsTitle?: string; saveContentType?: () => void; openContentType?: () => void; - changeStep?: () => void; + changeStep?: () => void | Promise; dropdownStateChange: () => void; } @@ -47,24 +47,24 @@ const SaveChangesModal = (props: Props) => { ))} @@ -3304,6 +3627,34 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref:
      {filteredContentTypes?.map?.((content: ContentType, index: number) => { const icon = STATUS_ICON_Mapping[content?.status] || ''; + const existingCTSide = asContentTypeListArray( + newMigrationData?.content_mapping?.existingCT + ); + const existingGlobalSide = asContentTypeListArray( + newMigrationData?.content_mapping?.existingGlobal + ); + let rowDestinationModels: ContentTypeList[] = getDestinationModelsForRow( + content, + existingCTSide, + existingGlobalSide + ); + if ( + !rowDestinationModels?.length && + contentModels?.length && + selectedContentType?.contentstackUid === content?.contentstackUid && + ((content?.type === 'content_type' && isContentType) || + (content?.type !== 'content_type' && !isContentType)) + ) { + rowDestinationModels = contentModels; + } + const showAutoMappedBadge = + !isNewStack && + isContentTypeAutoMapped( + content?.contentstackUid, + rowDestinationModels, + combinedContentTypeMapping, + uidAutoMapSuppressedForSourceUids + ); const format = (str: string) => { const frags = str?.split('_'); @@ -3341,14 +3692,38 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref:
    - + + {showAutoMappedBadge && ( + + e.preventDefault()} + > + + + + )} {icon && ( )} - + diff --git a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx index 809c659e5..81fe883be 100644 --- a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx +++ b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx @@ -44,6 +44,7 @@ export type stepperProps = { stepTitleClassName?: string; testId?: string; handleSaveCT?: () => void; + handleUpdateAutoMappedContentMapping?: () => Promise; changeDropdownState: () => void; projectData: MigrationResponse; isProjectMapped: boolean; @@ -194,13 +195,26 @@ const HorizontalStepper = forwardRef( if (newMigrationData?.content_mapping?.isDropDownChanged) { setIsModalOpen(true); return cbModal({ - component: (props: ModalObj) => ( + component: (modalProps: ModalObj) => ( setTabStep(idx)} + changeStep={async () => { + try { + await handleUpdateAutoMappedContentMapping?.(); + } catch { + Notification({ + notificationContent: { + text: 'Could not save content type mapping. Please try again.' + }, + type: 'error' + }); + return; + } + setTabStep(idx); + }} dropdownStateChange={handleDropdownChange} /> ), diff --git a/ui/src/context/app/app.interface.ts b/ui/src/context/app/app.interface.ts index dc34b0c00..48c4e6ba7 100644 --- a/ui/src/context/app/app.interface.ts +++ b/ui/src/context/app/app.interface.ts @@ -191,8 +191,8 @@ export interface IDestinationStack { csLocale: string[]; } export interface IContentMapper { - existingGlobal: ContentTypeList[] | (() => ContentTypeList[]); - existingCT: ContentTypeList[] | (() => ContentTypeList[]); + existingGlobal: ContentTypeList[]; + existingCT: ContentTypeList[]; content_type_mapping: ContentTypeMap; isDropDownChanged?: boolean; otherCmsTitle?: string; diff --git a/ui/src/pages/Migration/index.tsx b/ui/src/pages/Migration/index.tsx index 6e8fecda4..2ffc3c3bb 100644 --- a/ui/src/pages/Migration/index.tsx +++ b/ui/src/pages/Migration/index.tsx @@ -61,6 +61,7 @@ import ContentMapper from '../../components/ContentMapper'; import TestMigration from '../../components/TestMigration'; import MigrationExecution from '../../components/MigrationExecution'; import SaveChangesModal from '../../components/Common/SaveChangesModal'; +import AutoMappedMergeConfirmModal from '../../components/Common/AutoMappedMergeConfirmModal'; import { getMigratedStacks } from '../../services/api/project.service'; import { getConfig } from '../../services/api/upload.service'; import { useWarnOnRefresh } from '../../hooks/useWarnOnrefresh'; @@ -750,6 +751,22 @@ const Migration = () => { * Calls when click Continue button on Content Mapper step and handles to proceed to Test Migration */ const handleOnClickContentMapper = async (event: MouseEvent) => { + const persistAutoMappedContentMapper = async (): Promise => { + try { + await saveRef?.current?.handleUpdateAutoMappedContentMapping?.(); + return true; + } catch { + Notification({ + notificationContent: { + text: 'Could not save content type mapping. Please try again.' + }, + notificationProps: { position: 'bottom-center', hideProgressBar: true }, + type: 'error' + }); + return false; + } + }; + if (newMigrationData?.content_mapping?.isDropDownChanged) { setIsModalOpen(true); @@ -761,6 +778,7 @@ const Migration = () => { otherCmsTitle={newMigrationData?.content_mapping?.otherCmsTitle} saveContentType={saveRef?.current?.handleSaveContentType} changeStep={async () => { + if (!(await persistAutoMappedContentMapper())) return; const url = `/projects/${projectId}/migration/steps/4`; navigate(url, { replace: true }); @@ -776,14 +794,35 @@ const Migration = () => { } }); } else { - - const res = await updateCurrentStepData(selectedOrganisation.value, projectId); + const finishContentMapperNavigation = async () => { + if (!(await persistAutoMappedContentMapper())) return; + await updateCurrentStepData(selectedOrganisation.value, projectId); setIsLoading(false); - event.preventDefault(); + event?.preventDefault?.(); handleStepChange(3); const url = `/projects/${projectId}/migration/steps/4`; navigate(url, { replace: true }); + }; + + if (saveRef?.current?.shouldPromptShowAutoMappedMerge?.()) { + return cbModal({ + component: (props: ModalObj) => ( + { + props.closeModal(); + await finishContentMapperNavigation(); + }} + /> + ), + modalProps: { + size: 'xsmall', + shouldCloseOnOverlayClick: false + } + }); + } + await finishContentMapperNavigation(); } }; @@ -919,6 +958,9 @@ const Migration = () => { ref={stepperRef} steps={createStepper(projectData ?? defaultMigrationResponse, handleStepChange)} handleSaveCT={saveRef?.current?.handleSaveContentType} + handleUpdateAutoMappedContentMapping={() => + saveRef?.current?.handleUpdateAutoMappedContentMapping?.() ?? Promise.resolve() + } changeDropdownState={changeDropdownState} projectData={projectData || defaultMigrationResponse} isProjectMapped={isProjectMapper} diff --git a/ui/src/utilities/constants.ts b/ui/src/utilities/constants.ts index 403f7a508..6f831b92e 100644 --- a/ui/src/utilities/constants.ts +++ b/ui/src/utilities/constants.ts @@ -105,14 +105,13 @@ export const CONTENT_MAPPING_STATUS: ObjectType = { '1': 'Mapped', '2': 'Updated', '3': 'Failed', - '4': 'All' - // '4': 'Auto-Dump' + '4': 'All', + '5': 'Auto-mapped', }; export const STATUS_ICON_Mapping: { [key: string]: string } = { '1': 'CheckedCircle', '2': 'SuccessInverted', - '3': 'ErrorInverted' - // '4': 'completed' + '3': 'ErrorInverted', }; export const VALIDATION_DOCUMENTATION_URL: { [key: string]: string } = { @@ -199,3 +198,5 @@ export const EXECUTION_LOGS_UI_TEXT = { export const EXECUTION_LOGS_ERROR_TEXT = { ERROR: 'Error in Getting Migration Logs' } + +export const AUTO_MAPPED_PILL_ITEMS = [{ id: 'auto-mapped', text: 'Auto-mapped' }]; \ No newline at end of file diff --git a/ui/tests/unit/utilities/constants.test.ts b/ui/tests/unit/utilities/constants.test.ts index ee2c057f0..2a15360f3 100644 --- a/ui/tests/unit/utilities/constants.test.ts +++ b/ui/tests/unit/utilities/constants.test.ts @@ -111,6 +111,7 @@ describe('utilities/constants', () => { expect(CONTENT_MAPPING_STATUS['1']).toBe('Mapped'); expect(CONTENT_MAPPING_STATUS['2']).toBe('Updated'); expect(CONTENT_MAPPING_STATUS['3']).toBe('Failed'); + expect(CONTENT_MAPPING_STATUS['5']).toBe('Auto-mapped'); expect(CONTENT_MAPPING_STATUS['4']).toBe('All'); }); @@ -118,6 +119,7 @@ describe('utilities/constants', () => { expect(STATUS_ICON_Mapping['1']).toBe('CheckedCircle'); expect(STATUS_ICON_Mapping['2']).toBe('SuccessInverted'); expect(STATUS_ICON_Mapping['3']).toBe('ErrorInverted'); + expect(STATUS_ICON_Mapping['5']).toBe('Link'); }); it('should export VALIDATION_DOCUMENTATION_URL', () => { From 674a6c88c00e870deb785527d75a8b05c98380ac Mon Sep 17 00:00:00 2001 From: Aishwarya Date: Mon, 27 Apr 2026 11:14:07 +0530 Subject: [PATCH 17/48] Revert "feat: add AutoMappedMergeConfirmModal component and enhance SaveChangesModal with async handling for step changes" This reverts commit fe3bfed302148f6bf9be9463442e70cebd6a9a7a. --- .../AutoMappedMergeConfirmModal/index.tsx | 43 -- .../Common/SaveChangesModal/index.tsx | 12 +- .../ContentMapper/contentMapper.interface.ts | 2 - ui/src/components/ContentMapper/index.scss | 80 +--- ui/src/components/ContentMapper/index.tsx | 429 ++---------------- .../HorizontalStepper/HorizontalStepper.tsx | 20 +- ui/src/context/app/app.interface.ts | 4 +- ui/src/pages/Migration/index.tsx | 48 +- ui/src/utilities/constants.ts | 9 +- ui/tests/unit/utilities/constants.test.ts | 1 - 10 files changed, 48 insertions(+), 600 deletions(-) delete mode 100644 ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx diff --git a/ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx b/ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx deleted file mode 100644 index f223a555f..000000000 --- a/ui/src/components/Common/AutoMappedMergeConfirmModal/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - ModalBody, - ModalHeader, - ModalFooter, - ButtonGroup, - Button -} from '@contentstack/venus-components'; - -interface Props { - closeModal: () => void; - onContinue: () => void | Promise; -} - -const AutoMappedMergeConfirmModal = (props: Props) => { - return ( - <> - props.closeModal()} - /> - - All auto-mapped content types will be merged into your exisitng content types and global fields. You can cancel to stay on this step or continue to the next step. - - - - - - - - - ); -}; - -export default AutoMappedMergeConfirmModal; diff --git a/ui/src/components/Common/SaveChangesModal/index.tsx b/ui/src/components/Common/SaveChangesModal/index.tsx index 6c299da5e..67b3104d2 100644 --- a/ui/src/components/Common/SaveChangesModal/index.tsx +++ b/ui/src/components/Common/SaveChangesModal/index.tsx @@ -13,7 +13,7 @@ interface Props { otherCmsTitle?: string; saveContentType?: () => void; openContentType?: () => void; - changeStep?: () => void | Promise; + changeStep?: () => void; dropdownStateChange: () => void; } @@ -47,24 +47,24 @@ const SaveChangesModal = (props: Props) => { ))} @@ -3627,34 +3304,6 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref:
      {filteredContentTypes?.map?.((content: ContentType, index: number) => { const icon = STATUS_ICON_Mapping[content?.status] || ''; - const existingCTSide = asContentTypeListArray( - newMigrationData?.content_mapping?.existingCT - ); - const existingGlobalSide = asContentTypeListArray( - newMigrationData?.content_mapping?.existingGlobal - ); - let rowDestinationModels: ContentTypeList[] = getDestinationModelsForRow( - content, - existingCTSide, - existingGlobalSide - ); - if ( - !rowDestinationModels?.length && - contentModels?.length && - selectedContentType?.contentstackUid === content?.contentstackUid && - ((content?.type === 'content_type' && isContentType) || - (content?.type !== 'content_type' && !isContentType)) - ) { - rowDestinationModels = contentModels; - } - const showAutoMappedBadge = - !isNewStack && - isContentTypeAutoMapped( - content?.contentstackUid, - rowDestinationModels, - combinedContentTypeMapping, - uidAutoMapSuppressedForSourceUids - ); const format = (str: string) => { const frags = str?.split('_'); @@ -3692,38 +3341,14 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref:
    - - {showAutoMappedBadge && ( - - e.preventDefault()} - > - - - - )} + {icon && ( )} - + diff --git a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx index 81fe883be..809c659e5 100644 --- a/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx +++ b/ui/src/components/Stepper/HorizontalStepper/HorizontalStepper.tsx @@ -44,7 +44,6 @@ export type stepperProps = { stepTitleClassName?: string; testId?: string; handleSaveCT?: () => void; - handleUpdateAutoMappedContentMapping?: () => Promise; changeDropdownState: () => void; projectData: MigrationResponse; isProjectMapped: boolean; @@ -195,26 +194,13 @@ const HorizontalStepper = forwardRef( if (newMigrationData?.content_mapping?.isDropDownChanged) { setIsModalOpen(true); return cbModal({ - component: (modalProps: ModalObj) => ( + component: (props: ModalObj) => ( { - try { - await handleUpdateAutoMappedContentMapping?.(); - } catch { - Notification({ - notificationContent: { - text: 'Could not save content type mapping. Please try again.' - }, - type: 'error' - }); - return; - } - setTabStep(idx); - }} + changeStep={() => setTabStep(idx)} dropdownStateChange={handleDropdownChange} /> ), diff --git a/ui/src/context/app/app.interface.ts b/ui/src/context/app/app.interface.ts index 48c4e6ba7..dc34b0c00 100644 --- a/ui/src/context/app/app.interface.ts +++ b/ui/src/context/app/app.interface.ts @@ -191,8 +191,8 @@ export interface IDestinationStack { csLocale: string[]; } export interface IContentMapper { - existingGlobal: ContentTypeList[]; - existingCT: ContentTypeList[]; + existingGlobal: ContentTypeList[] | (() => ContentTypeList[]); + existingCT: ContentTypeList[] | (() => ContentTypeList[]); content_type_mapping: ContentTypeMap; isDropDownChanged?: boolean; otherCmsTitle?: string; diff --git a/ui/src/pages/Migration/index.tsx b/ui/src/pages/Migration/index.tsx index 2ffc3c3bb..6e8fecda4 100644 --- a/ui/src/pages/Migration/index.tsx +++ b/ui/src/pages/Migration/index.tsx @@ -61,7 +61,6 @@ import ContentMapper from '../../components/ContentMapper'; import TestMigration from '../../components/TestMigration'; import MigrationExecution from '../../components/MigrationExecution'; import SaveChangesModal from '../../components/Common/SaveChangesModal'; -import AutoMappedMergeConfirmModal from '../../components/Common/AutoMappedMergeConfirmModal'; import { getMigratedStacks } from '../../services/api/project.service'; import { getConfig } from '../../services/api/upload.service'; import { useWarnOnRefresh } from '../../hooks/useWarnOnrefresh'; @@ -751,22 +750,6 @@ const Migration = () => { * Calls when click Continue button on Content Mapper step and handles to proceed to Test Migration */ const handleOnClickContentMapper = async (event: MouseEvent) => { - const persistAutoMappedContentMapper = async (): Promise => { - try { - await saveRef?.current?.handleUpdateAutoMappedContentMapping?.(); - return true; - } catch { - Notification({ - notificationContent: { - text: 'Could not save content type mapping. Please try again.' - }, - notificationProps: { position: 'bottom-center', hideProgressBar: true }, - type: 'error' - }); - return false; - } - }; - if (newMigrationData?.content_mapping?.isDropDownChanged) { setIsModalOpen(true); @@ -778,7 +761,6 @@ const Migration = () => { otherCmsTitle={newMigrationData?.content_mapping?.otherCmsTitle} saveContentType={saveRef?.current?.handleSaveContentType} changeStep={async () => { - if (!(await persistAutoMappedContentMapper())) return; const url = `/projects/${projectId}/migration/steps/4`; navigate(url, { replace: true }); @@ -794,35 +776,14 @@ const Migration = () => { } }); } else { - const finishContentMapperNavigation = async () => { - if (!(await persistAutoMappedContentMapper())) return; - await updateCurrentStepData(selectedOrganisation.value, projectId); + + const res = await updateCurrentStepData(selectedOrganisation.value, projectId); setIsLoading(false); - event?.preventDefault?.(); + event.preventDefault(); handleStepChange(3); const url = `/projects/${projectId}/migration/steps/4`; navigate(url, { replace: true }); - }; - - if (saveRef?.current?.shouldPromptShowAutoMappedMerge?.()) { - return cbModal({ - component: (props: ModalObj) => ( - { - props.closeModal(); - await finishContentMapperNavigation(); - }} - /> - ), - modalProps: { - size: 'xsmall', - shouldCloseOnOverlayClick: false - } - }); - } - await finishContentMapperNavigation(); } }; @@ -958,9 +919,6 @@ const Migration = () => { ref={stepperRef} steps={createStepper(projectData ?? defaultMigrationResponse, handleStepChange)} handleSaveCT={saveRef?.current?.handleSaveContentType} - handleUpdateAutoMappedContentMapping={() => - saveRef?.current?.handleUpdateAutoMappedContentMapping?.() ?? Promise.resolve() - } changeDropdownState={changeDropdownState} projectData={projectData || defaultMigrationResponse} isProjectMapped={isProjectMapper} diff --git a/ui/src/utilities/constants.ts b/ui/src/utilities/constants.ts index 6f831b92e..403f7a508 100644 --- a/ui/src/utilities/constants.ts +++ b/ui/src/utilities/constants.ts @@ -105,13 +105,14 @@ export const CONTENT_MAPPING_STATUS: ObjectType = { '1': 'Mapped', '2': 'Updated', '3': 'Failed', - '4': 'All', - '5': 'Auto-mapped', + '4': 'All' + // '4': 'Auto-Dump' }; export const STATUS_ICON_Mapping: { [key: string]: string } = { '1': 'CheckedCircle', '2': 'SuccessInverted', - '3': 'ErrorInverted', + '3': 'ErrorInverted' + // '4': 'completed' }; export const VALIDATION_DOCUMENTATION_URL: { [key: string]: string } = { @@ -198,5 +199,3 @@ export const EXECUTION_LOGS_UI_TEXT = { export const EXECUTION_LOGS_ERROR_TEXT = { ERROR: 'Error in Getting Migration Logs' } - -export const AUTO_MAPPED_PILL_ITEMS = [{ id: 'auto-mapped', text: 'Auto-mapped' }]; \ No newline at end of file diff --git a/ui/tests/unit/utilities/constants.test.ts b/ui/tests/unit/utilities/constants.test.ts index 2a15360f3..f2673767d 100644 --- a/ui/tests/unit/utilities/constants.test.ts +++ b/ui/tests/unit/utilities/constants.test.ts @@ -111,7 +111,6 @@ describe('utilities/constants', () => { expect(CONTENT_MAPPING_STATUS['1']).toBe('Mapped'); expect(CONTENT_MAPPING_STATUS['2']).toBe('Updated'); expect(CONTENT_MAPPING_STATUS['3']).toBe('Failed'); - expect(CONTENT_MAPPING_STATUS['5']).toBe('Auto-mapped'); expect(CONTENT_MAPPING_STATUS['4']).toBe('All'); }); From e4dd7931420376f07be069c515b4d506cb0c4236 Mon Sep 17 00:00:00 2001 From: Shradha Nahar Date: Wed, 22 Apr 2026 14:46:54 +0530 Subject: [PATCH 18/48] fix(ui): avoid mapper footer overlap with smart field select menu placement --- ui/src/components/ContentMapper/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/components/ContentMapper/index.tsx b/ui/src/components/ContentMapper/index.tsx index 1b42e6bf9..ad9c6dac0 100644 --- a/ui/src/components/ContentMapper/index.tsx +++ b/ui/src/components/ContentMapper/index.tsx @@ -7,6 +7,7 @@ import { useImperativeHandle, forwardRef, } from 'react'; +import { flushSync } from 'react-dom'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useParams } from 'react-router-dom'; import { From 6fcbe6321d309207f89d9af6aa3538c77edcd6dc Mon Sep 17 00:00:00 2001 From: shobhit-cstk Date: Tue, 21 Apr 2026 13:00:58 +0530 Subject: [PATCH 19/48] feat(contentful): taxonomy migration, locale resolution, tests and config - Extract and map Contentful taxonomies from export; upload-api and mapper integration - Contentful service: field/widget helpers, taxonomy metadata locale resolution (mapper + sys.locale) - API: Vitest thresholds; migration and user unit tests (SSO, createTaxonomy mocks) - app.json placeholder updates and related fixes --- api/package.json | 8 +- api/src/services/contentMapper.service.ts | 48 +++ api/src/services/contentful.service.ts | 128 ++++++- .../services/contentful/taxonomy.service.ts | 237 ++++++++++++ api/src/services/migration.service.ts | 10 + api/src/utils/content-type-creator.utils.ts | 161 ++++++-- .../unit/services/migration.service.test.ts | 1 + api/tests/unit/services/user.service.test.ts | 129 ++++++- api/vitest.config.ts | 6 +- app.json | 28 +- ui/src/components/ContentMapper/index.tsx | 346 +++++++++--------- upload-api/migration-aem/package-lock.json | 14 +- upload-api/migration-contentful/index.js | 3 + .../libs/createInitialMapper.js | 63 +++- .../libs/extractTaxonomy.js | 59 +++ upload-api/src/services/contentful/index.ts | 33 +- 16 files changed, 1024 insertions(+), 250 deletions(-) create mode 100644 api/src/services/contentful/taxonomy.service.ts create mode 100644 upload-api/migration-contentful/libs/extractTaxonomy.js diff --git a/api/package.json b/api/package.json index e1bd7a07d..eac27a9b0 100644 --- a/api/package.json +++ b/api/package.json @@ -34,6 +34,9 @@ "@contentstack/cli-utilities": "^1.18.1", "@contentstack/json-rte-serializer": "^3.0.5", "@contentstack/marketplace-sdk": "^1.5.0", + "@emnapi/core": "1.9.1", + "@emnapi/runtime": "1.9.1", + "@emnapi/wasi-threads": "1.2.0", "@wordpress/block-serialization-default-parser": "^5.39.0", "axios": "^1.15.2", "cheerio": "^1.2.0", @@ -59,10 +62,7 @@ "php-serialize": "^5.1.3", "socket.io": "^4.7.5", "uuid": "^14.0.0", - "winston": "^3.11.0", - "@emnapi/core": "1.9.1", - "@emnapi/runtime" : "1.9.1", - "@emnapi/wasi-threads": "1.2.0" + "winston": "^3.11.0" }, "devDependencies": { "@types/cors": "^2.8.17", diff --git a/api/src/services/contentMapper.service.ts b/api/src/services/contentMapper.service.ts index 5a9ee676c..72300688b 100644 --- a/api/src/services/contentMapper.service.ts +++ b/api/src/services/contentMapper.service.ts @@ -1674,6 +1674,54 @@ const getExistingTaxonomies = async (req: Request) => { ); } } + + // Path 3: Contentful export validation (upload-api contentfulMigrationData) + if (sourceTaxonomies.length === 0) { + try { + const contentfulTaxonomyPath = path.join( + process.cwd(), + '..', + 'upload-api', + 'contentfulMigrationData', + 'taxonomySchema', + 'taxonomySchema.json', + ); + const resolvedCf = path.resolve(contentfulTaxonomyPath); + if ( + resolvedCf.includes('upload-api') && + resolvedCf.includes('contentfulMigrationData') + ) { + const stats = await fs.promises + .lstat(resolvedCf) + .catch(() => null); + if (stats && stats.isFile() && !stats.isSymbolicLink()) { + const taxonomyData = await fs.promises.readFile( + resolvedCf, + 'utf8', + ); + const taxonomiesArray = JSON.parse(taxonomyData); + const cfTaxonomies = ( + Array.isArray(taxonomiesArray) + ? taxonomiesArray + : Object.values(taxonomiesArray) + ).map((taxonomy: any) => ({ + uid: taxonomy.uid || '', + name: taxonomy.name || taxonomy.uid || '', + description: taxonomy.description || '', + source: 'source_cms', + })); + sourceTaxonomies.push(...cfTaxonomies); + logger.info( + `Found ${cfTaxonomies.length} taxonomies from upload-api contentfulMigrationData`, + ); + } + } + } catch (cfTaxError: any) { + logger.warn( + `Could not read Contentful taxonomies from upload-api: ${cfTaxError.message}`, + ); + } + } } // Step 2: Get destination taxonomies from Contentstack (if stack exists and token_payload is available) diff --git a/api/src/services/contentful.service.ts b/api/src/services/contentful.service.ts index 709d27c4b..61edc28a8 100644 --- a/api/src/services/contentful.service.ts +++ b/api/src/services/contentful.service.ts @@ -10,6 +10,11 @@ import { jsonToHtml, jsonToMarkdown, htmlToJson } from '@contentstack/json-rte-s import { CHUNK_SIZE, LOCALE_MAPPER, MIGRATION_DATA_CONFIG } from "../constants/index.js"; import { Locale } from "../models/types.js"; import jsonRTE from "./contentful/jsonRTE.js"; +import { + buildContentfulTaxonomyAssignments, + contentfulSchemeIdToStackTaxonomyUid, + createTaxonomy as createContentfulTaxonomyFromExport, +} from "./contentful/taxonomy.service.js"; import { getAllLocales, getLogMessage } from "../utils/index.js"; import customLogger from "../utils/custom-logger.utils.js"; @@ -99,6 +104,52 @@ const mapLocales = ({ masterLocale, locale, locales, isNull = false }: any) => { } } +/** + * When an entry has `metadata.concepts` but no field locales, choose Contentful locale key(s) + * that align with the project/package locale mapper so `mapLocales` can resolve them later. + */ +function pickContentfulLocaleFromMasterLocaleMap(master: unknown): string | undefined { + if (!master || typeof master !== 'object' || Array.isArray(master)) return undefined; + const m = master as Record; + const keys = Object.keys(m); + if (!keys.length) return undefined; + for (const k of keys) { + if (k.includes('-')) return k; + } + for (const v of Object.values(m)) { + if (typeof v === 'string' && v.includes('-')) return v; + } + return keys[0]; +} + +function resolveLocalesForTaxonomyMetadata( + entryLocaleKeys: Set, + entryDataBranch: Record | undefined, + localeMapper: Record, + entrySysLocale?: string, +): string[] { + const fromFields = [...entryLocaleKeys]; + if (fromFields.length) return fromFields; + + if (entrySysLocale && typeof entrySysLocale === 'string') { + return [entrySysLocale]; + } + + const fromExisting = Object.keys(entryDataBranch || {}); + if (fromExisting.length) return fromExisting; + + const fromProjectMaster = pickContentfulLocaleFromMasterLocaleMap(localeMapper?.masterLocale); + if (fromProjectMaster) return [fromProjectMaster]; + + const fromDefaultMaster = pickContentfulLocaleFromMasterLocaleMap(LOCALE_MAPPER?.masterLocale); + if (fromDefaultMaster) return [fromDefaultMaster]; + + const otherKeys = Object.keys(localeMapper || {}).filter((k) => k !== 'masterLocale'); + if (otherKeys.length) return [otherKeys[0]]; + + return ['en-US']; +} + function resolveEntryFieldKey(entry: Record, baseKey: string): string | undefined { if (baseKey in entry) return baseKey; const snake = baseKey.replace(/([A-Z])/g, (m) => `_${m.toLowerCase()}`); @@ -106,6 +157,20 @@ function resolveEntryFieldKey(entry: Record, baseKey: string): return undefined; } +/** Allowed taxonomy scheme UIDs from Contentful export content type `metadata.taxonomy` (sanitized for Contentstack). */ +function getAllowedTaxonomySchemesFromExportContentType( + contentTypesFromPackage: any[] | undefined, + contentTypeId: string, +): string[] { + if (!contentTypesFromPackage?.length) return []; + const ctDef = contentTypesFromPackage.find((c: any) => c?.sys?.id === contentTypeId); + const links = ctDef?.metadata?.taxonomy; + if (!Array.isArray(links)) return []; + return links + .map((l: any) => contentfulSchemeIdToStackTaxonomyUid(l?.sys?.id)) + .filter(Boolean); +} + /** * Maps Contentful content type id → field id → whether that field is localized in the export schema. * Used so we only fan out values for fields with `localized: false`, not for localized fields that @@ -150,6 +215,7 @@ function inferContentfulDefaultWidgetId(fieldType: string | undefined): string | return undefined; } } + function getContentfulFieldFromPackage( contentTypesFromPackage: any[] | undefined, ctId: string, @@ -158,6 +224,7 @@ function getContentfulFieldFromPackage( const ct = contentTypesFromPackage?.find((c: any) => c?.sys?.id === ctId); return ct?.fields?.find((f: any) => f?.id === fieldId); } + /** * Picks one fieldMapping row when several share the same `uid` (e.g. bootstrap `title`/`url` rows * from createInitialMapper plus the real Contentful field). Mapper `otherCmsType` is Contentful @@ -900,18 +967,20 @@ const createEntry = async (packagePath: any, destination_stack_id: string, proje { sys: { id, + locale: entrySysLocale, contentType: { sys: { id: name }, }, environment: { sys: { id: environment_id = "" } = {} } = {}, }, fields, + metadata, }: any ) => { entryData[name] ??= {}; + const currentCT = contentTypes?.find((ct: any) => ct?.otherCmsUid === name); - Object.entries(fields).forEach(([key, value]) => { - const currentCT = contentTypes?.find((ct: any) => ct?.otherCmsUid === name); + Object.entries(fields || {}).forEach(([key, value]) => { const locales: string[] = []; Object.entries(value as object).forEach(([lang, langValue]) => { entryData[name][lang] ??= {}; @@ -923,7 +992,6 @@ const createEntry = async (packagePath: any, destination_stack_id: string, proje name, key ); - const newId = fieldData?.contentstackFieldUid ?? `${key}`?.replace?.(/[^a-zA-Z0-9]+/g, "_"); entryData[name][lang][id][newId] = processField( langValue, @@ -934,6 +1002,7 @@ const createEntry = async (packagePath: any, destination_stack_id: string, proje fieldData ); }); + const pathName = getDisplayName(name, displayField); locales.forEach((locale) => { const localeCode = mapLocales({ masterLocale: master_locale, locale, locales: LocaleMapper }); @@ -975,16 +1044,15 @@ const createEntry = async (packagePath: any, destination_stack_id: string, proje // Do not infer non-localized-ness from a single locale key — localized fields can legitimately // have only one locale when translations are missing. const entryLocaleKeys = new Set(); - for (const [, v] of Object?.entries?.(fields)) { + for (const [, v] of Object?.entries?.(fields || {})) { for (const lang of Object.keys(v as object)) { entryLocaleKeys.add(lang); } } - const ct = contentTypes?.find((c: any) => c?.otherCmsUid === name); - for (const [key, value] of Object?.entries?.(fields)) { + for (const [key, value] of Object?.entries?.(fields || {})) { const langs = Object?.keys(value as object); if (langs?.length !== 1) continue; - const fd = resolveFieldMappingRow(ct?.fieldMapping, content, name, key); + const fd = resolveFieldMappingRow(currentCT?.fieldMapping, content, name, key); const localizedInCf = cfFieldLocalizedByCt.get(name)?.get(key); const explicitlyNonLocalized = localizedInCf === false || @@ -1007,6 +1075,51 @@ const createEntry = async (packagePath: any, destination_stack_id: string, proje } } + const metaTaxField = currentCT?.fieldMapping?.find( + (f: any) => + f?.otherCmsType === 'TaxonomyMetadata' || + f?.contentstackFieldType === 'taxonomy' || + f?.contentstackFieldUid === 'taxonomies' || + f?.contentstackFieldUid === 'metadata_taxonomies', + ); + let allowedFromMapper: string[] = []; + if (metaTaxField) { + const taxonomiesConfig = + metaTaxField?.advanced?.taxonomies || metaTaxField?.taxonomies || []; + allowedFromMapper = taxonomiesConfig + .map((t: any) => (typeof t === 'string' ? t : t?.taxonomy_uid)) + .filter(Boolean) + .map((uid: string) => contentfulSchemeIdToStackTaxonomyUid(uid)) + .filter(Boolean); + } + const allowedFromExport = getAllowedTaxonomySchemesFromExportContentType( + content, + name, + ); + const allowedSchemes = + allowedFromMapper.length > 0 ? allowedFromMapper : allowedFromExport; + + if (metadata?.concepts?.length) { + const taxValue = buildContentfulTaxonomyAssignments( + metadata.concepts, + allowedSchemes, + ); + if (taxValue.length) { + const fieldKey = metaTaxField?.contentstackFieldUid || 'taxonomies'; + const localesForTax = resolveLocalesForTaxonomyMetadata( + entryLocaleKeys, + entryData[name], + LocaleMapper, + entrySysLocale, + ); + for (const loc of localesForTax) { + entryData[name][loc] ??= {}; + entryData[name][loc][id] ??= {}; + entryData[name][loc][id][fieldKey] = taxValue; + } + } + } + return entryData; }, {} @@ -1546,4 +1659,5 @@ export const contentfulService = { createRefrence, createWebhooks, createVersionFile, + createTaxonomy: createContentfulTaxonomyFromExport, }; diff --git a/api/src/services/contentful/taxonomy.service.ts b/api/src/services/contentful/taxonomy.service.ts new file mode 100644 index 000000000..e93a0f855 --- /dev/null +++ b/api/src/services/contentful/taxonomy.service.ts @@ -0,0 +1,237 @@ +import fs from 'fs'; +import path from 'path'; +import { getLogMessage } from '../../utils/index.js'; +import customLogger from '../../utils/custom-logger.utils.js'; +import { MIGRATION_DATA_CONFIG } from '../../constants/index.js'; + +const { DATA, TAXONOMIES_DIR_NAME, TAXONOMIES_FILE_NAME } = MIGRATION_DATA_CONFIG; + +/** + * Contentful export uses scheme ids like `productCategory`. Contentstack taxonomy UIDs must be + * lowercase alphanumeric + underscores only (no camelCase). + */ +export function contentfulSchemeIdToStackTaxonomyUid(contentfulSchemeId: string): string { + if (!contentfulSchemeId || typeof contentfulSchemeId !== 'string') return ''; + return contentfulSchemeId + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); +} + +/** Maps Contentful concept id prefix (before first "-") to Contentstack taxonomy uid (sanitized). */ +export const CONCEPT_PREFIX_TO_SCHEME: Record = { + brd: 'brand', + cat: 'product_category', + branch: 'branch', + dis: 'discipline', +}; + +export function inferSchemeFromConceptId(conceptId: string): string | null { + if (!conceptId || typeof conceptId !== 'string') return null; + const prefix = conceptId.split('-')[0]; + return CONCEPT_PREFIX_TO_SCHEME[prefix] ?? null; +} + +export function sanitizeTermUid(conceptId: string): string { + return conceptId + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); +} + +function humanizeSchemeId(id: string): string { + if (!id) return ''; + const words = id.split('_').filter(Boolean); + return words + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(' '); +} + +export function buildContentfulTaxonomyAssignments( + concepts: Array<{ sys?: { id?: string } }> | undefined, + allowedSchemeIds: string[], +): Array<{ taxonomy_uid: string; term_uid: string }> { + const allow = (allowedSchemeIds || []).filter(Boolean); + const allowSet = new Set( + allow.map((id) => contentfulSchemeIdToStackTaxonomyUid(id)).filter(Boolean), + ); + const useAllow = allowSet.size > 0; + const out: Array<{ taxonomy_uid: string; term_uid: string }> = []; + const seen = new Set(); + + for (const c of concepts || []) { + const id = c?.sys?.id; + if (!id) continue; + const scheme = inferSchemeFromConceptId(id); + if (!scheme) continue; + if (useAllow && !allowSet.has(scheme)) continue; + const termUid = sanitizeTermUid(id); + const key = `${scheme}::${termUid}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ taxonomy_uid: scheme, term_uid: termUid }); + } + return out; +} + +interface TaxonomyTerm { + uid: string; + name: string; + parent_uid: string | null; + description?: string; + contentful_concept_id: string; +} + +interface TaxonomyStructure { + taxonomy: { + uid: string; + name: string; + description: string; + }; + terms: TaxonomyTerm[]; +} + +const saveTaxonomyFiles = async ( + taxonomies: Record, + taxonomiesPath: string, + projectId: string, + destination_stack_id: string, +): Promise => { + for (const [schemeUid, taxonomy] of Object.entries(taxonomies)) { + const filePath = path.join(taxonomiesPath, `${schemeUid}.json`); + await fs.promises.writeFile(filePath, JSON.stringify(taxonomy, null, 2), 'utf8'); + const message = getLogMessage( + 'saveTaxonomyFiles', + `Saved taxonomy file: ${schemeUid}.json with ${taxonomy.terms.length} terms.`, + {}, + ); + await customLogger(projectId, destination_stack_id, 'info', message); + } + + const taxonomiesDataObject: Record = {}; + for (const [schemeUid, taxonomy] of Object.entries(taxonomies)) { + taxonomiesDataObject[schemeUid] = { + uid: taxonomy.taxonomy.uid, + name: taxonomy.taxonomy.name, + description: taxonomy.taxonomy.description, + }; + } + + const taxonomiesFilePath = path.join(taxonomiesPath, TAXONOMIES_FILE_NAME); + await fs.promises.writeFile( + taxonomiesFilePath, + JSON.stringify(taxonomiesDataObject, null, 2), + 'utf8', + ); + await customLogger( + projectId, + destination_stack_id, + 'info', + getLogMessage( + 'saveTaxonomyFiles', + `Saved consolidated ${TAXONOMIES_FILE_NAME} with ${Object.keys(taxonomiesDataObject).length} taxonomies.`, + {}, + ), + ); +}; + +/** + * Builds taxonomy vocabularies and terms from a Contentful export JSON (metadata.taxonomy on content types, + * metadata.concepts on entries) and writes the same layout as Drupal: per-scheme JSON + taxonomies.json. + */ +export const createTaxonomy = async ( + packagePath: string, + destination_stack_id: string, + projectId: string, +): Promise => { + const taxonomiesPath = path.join(DATA, destination_stack_id, TAXONOMIES_DIR_NAME); + + try { + await fs.promises.mkdir(taxonomiesPath, { recursive: true }); + const raw = await fs.promises.readFile(packagePath, 'utf8'); + const data = JSON.parse(raw); + const contentTypes = data?.contentTypes || []; + const entries = data?.entries || []; + + const schemeIds = new Set(); + for (const ct of contentTypes) { + for (const link of ct?.metadata?.taxonomy || []) { + const sid = link?.sys?.id; + if (sid) schemeIds.add(contentfulSchemeIdToStackTaxonomyUid(sid)); + } + } + + const termsByScheme: Record> = {}; + for (const sid of schemeIds) { + termsByScheme[sid] = new Map(); + } + + for (const entry of entries) { + for (const c of entry?.metadata?.concepts || []) { + const conceptId = c?.sys?.id; + if (!conceptId) continue; + const scheme = inferSchemeFromConceptId(conceptId); + if (!scheme || !termsByScheme[scheme]) continue; + const termUid = sanitizeTermUid(conceptId); + if (!termsByScheme[scheme].has(termUid)) { + termsByScheme[scheme].set(termUid, conceptId); + } + } + } + + const taxonomies: Record = {}; + + for (const schemeUid of schemeIds) { + const termMap = termsByScheme[schemeUid]; + const terms: TaxonomyTerm[] = []; + for (const [termUid, conceptId] of termMap) { + terms.push({ + uid: termUid, + name: conceptId, + parent_uid: null, + description: '', + contentful_concept_id: conceptId, + }); + } + taxonomies[schemeUid] = { + taxonomy: { + uid: schemeUid, + name: humanizeSchemeId(schemeUid) || schemeUid, + description: 'Imported from Contentful taxonomy', + }, + terms, + }; + } + + if (Object.keys(taxonomies).length === 0) { + const message = getLogMessage( + 'createTaxonomy', + 'No Contentful taxonomy schemes found on content types (metadata.taxonomy). Skipping taxonomy files.', + {}, + ); + await customLogger(projectId, destination_stack_id, 'info', message); + return; + } + + await saveTaxonomyFiles(taxonomies, taxonomiesPath, projectId, destination_stack_id); + + const successMessage = getLogMessage( + 'createTaxonomy', + `Exported ${Object.keys(taxonomies).length} Contentful taxonomies.`, + {}, + ); + await customLogger(projectId, destination_stack_id, 'info', successMessage); + } catch (err) { + const message = getLogMessage( + 'createTaxonomy', + 'Error encountered while creating taxonomies from Contentful export.', + {}, + err, + ); + await customLogger(projectId, destination_stack_id, 'error', message); + throw err; + } +}; diff --git a/api/src/services/migration.service.ts b/api/src/services/migration.service.ts index 1d4e37933..dac46a253 100644 --- a/api/src/services/migration.service.ts +++ b/api/src/services/migration.service.ts @@ -533,6 +533,11 @@ const startTestMigration = async (req: Request): Promise => { projectId, true ); + await contentfulService?.createTaxonomy( + cleanLocalPath, + project?.current_test_stack_id, + projectId, + ); await contentfulService?.createEntry( cleanLocalPath, project?.current_test_stack_id, @@ -951,6 +956,11 @@ const startMigration = async (req: Request): Promise => { project?.destination_stack_id, projectId ); + await contentfulService?.createTaxonomy( + cleanLocalPath, + project?.destination_stack_id, + projectId, + ); await contentfulService?.createEntry( cleanLocalPath, project?.destination_stack_id, diff --git a/api/src/utils/content-type-creator.utils.ts b/api/src/utils/content-type-creator.utils.ts index ee3028390..9c0bc83c5 100644 --- a/api/src/utils/content-type-creator.utils.ts +++ b/api/src/utils/content-type-creator.utils.ts @@ -8,7 +8,7 @@ import customLogger from './custom-logger.utils.js'; import { getLogMessage } from './index.js'; import { LIST_EXTENSION_UID, MIGRATION_DATA_CONFIG } from '../constants/index.js'; import { contentMapperService } from "../services/contentMapper.service.js"; -import appMeta from '../constants/app/index.json' with { type: 'json' }; +import appMeta from '../constants/app/index.json'; const { GLOBAL_FIELDS_FILE_NAME, @@ -39,6 +39,17 @@ interface ContentType { const RESERVED_UIDS = new Set(['locale', 'publish_details', 'tags']); +/** Contentful taxonomy scheme ids may be camelCase, Contentstack requires [a-z0-9_]. */ +function normalizeStackTaxonomyUid(raw?: string): string { + if (!raw || typeof raw !== 'string') return ''; + return raw + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); +} + function sanitizeUid(uid?: string) { if (!uid) return uid; let out = uid?.replace?.(/[^a-zA-Z0-9_]/g, '_').replace?.(/^_+/, ''); @@ -791,17 +802,19 @@ export const convertToSchemaFormate = ({ field, advanced = false, marketPlacePat const taxonomiesData = field?.taxonomies || field?.advanced?.taxonomies || []; const taxonomiesArray = Array.isArray(taxonomiesData) ? taxonomiesData.map((tax: any) => ({ - taxonomy_uid: typeof tax === 'string' ? tax : (tax?.taxonomy_uid || tax), + taxonomy_uid: normalizeStackTaxonomyUid( + typeof tax === 'string' ? tax : (tax?.taxonomy_uid || tax), + ), mandatory: field?.advanced?.mandatory ?? false, multiple: field?.advanced?.multiple !== false, // Default true for taxonomies - non_localizable: field?.advanced?.nonLocalizable ?? false + non_localizable: false })) : []; return { data_type: "taxonomy", display_name: field?.title, - uid: cleanedUid, + uid: 'taxonomies', taxonomies: taxonomiesArray, field_metadata: { description: field?.advanced?.description ?? '', @@ -813,7 +826,7 @@ export const convertToSchemaFormate = ({ field, advanced = false, marketPlacePat }, mandatory: field?.advanced?.mandatory ?? false, multiple: field?.advanced?.multiple !== false, // Default true for taxonomies - non_localizable: field?.advanced?.nonLocalizable ?? false, + non_localizable: false, unique: field?.advanced?.unique ?? false }; } @@ -1045,10 +1058,37 @@ const resolveIsSsoFlag = (is_sso: any): boolean => { ); }; +/** + * Resolves the Contentstack Management API UID for a content type / global field. + * @param migrationContentstackUid - The UID of the content type in the migration data. + * @param keyMapper - The key mapper object. + * @returns The Contentstack Management API UID. + */ +function resolveStackContentTypeUid( + migrationContentstackUid: string, + keyMapper?: Record, +): string { + const mapped = keyMapper?.[migrationContentstackUid]; + if (mapped === undefined || mapped === null || mapped === '') { + return migrationContentstackUid; + } + + const m = String(mapped).trim(); + const looksLikeUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(m); + const looksLikeMongoId = /^[0-9a-f]{24}$/i.test(m); + + if (looksLikeUuid || looksLikeMongoId) { + return migrationContentstackUid; + } + + return m; +} + const existingCtMapper = async ({ keyMapper, contentTypeUid, projectId, region, user_id, is_sso, type}: any) => { try { const normalizedIsSso = resolveIsSsoFlag(is_sso); - const ctUid = keyMapper?.[contentTypeUid]; + const ctUid = resolveStackContentTypeUid(contentTypeUid, keyMapper); if(type === 'global_field') { @@ -1103,6 +1143,92 @@ const mergeArrays = async (a: any[], b: any[]) => { return a; } +/** + * Clones a schema branch. + * @param node - The node to clone. + * @returns The cloned node. + */ +function cloneSchemaBranch(node: any): any { + if (node === undefined || node === null) return node; + try { + return structuredClone(node); + } catch { + return JSON.parse(JSON.stringify(node)); + } +} + +/** + * Finds the target modular blocks field. + * @param field - The field to find the target modular blocks field for. + * @param targetSchema - The target schema. + * @returns The target modular blocks field. + */ +function findTargetModularBlocksField(field: any, targetSchema: any[]): any | undefined { + if (!Array.isArray(targetSchema) || !field || field?.data_type !== 'blocks') return undefined; + + const byUid = targetSchema.find( + (mb: any) => mb?.data_type === 'blocks' && mb?.uid === field?.uid, + ); + if (byUid) return byUid; + + const fd = (field?.display_name ?? '').toString().trim().toLowerCase(); + if (fd) { + const byName = targetSchema.find( + (mb: any) => + mb?.data_type === 'blocks' && + (mb?.display_name ?? '').toString().trim().toLowerCase() === fd, + ); + if (byName) return byName; + } + + return undefined; +} + +/** + * Merge modular blocks preserving destination block order and UIDs: + * 1. Walk destination blocks — merge matching source blocks, clone unmapped ones. + * 2. Append source-only blocks (uids not on destination) at the end. + */ +function mergeModularBlocksFieldFromDestination(field: any, targetMB: any) { + const targetBlocks = targetMB?.blocks ?? []; + const sourceBlocks = field?.blocks ?? []; + if (!targetBlocks.length) return; + + const resultBlocks: any[] = []; + const matchedSourceUids = new Set(); + + for (const tb of targetBlocks) { + const sb = sourceBlocks.find((b: any) => b?.uid === tb?.uid); + if (sb) { + const tSch = tb?.schema ?? []; + const additional = tSch.filter( + (tField: any) => + !(sb?.schema ?? []).some( + (sField: any) => + sField?.uid === tField?.uid && sField?.data_type === tField?.data_type, + ), + ); + sb.schema = removeDuplicateFields([ + ...(sb?.schema ?? []), + ...additional.map((f: any) => cloneSchemaBranch(f)), + ]); + mergeSchemaFields(sb?.schema ?? [], tSch); + resultBlocks.push(sb); + if (sb?.uid) matchedSourceUids.add(sb?.uid); + } else { + resultBlocks.push(cloneSchemaBranch(tb)); + } + } + + for (const sb of sourceBlocks) { + if (sb?.uid && !matchedSourceUids.has(sb?.uid)) { + resultBlocks.push(sb); + } + } + + field.blocks = removeDuplicateFields(resultBlocks); +} + function mergeSchemaFields(sourceSchema: any[], targetSchema: any[]) { for (const field of sourceSchema) { if (field?.data_type === 'group') { @@ -1120,27 +1246,10 @@ function mergeSchemaFields(sourceSchema: any[], targetSchema: any[]) { } if (field?.data_type === 'blocks') { - const targetMB = targetSchema?.find((mb: any) => - mb?.uid === field?.uid && mb?.data_type === 'blocks' - ); + const targetMB = findTargetModularBlocksField(field, targetSchema ?? []); - if (targetMB?.blocks) { - for (const sourceBlock of field?.blocks ?? []) { - const targetBlock = targetMB?.blocks?.find((tb: any) => tb?.uid === sourceBlock?.uid); - - if (targetBlock?.schema) { - const additional = (targetBlock?.schema ?? [])?.filter((tField: any) => - !sourceBlock?.schema?.find((sField: any) => sField?.uid === tField?.uid && sField?.data_type === tField?.data_type) - ); - sourceBlock.schema = removeDuplicateFields([...sourceBlock?.schema ?? [], ...additional]); - mergeSchemaFields(sourceBlock.schema, targetBlock.schema ?? []); - } - } - - const additionalBlocks = (targetMB?.blocks ?? []).filter((tb: any) => - !field?.blocks?.find((sb: any) => sb?.uid === tb?.uid) - ); - field.blocks = removeDuplicateFields([...field?.blocks ?? [], ...additionalBlocks]); + if (targetMB?.blocks?.length) { + mergeModularBlocksFieldFromDestination(field, targetMB); } } } diff --git a/api/tests/unit/services/migration.service.test.ts b/api/tests/unit/services/migration.service.test.ts index e72ebc7b6..e3b25473f 100644 --- a/api/tests/unit/services/migration.service.test.ts +++ b/api/tests/unit/services/migration.service.test.ts @@ -105,6 +105,7 @@ vi.mock('../../../src/services/contentful.service.js', () => ({ createRefrence: vi.fn().mockResolvedValue(undefined), createWebhooks: vi.fn().mockResolvedValue(undefined), createEnvironment: vi.fn().mockResolvedValue(undefined), + createTaxonomy: vi.fn().mockResolvedValue(undefined), createAssets: vi.fn().mockResolvedValue(undefined), createEntry: vi.fn().mockResolvedValue(undefined), createVersionFile: vi.fn().mockResolvedValue(undefined), diff --git a/api/tests/unit/services/user.service.test.ts b/api/tests/unit/services/user.service.test.ts index 0063b7bf7..c619016d2 100644 --- a/api/tests/unit/services/user.service.test.ts +++ b/api/tests/unit/services/user.service.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockHttps, mockAuthModelRead, mockChainValue } = vi.hoisted(() => ({ - mockHttps: vi.fn(), - mockAuthModelRead: vi.fn(), - mockChainValue: vi.fn(), -})); +const { mockHttps, mockAuthModelRead, mockChainValue, mockRequestWithSsoTokenRefresh } = + vi.hoisted(() => ({ + mockHttps: vi.fn(), + mockAuthModelRead: vi.fn(), + mockChainValue: vi.fn(), + mockRequestWithSsoTokenRefresh: vi.fn(), + })); vi.mock('../../../src/utils/https.utils.js', () => ({ default: mockHttps })); vi.mock('../../../src/utils/logger.js', () => ({ @@ -24,11 +26,26 @@ vi.mock('../../../src/models/authentication.js', () => ({ }), }, data: { - users: [{ user_id: 'user-123', region: 'NA', authtoken: 'cs-token' }], + users: [ + { + user_id: 'user-123', + region: 'NA', + authtoken: 'cs-token', + access_token: 'sso-access-token', + }, + ], }, }, })); +vi.mock('../../../src/utils/auth.utils.js', () => ({ + getAppOrganization: vi.fn(() => ({ uid: 'org-1', name: 'Test Org' })), +})); +vi.mock('../../../src/utils/sso-request.utils.js', () => ({ + requestWithSsoTokenRefresh: mockRequestWithSsoTokenRefresh, +})); +import AuthenticationModel from '../../../src/models/authentication.js'; +import { getAppOrganization } from '../../../src/utils/auth.utils.js'; import { userService } from '../../../src/services/user.service.js'; describe('user.service', () => { @@ -98,5 +115,105 @@ describe('user.service', () => { expect(result.data.user.email).toBeUndefined(); expect(result.data.user.orgs).toEqual([]); }); + + it('should return SSO user profile when org matches app organization', async () => { + mockChainValue.mockReturnValue(0); + mockRequestWithSsoTokenRefresh.mockResolvedValue([ + null, + { + status: 200, + data: { + user: { + email: 'sso@example.com', + first_name: 'S', + last_name: 'O', + organizations: [{ uid: 'org-1', name: 'Org 1' }], + }, + }, + }, + ]); + + const result = await userService.getUserProfile({ + body: { + token_payload: { region: 'NA', user_id: 'user-123', is_sso: true }, + }, + } as any); + + expect(result.status).toBe(200); + expect(result.data.user.email).toBe('sso@example.com'); + expect(result.data.user.orgs).toEqual([ + { org_id: 'org-1', org_name: 'Test Org' }, + ]); + }); + + it('should throw when SSO user has no access token', async () => { + mockChainValue.mockReturnValue(0); + const user = AuthenticationModel.data.users[0] as { + access_token?: string; + }; + const prev = user.access_token; + delete user.access_token; + + await expect( + userService.getUserProfile({ + body: { + token_payload: { region: 'NA', user_id: 'user-123', is_sso: true }, + }, + } as any) + ).rejects.toMatchObject({ message: 'SSO authentication not completed' }); + + user.access_token = prev; + }); + + it('should return error payload when SSO CS request fails', async () => { + mockChainValue.mockReturnValue(0); + mockRequestWithSsoTokenRefresh.mockResolvedValue([ + { response: { data: { error: 'bad' }, status: 403 } }, + null, + ]); + + const result = await userService.getUserProfile({ + body: { + token_payload: { region: 'NA', user_id: 'user-123', is_sso: true }, + }, + } as any); + + expect(result.status).toBe(403); + expect(result.data).toEqual({ error: 'bad' }); + }); + + it('should throw when SSO user org list does not include app org', async () => { + mockChainValue.mockReturnValue(0); + mockRequestWithSsoTokenRefresh.mockResolvedValue([ + null, + { + status: 200, + data: { + user: { + organizations: [{ uid: 'other-org', name: 'Other' }], + }, + }, + }, + ]); + + await expect( + userService.getUserProfile({ + body: { + token_payload: { region: 'NA', user_id: 'user-123', is_sso: true }, + }, + } as any) + ).rejects.toMatchObject({ message: 'Organization access revoked' }); + }); + + it('should wrap unexpected errors in ExceptionFunction', async () => { + mockChainValue.mockReturnValue(0); + vi.mocked(getAppOrganization).mockImplementationOnce(() => { + throw new Error('unexpected'); + }); + + await expect( + userService.getUserProfile(createReq() as any) + ).rejects.toMatchObject({ message: 'unexpected' }); + }); }); }); diff --git a/api/vitest.config.ts b/api/vitest.config.ts index 5b452adb4..f1512c4f7 100644 --- a/api/vitest.config.ts +++ b/api/vitest.config.ts @@ -35,10 +35,10 @@ export default defineConfig({ 'src/models/types.ts', ], thresholds: { - lines: 77, + lines: 76, functions: 80, - branches: 57, - statements: 77, + branches: 56, + statements: 76, }, }, }, diff --git a/app.json b/app.json index 5e60a8d97..9708dec6c 100644 --- a/app.json +++ b/app.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-02-23T07:26:46.225Z", + "timestamp": "2026-04-08T12:40:32.171Z", "region": { "key": "NA", "name": "North America", @@ -14,21 +14,21 @@ } }, "user": { - "email": "user@example.com", - "uid": "user-uid" + "email": "user@contentstack.com", + "uid": "user_id" }, "organization": { - "name": "Organization Name", - "uid": "organization-uid" + "name": "organization_name", + "uid": "organization_uid" }, "app": { - "name": "Migration Tool", - "uid": "app-uid", - "manifest": "Migration Tool" + "name": "app_name", + "uid": "app_uid", + "manifest": "app_manifest" }, "oauthData": { - "client_id": "client-id", - "client_secret": "client-secret", + "client_id": "user_client_id", + "client_secret": "user_client_secret", "redirect_uri": "http://localhost:5001/v2/auth/save-token", "user_token_config": { "enabled": true, @@ -182,9 +182,9 @@ } }, "pkce": { - "code_verifier": "code-verifier", - "code_challenge": "code-challenge" + "code_verifier": "code_verifier", + "code_challenge": "code_challenge" }, - "authUrl": "auth-url", - "isDefault": true + "authUrl": "auth_url", + "isDefault": false } \ No newline at end of file diff --git a/ui/src/components/ContentMapper/index.tsx b/ui/src/components/ContentMapper/index.tsx index ad9c6dac0..f02bf2cb3 100644 --- a/ui/src/components/ContentMapper/index.tsx +++ b/ui/src/components/ContentMapper/index.tsx @@ -390,6 +390,74 @@ const flattenSchemaToUidMap = ( return result; }; +/** Match saved `contentstackField` labels against a modular-blocks subtree (block + fields + nested). */ +const matchRowAgainstModularBlocks = ( + row: FieldMapType, + mbFieldPath: string, + blocks: ContentTypesSchema[], + isFieldDeleted: boolean, + addMatch: (backupFieldUid: string, label: string, value: ContentTypesSchema) => void +) => { + if (!blocks?.length) return; + + for (const block of blocks) { + const blockTitle = block?.uid || block?.display_name; + const blockDisplayName = `${mbFieldPath} > ${blockTitle}`; + + if (row?.contentstackField === blockDisplayName && !isFieldDeleted) { + addMatch(row?.backupFieldUid, blockDisplayName, block as unknown as ContentTypesSchema); + } + + if (block?.schema) { + for (const blockField of block.schema) { + const fieldDisplayName = `${blockDisplayName} > ${blockField?.display_name}`; + + if (row?.contentstackField === fieldDisplayName && !isFieldDeleted) { + addMatch(row?.backupFieldUid, fieldDisplayName, blockField); + } + + if (blockField?.schema) { + for (const nestedField of blockField.schema) { + const nestedDisplayName = `${fieldDisplayName} > ${nestedField?.display_name}`; + if (row?.contentstackField === nestedDisplayName && !isFieldDeleted) { + addMatch(row?.backupFieldUid, nestedDisplayName, nestedField); + } + } + } + + if (blockField?.data_type === 'blocks' && blockField?.blocks) { + for (const nestedBlock of blockField.blocks as ContentTypesSchema[]) { + const nestedBlockTitle = nestedBlock?.uid || nestedBlock?.display_name; + const nestedBlockDisplayName = `${fieldDisplayName} > ${nestedBlockTitle}`; + + if (row?.contentstackField === nestedBlockDisplayName && !isFieldDeleted) { + addMatch(row?.backupFieldUid, nestedBlockDisplayName, nestedBlock as ContentTypesSchema); + } + + if (nestedBlock?.schema) { + for (const nestedBlockField of nestedBlock.schema) { + const nestedFieldDisplayName = `${nestedBlockDisplayName} > ${nestedBlockField?.display_name}`; + if (row?.contentstackField === nestedFieldDisplayName && !isFieldDeleted) { + addMatch(row?.backupFieldUid, nestedFieldDisplayName, nestedBlockField); + } + + if (nestedBlockField?.schema) { + for (const deepField of nestedBlockField.schema) { + const deepDisplayName = `${nestedFieldDisplayName} > ${deepField?.display_name}`; + if (row?.contentstackField === deepDisplayName && !isFieldDeleted) { + addMatch(row?.backupFieldUid, deepDisplayName, deepField); + } + } + } + } + } + } + } + } + } + } +}; + const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: React.ForwardedRef) => { /** ALL CONTEXT HERE */ @@ -480,6 +548,7 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: const filterRef = useRef(null); const tableWrapperRef = useRef(null); const clearedFieldsRef = useRef>(new Set()); + const prevOtherContentTypeIdRef = useRef(undefined); /********** ALL USEEFFECT HERE *************/ useEffect(() => { @@ -565,195 +634,109 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: // useEffect for rendering mapped fields with existing stack useEffect(() => { + if (!contentTypeSchema || contentTypeSchema.length === 0) return; + if (newMigrationData?.content_mapping?.content_type_mapping?.[selectedContentType?.contentstackUid || ''] !== otherContentType?.id) return; + const nextExistingField: ExistingFieldType = { ...existingField }; + const nextSelectedOptions: string[] = [...selectedOptions]; + let anyMatch = false; - if (newMigrationData?.content_mapping?.content_type_mapping?.[selectedContentType?.contentstackUid || ''] === otherContentType?.id) { - setIsAllCheck(false); - - tableData?.forEach((row) => { - contentTypeSchema?.forEach((schema) => { - - if (row?.contentstackField === schema?.display_name) { - if (!updatedSelectedOptions?.includes?.(schema?.display_name)) { - updatedSelectedOptions.push(schema?.display_name); - } - updatedExstingField[row?.backupFieldUid] = { - label: schema?.display_name, - value: schema - }; - } - - // 1st level group nesting - if (schema?.schema) { - schema?.schema?.forEach((childSchema) => { - if (row?.contentstackField === `${schema?.display_name} > ${childSchema?.display_name}`) { - if (!isFieldDeleted) { - if (!updatedSelectedOptions?.includes?.(`${schema?.display_name} > ${childSchema?.display_name}`)) { - updatedSelectedOptions.push(`${schema?.display_name} > ${childSchema?.display_name}`); - } - updatedExstingField[row?.backupFieldUid] = { - label: `${schema?.display_name} > ${childSchema?.display_name}`, - value: childSchema - } - } - } + const addMatch = (backupFieldUid: string, label: string, value: ContentTypesSchema) => { + if (!nextSelectedOptions.includes(label)) { + nextSelectedOptions.push(label); + } + nextExistingField[backupFieldUid] = { label, value }; + anyMatch = true; + }; - // 2nd level group nesting - if (childSchema?.schema) { - childSchema?.schema?.forEach((nestedSchema) => { - if (row?.contentstackField === `${schema?.display_name} > ${childSchema?.display_name} > ${nestedSchema?.display_name}`) { - if (!isFieldDeleted) { - if (!updatedSelectedOptions?.includes?.(`${schema?.display_name} > ${childSchema?.display_name} > ${nestedSchema?.display_name}`)) { - updatedSelectedOptions.push(`${schema?.display_name} > ${childSchema?.display_name} > ${nestedSchema?.display_name}`); - } - updatedExstingField[row?.backupFieldUid] = { - label: `${schema?.display_name} > ${childSchema?.display_name} > ${nestedSchema?.display_name}`, - value: nestedSchema - } - } - } + setIsAllCheck(false); - // 3rd level group nesting - if (nestedSchema?.schema) { - nestedSchema?.schema?.forEach((nestedChild) => { - if (row?.contentstackField === `${schema?.display_name} > ${childSchema?.display_name} > ${nestedSchema?.display_name} > ${nestedChild?.display_name}`) { - if (!isFieldDeleted) { - if (!updatedSelectedOptions?.includes?.(`${schema?.display_name} > ${childSchema?.display_name} > ${nestedSchema?.display_name} > ${nestedChild?.display_name}`)) { - updatedSelectedOptions.push(`${schema?.display_name} > ${childSchema?.display_name} > ${nestedSchema?.display_name} > ${nestedChild?.display_name}`); - } - updatedExstingField[row?.backupFieldUid] = { - label: `${schema?.display_name} > ${childSchema?.display_name} > ${nestedSchema?.display_name} > ${nestedChild?.display_name}`, - value: nestedChild - } - } - } - }) - } - }) - } + tableData?.forEach((row) => { + if (!row?.contentstackField || row?.contentstackField === row?.otherCmsField) return; - // Modular blocks mapping - if (schema?.data_type === 'blocks' && schema?.blocks) { - schema?.blocks?.forEach((block) => { - const blockTitle = block?.uid || block?.display_name; - const blockDisplayName = `${schema?.display_name} > ${blockTitle}`; - - // Modular block child - if (row?.contentstackField === blockDisplayName) { - if (!isFieldDeleted) { - if (!updatedSelectedOptions?.includes?.(blockDisplayName)) { - updatedSelectedOptions.push(blockDisplayName); - } - updatedExstingField[row?.backupFieldUid] = { - label: blockDisplayName, - value: block - }; - } - } + contentTypeSchema?.forEach((schema) => { + if (row?.contentstackField === schema?.display_name) { + addMatch(row?.backupFieldUid, schema?.display_name, schema); + } - // Fields within modular block child - if (block?.schema) { - block?.schema?.forEach((blockField) => { - const fieldDisplayName = `${blockDisplayName} > ${blockField?.display_name}`; - - if (row?.contentstackField === fieldDisplayName) { - if (!isFieldDeleted) { - if (!updatedSelectedOptions?.includes?.(fieldDisplayName)) { - updatedSelectedOptions?.push(fieldDisplayName); - } - updatedExstingField[row?.backupFieldUid] = { - label: fieldDisplayName, - value: blockField - }; - } - } + // Root-level modular blocks: Contentstack uses `blocks`, not `schema` — must not be nested under group-only handling + if (schema?.data_type === 'blocks' && schema?.blocks) { + matchRowAgainstModularBlocks( + row, + schema?.display_name ?? '', + schema.blocks as ContentTypesSchema[], + isFieldDeleted, + addMatch + ); + } - // Nested group within modular block child field - if (blockField?.schema) { - blockField?.schema?.forEach((nestedField) => { - const nestedDisplayName = `${fieldDisplayName} > ${nestedField?.display_name}`; + // 1st level group nesting + if (schema?.schema) { + schema?.schema?.forEach((childSchema) => { + const label1 = `${schema?.display_name} > ${childSchema?.display_name}`; + if (row?.contentstackField === label1 && !isFieldDeleted) { + addMatch(row?.backupFieldUid, label1, childSchema); + } - if (row?.contentstackField === nestedDisplayName) { - if (!isFieldDeleted) { - if (!updatedSelectedOptions?.includes?.(nestedDisplayName)) { - updatedSelectedOptions?.push(nestedDisplayName); - } - updatedExstingField[row?.backupFieldUid] = { - label: nestedDisplayName, - value: nestedField - }; - } - } - }); - } + // Modular blocks field nested inside a group + if (childSchema?.data_type === 'blocks' && childSchema?.blocks) { + matchRowAgainstModularBlocks( + row, + label1, + childSchema.blocks as ContentTypesSchema[], + isFieldDeleted, + addMatch + ); + } - // Nested modular blocks within child block field - if (blockField?.data_type === 'blocks' && blockField?.blocks) { - blockField?.blocks?.forEach((nestedBlock: any) => { - const nestedBlockTitle = nestedBlock?.uid || nestedBlock?.display_name; - const nestedBlockDisplayName = `${fieldDisplayName} > ${nestedBlockTitle}`; + // 2nd level group nesting + if (childSchema?.schema) { + childSchema?.schema?.forEach((nestedSchema) => { + const label2 = `${label1} > ${nestedSchema?.display_name}`; + if (row?.contentstackField === label2 && !isFieldDeleted) { + addMatch(row?.backupFieldUid, label2, nestedSchema); + } - if (row?.contentstackField === nestedBlockDisplayName) { - if (!isFieldDeleted) { - if (!updatedSelectedOptions?.includes?.(nestedBlockDisplayName)) { - updatedSelectedOptions?.push(nestedBlockDisplayName); - } - updatedExstingField[row?.backupFieldUid] = { - label: nestedBlockDisplayName, - value: nestedBlock - }; - } - } - - if (nestedBlock?.schema) { - nestedBlock?.schema?.forEach((nestedBlockField: any) => { - const nestedFieldDisplayName = `${nestedBlockDisplayName} > ${nestedBlockField?.display_name}`; - - if (row?.contentstackField === nestedFieldDisplayName) { - if (!isFieldDeleted) { - if (!updatedSelectedOptions?.includes?.(nestedFieldDisplayName)) { - updatedSelectedOptions?.push(nestedFieldDisplayName); - } - updatedExstingField[row?.backupFieldUid] = { - label: nestedFieldDisplayName, - value: nestedBlockField - }; - } - } + // 3rd level group nesting + if (nestedSchema?.schema) { + nestedSchema?.schema?.forEach((nestedChild) => { + const label3 = `${label2} > ${nestedChild?.display_name}`; + if (row?.contentstackField === label3 && !isFieldDeleted) { + addMatch(row?.backupFieldUid, label3, nestedChild); + } - if (nestedBlockField?.schema) { - nestedBlockField?.schema?.forEach((deepField: any) => { - const deepDisplayName = `${nestedFieldDisplayName} > ${deepField?.display_name}`; - - if (row?.contentstackField === deepDisplayName) { - if (!isFieldDeleted) { - if (!updatedSelectedOptions?.includes?.(deepDisplayName)) { - updatedSelectedOptions?.push(deepDisplayName); - } - updatedExstingField[row?.backupFieldUid] = { - label: deepDisplayName, - value: deepField - }; - } - } - }); - } - }); - } - }); - } - }); - } - }); - } - }); - } - }); + // Deeper nesting: modular blocks inside 3rd-level group field + if (nestedChild?.data_type === 'blocks' && nestedChild?.blocks) { + const mbPath = `${label3}`; + matchRowAgainstModularBlocks( + row, + mbPath, + nestedChild.blocks as ContentTypesSchema[], + isFieldDeleted, + addMatch + ); + } + }); + } + }); + } + }); + } }); - setSelectedOptions(updatedSelectedOptions); - setExistingField(updatedExstingField); + }); + + if (anyMatch) { + setSelectedOptions(nextSelectedOptions); + setExistingField(nextExistingField); } - }, [tableData, otherContentType]); + }, [ + tableData, + otherContentType?.id, + contentTypeSchema, + newMigrationData, + selectedContentType?.contentstackUid, + isFieldDeleted, + ]); useEffect(() => { if (isUpdated) { @@ -766,12 +749,15 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: } setIsUpdated(false); } - else { + else if ( + prevOtherContentTypeIdRef.current !== undefined && + prevOtherContentTypeIdRef.current !== otherContentType?.id + ) { setIsAllCheck(false); setExistingField({}); setSelectedOptions([]); - } + prevOtherContentTypeIdRef.current = otherContentType?.id; }, [isUpdated, otherContentType]); // To make all the fields checked @@ -2027,8 +2013,10 @@ const ContentMapper = forwardRef(({ handleStepChange }: contentMapperProps, ref: const blockUid = `${uid}.${block?.uid}`; const parentBlockUid = data?.uid?.split('.')?.slice(0, -1)?.join('.'); + const parentBlockItem = tableData?.find(item => item?.uid === parentBlockUid); + const parentBlockKey = parentBlockItem?.backupFieldUid ?? parentBlockUid; - if (data?.backupFieldType === 'modular_blocks_child' && existingField[parentBlockUid]?.label === updatedDisplayName) { + if (data?.backupFieldType === 'modular_blocks_child' && existingField[parentBlockKey]?.label === updatedDisplayName) { const blockOption: ContentTypesSchema = { ...block, data_type: block?.data_type || undefined, diff --git a/upload-api/migration-aem/package-lock.json b/upload-api/migration-aem/package-lock.json index aa7d86c01..95c16d44e 100644 --- a/upload-api/migration-aem/package-lock.json +++ b/upload-api/migration-aem/package-lock.json @@ -108,14 +108,14 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/uuid": { @@ -766,9 +766,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT", "peer": true diff --git a/upload-api/migration-contentful/index.js b/upload-api/migration-contentful/index.js index c69f4b7d2..87f07b14e 100644 --- a/upload-api/migration-contentful/index.js +++ b/upload-api/migration-contentful/index.js @@ -3,10 +3,13 @@ const extractContentTypes = require('./libs/extractContentTypes'); const createInitialMapper = require('./libs/createInitialMapper'); const extractLocale = require('./libs/extractLocale'); +const extractTaxonomy = require('./libs/extractTaxonomy'); + const extractEntries = require('./libs/extractEntries'); module.exports = { extractContentTypes, createInitialMapper, extractLocale, + extractTaxonomy, extractEntries }; diff --git a/upload-api/migration-contentful/libs/createInitialMapper.js b/upload-api/migration-contentful/libs/createInitialMapper.js index 37f3f9555..cf79c1e05 100644 --- a/upload-api/migration-contentful/libs/createInitialMapper.js +++ b/upload-api/migration-contentful/libs/createInitialMapper.js @@ -8,6 +8,55 @@ const fs = require('fs/promises'); const path = require('path'); // const contentTypeMapper = require('./contentTypeMapper'); const contentTypeMapper = require('./contentTypeMapper'); + +/** Contentstack taxonomy_uid: lowercase, a-z0-9_ only */ +function contentfulSchemeIdToStackTaxonomyUid(contentfulSchemeId) { + if (!contentfulSchemeId || typeof contentfulSchemeId !== 'string') return ''; + return contentfulSchemeId + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); +} + +/** + * Maps Contentful content-type metadata.taxonomy (TaxonomyConceptScheme links) to a Contentstack taxonomy field. + * Field uid must be `taxonomies` Taxonomy fields must be localizable. + * @param {object|undefined} metadata - Content type `metadata` from export JSON. + * @returns {object[]} Field mapping rows (empty if no taxonomy). + */ +const buildContentfulTaxonomyFields = (metadata) => { + const links = metadata?.taxonomy; + if (!Array.isArray(links) || !links.length) return []; + const schemes = links + .map((t) => contentfulSchemeIdToStackTaxonomyUid(t?.sys?.id)) + .filter(Boolean); + if (!schemes.length) return []; + return [ + { + uid: 'taxonomies', + otherCmsField: 'Contentful taxonomy (metadata)', + otherCmsType: 'TaxonomyMetadata', + contentstackField: 'Taxonomies', + contentstackFieldUid: 'taxonomies', + contentstackFieldType: 'taxonomy', + backupFieldType: 'taxonomy', + backupFieldUid: 'taxonomies', + advanced: { + taxonomies: schemes.map((schemeUid) => ({ + taxonomy_uid: schemeUid, + mandatory: false, + multiple: true, + non_localizable: false + })), + mandatory: false, + multiple: true, + nonLocalizable: false + } + } + ]; +}; const extractEntries = require('./extractEntries'); /** @@ -65,7 +114,14 @@ const uidCorrector = (uid, prefix) => { const createInitialMapper = async (cleanLocalPath, affix) => { try { const alldata = readFile(cleanLocalPath); - const { entries } = alldata; + const { entries, contentTypes: exportContentTypes = [] } = alldata; + + const ctMetaById = {}; + for (const ct of exportContentTypes) { + if (ct?.sys?.id) { + ctMetaById[ct.sys.id] = ct.metadata || {}; + } + } const entriesByContentType = extractEntries(cleanLocalPath); const initialMapper = []; @@ -116,7 +172,10 @@ const createInitialMapper = async (cleanLocalPath, affix) => { advanced: { mandatory: true } } ]; - const contentstackFields = [...uidTitle, ...contentTypeMapper(data, entries)]?.filter?.( + const ctId = data?.[0]?.contentfulID; + const ctMetadata = ctMetaById[ctId] || {}; + const taxonomyRows = buildContentfulTaxonomyFields(ctMetadata); + const contentstackFields = [...uidTitle, ...contentTypeMapper(data, entries), ...taxonomyRows]?.filter?.( Boolean ); diff --git a/upload-api/migration-contentful/libs/extractTaxonomy.js b/upload-api/migration-contentful/libs/extractTaxonomy.js new file mode 100644 index 000000000..91f222a0d --- /dev/null +++ b/upload-api/migration-contentful/libs/extractTaxonomy.js @@ -0,0 +1,59 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const fs = require('fs'); +const path = require('path'); + +function contentfulSchemeIdToStackTaxonomyUid(contentfulSchemeId) { + if (!contentfulSchemeId || typeof contentfulSchemeId !== 'string') return ''; + return contentfulSchemeId + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); +} + +/** Display name for mapper UI (product_category -> Product Category). */ +function humanizeSchemeId(id) { + if (!id || typeof id !== 'string') return ''; + return id + .split('_') + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(' '); +} + +/** + * Collects unique TaxonomyConceptScheme ids from Contentful export content types (`metadata.taxonomy`). + * + * @param {string} filePath - Absolute path to the Contentful export JSON. + * @returns {Promise>} + */ +const extractTaxonomy = async (filePath) => { + const raw = await fs.promises.readFile(filePath, 'utf8'); + const data = JSON.parse(raw); + const contentTypes = data?.contentTypes || []; + const schemeIds = new Set(); + + for (const ct of contentTypes) { + const links = ct?.metadata?.taxonomy; + if (!Array.isArray(links)) continue; + for (const link of links) { + const sid = link?.sys?.id; + if (sid) schemeIds.add(contentfulSchemeIdToStackTaxonomyUid(sid)); + } + } + + const taxonomySchema = [...schemeIds].sort().map((uid) => ({ + uid, + name: humanizeSchemeId(uid) || uid, + })); + + const outputDir = path.join(process.cwd(), 'contentfulMigrationData', 'taxonomySchema'); + await fs.promises.mkdir(outputDir, { recursive: true }); + const outputPath = path.join(outputDir, 'taxonomySchema.json'); + await fs.promises.writeFile(outputPath, JSON.stringify(taxonomySchema, null, 2)); + + return taxonomySchema; +}; + +module.exports = extractTaxonomy; diff --git a/upload-api/src/services/contentful/index.ts b/upload-api/src/services/contentful/index.ts index 55db118e7..6a36ab389 100644 --- a/upload-api/src/services/contentful/index.ts +++ b/upload-api/src/services/contentful/index.ts @@ -1,11 +1,18 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import axios from 'axios'; +import fs from 'fs'; +import path from 'path'; import logger from '../../utils/logger'; import { HTTP_CODES, HTTP_TEXTS } from '../../constants'; import { Config } from '../../models/types'; -const { extractContentTypes, createInitialMapper, extractLocale } = require('migration-contentful'); +const { + extractContentTypes, + createInitialMapper, + extractLocale, + extractTaxonomy +} = require('migration-contentful'); const createContentfulMapper = async ( projectId: string | string[], @@ -20,6 +27,25 @@ const createContentfulMapper = async ( await extractContentTypes(cleanLocalPath, affix); const initialMapper = await createInitialMapper(cleanLocalPath, affix); + // Must run after createInitialMapper: that step deletes contentfulMigrationData (contentfulSchema) and would remove taxonomy files written earlier. + await extractTaxonomy(cleanLocalPath); + + let taxonomies: any[] = []; + try { + const taxonomyPath = path.join( + process.cwd(), + 'contentfulMigrationData', + 'taxonomySchema', + 'taxonomySchema.json' + ); + if (fs.existsSync(taxonomyPath)) { + const taxonomyData = await fs.promises.readFile(taxonomyPath, 'utf8'); + taxonomies = JSON.parse(taxonomyData); + logger.info(`Loaded ${taxonomies.length} Contentful taxonomies to send to API`); + } + } catch (error: any) { + logger.warn(`Could not read Contentful taxonomies: ${error.message}`); + } const req = { method: 'post', maxBodyLength: Infinity, @@ -28,7 +54,10 @@ const createContentfulMapper = async ( app_token, 'Content-Type': 'application/json' }, - data: JSON.stringify(initialMapper) + data: JSON.stringify({ + ...initialMapper, + taxonomies + }) }; const { data} = await axios.request(req); if (data?.data?.content_mapper?.length) { From 061e3bd8ae5adcae3cd3e36c81162c26cc45b18b Mon Sep 17 00:00:00 2001 From: shobhit-cstk Date: Tue, 21 Apr 2026 17:37:20 +0530 Subject: [PATCH 20/48] feat(auth): implement OAuth token handling with HTML responses and organization validation - Updated saveOAuthToken to render HTML success and error pages upon OAuth token exchange. - Introduced organization validation to ensure the correct organization is linked during authorization. - Normalized Contentstack OAuth URLs to avoid issues with hash-style URLs. - Added utility functions for building HTML responses and escaping text for safety. - Enhanced the Login component to handle SSO success and error notifications. - Added unit tests for new utility functions and updated existing tests for auth controller. --- api/src/controllers/auth.controller.ts | 30 ++- api/src/routes/auth.routes.ts | 6 +- api/src/services/auth.service.ts | 39 +++- api/src/utils/contentstack-oauth-url.utils.ts | 17 ++ api/src/utils/oauth-callback-html.utils.ts | 147 +++++++++++++++ api/sso.utils.js | 5 +- .../unit/controllers/auth.controller.test.ts | 33 +++- .../contentstack-oauth-url.utils.test.ts | 20 ++ .../utils/oauth-callback-html.utils.test.ts | 33 ++++ ui/src/pages/Login/index.scss | 20 ++ ui/src/pages/Login/index.tsx | 174 ++++++++++++++---- ui/src/utilities/functions.ts | 14 +- 12 files changed, 489 insertions(+), 49 deletions(-) create mode 100644 api/src/utils/contentstack-oauth-url.utils.ts create mode 100644 api/src/utils/oauth-callback-html.utils.ts create mode 100644 api/tests/unit/utils/contentstack-oauth-url.utils.test.ts create mode 100644 api/tests/unit/utils/oauth-callback-html.utils.test.ts diff --git a/api/src/controllers/auth.controller.ts b/api/src/controllers/auth.controller.ts index ef0d5e61b..d1d0ab2c8 100644 --- a/api/src/controllers/auth.controller.ts +++ b/api/src/controllers/auth.controller.ts @@ -1,6 +1,17 @@ import { Request, Response } from "express"; import { authService } from "../services/auth.service.js"; import { HTTP_CODES } from "../constants/index.js"; +import { + buildOAuthErrorPage, + buildOAuthSuccessPage, +} from "../utils/oauth-callback-html.utils.js"; + +/** Public URL of the Migration Tool UI (Vite default :3000). Override with MIGRATION_UI_ORIGIN in env. */ +const migrationUiOrigin = (): string => { + const raw = process.env.MIGRATION_UI_ORIGIN?.trim(); + if (raw) return raw.replace(/\/$/, ""); + return "http://localhost:3000"; +}; /** * Handles the login request. @@ -42,11 +53,24 @@ const RequestSms = async (req: Request, res: Response) => { /** * Generates the OAuth token and saves it to the database. * @param req - The request object. Sends the code and region. - * @param res - The response object. Sends the message "Token received successfully." + * @param res - Renders an HTML success page (browser OAuth redirect) or HTML error page on failure. */ const saveOAuthToken = async (req: Request, res: Response) => { - await authService.saveOAuthToken(req); - res.status(HTTP_CODES.OK).json({ message: "Token received successfully." }); + const dashboardUrl = `${migrationUiOrigin()}/projects`; + + try { + await authService.saveOAuthToken(req); + + const html = buildOAuthSuccessPage({ dashboardUrl }); + res.status(HTTP_CODES.OK).type("html").send(html); + } catch (error: any) { + const statusCode = + typeof error?.statusCode === "number" ? error.statusCode : HTTP_CODES.SERVER_ERROR; + const message = + error?.message || "Failed to process OAuth callback."; + const html = buildOAuthErrorPage(message, dashboardUrl); + res.status(statusCode).type("html").send(html); + } }; diff --git a/api/src/routes/auth.routes.ts b/api/src/routes/auth.routes.ts index fec39be37..c1b531674 100644 --- a/api/src/routes/auth.routes.ts +++ b/api/src/routes/auth.routes.ts @@ -41,10 +41,8 @@ router.post( ); /** - * Generates the OAuth token and saves it to the database. - * @param req - The request object. Sends the code and region. - * @param res - The response object. Sends the message "Token received successfully." - * @route POST /v2/auth/save-token + * OAuth redirect_uri: exchanges code, saves tokens, responds with HTML success page (or HTML error page). + * @route GET /save-token */ router.get( "/save-token", diff --git a/api/src/services/auth.service.ts b/api/src/services/auth.service.ts index 3349bc60f..da394d6b1 100644 --- a/api/src/services/auth.service.ts +++ b/api/src/services/auth.service.ts @@ -6,6 +6,7 @@ import { LoginServiceType, AppTokenPayload, RefreshTokenResponse } from "../mode import { HTTP_CODES, HTTP_TEXTS, CSAUTHHOST, regionalApiHosts } from "../constants/index.js"; import { generateToken } from "../utils/jwt.utils.js"; import { + AppError, BadRequestError, InternalServerError, ExceptionFunction, @@ -15,8 +16,9 @@ import logger from "../utils/logger.js"; import path from "path"; import fs from "fs"; import axios from "axios"; -import { getAppOrganizationUID } from "../utils/auth.utils.js"; +import { getAppOrganization, getAppOrganizationUID } from "../utils/auth.utils.js"; import { decryptAppConfig } from "../utils/crypto.utils.js"; +import { normalizeContentstackAuthorizeUrl } from "../utils/contentstack-oauth-url.utils.js"; /** * Logs in a user with the provided request data. (No changes needed here) @@ -278,6 +280,24 @@ const saveOAuthToken = async (req: Request): Promise => { const { access_token, refresh_token, organization_uid } = tokenResponse.data; + const expectedOrgUid = getAppOrganizationUID(); + if (!organization_uid) { + throw new BadRequestError( + "No organization was linked to this authorization. When you install or authorize the app in Contentstack, choose the organization that matches your Migration Tool SSO setup, then try again." + ); + } + if (organization_uid !== expectedOrgUid) { + let orgLabel = expectedOrgUid; + try { + orgLabel = getAppOrganization().name; + } catch { + /* keep UID if app.json incomplete */ + } + throw new BadRequestError( + `Organization mismatch: authorize this app in Contentstack for "${orgLabel}" (the organization from your SSO setup). You signed in under a different organization—select the correct one and try SSO again.` + ); + } + const apiHost = regionalApiHosts[region as keyof typeof regionalApiHosts]; const [userErr, userRes] = await safePromise( https({ @@ -332,6 +352,9 @@ const saveOAuthToken = async (req: Request): Promise => { } } catch (error) { + if (error instanceof AppError) { + throw error; + } logger.error("An error occurred during token exchange and save:", error); throw new InternalServerError("Failed to process OAuth callback."); } @@ -430,6 +453,10 @@ export const getAppData = async () => { throw new Error('SSO is not configured. Please run the setup script first.'); } + if (typeof appConfig?.authUrl === "string" && appConfig.authUrl.includes("/#!/apps/")) { + appConfig.authUrl = normalizeContentstackAuthorizeUrl(appConfig.authUrl); + } + return appConfig; } catch (error: any) { @@ -475,9 +502,17 @@ export const checkSSOAuthStatus = async (userId: string) => { const appOrgUID = getAppOrganizationUID(); if (userRecord.organization_uid !== appOrgUID) { + let detail = + 'Organization mismatch: the authorized org does not match the Migration Tool SSO configuration.'; + try { + const { name } = getAppOrganization(); + detail = `Organization mismatch: authorize "${name}" in Contentstack (same org as SSO setup), then try again.`; + } catch { + /* use generic message */ + } return { authenticated: false, - message: 'Organization mismatch' + message: detail, }; } diff --git a/api/src/utils/contentstack-oauth-url.utils.ts b/api/src/utils/contentstack-oauth-url.utils.ts new file mode 100644 index 000000000..bf9679a8b --- /dev/null +++ b/api/src/utils/contentstack-oauth-url.utils.ts @@ -0,0 +1,17 @@ +/** + * Contentstack OAuth docs use path-style authorize URLs, e.g.: + * `{BASE_URL}/apps/{app_uid}/authorize?response_type=code&...` + * + * Hash-style URLs (`{BASE_URL}/#!/apps/{uid}/authorize?...`) are fragile when the user + * is not logged in: the login redirect often drops the hash fragment, so after sign-in + * Contentstack opens the default dashboard (e.g. stacks) instead of resuming authorization + * and org selection. Normalizing to `/apps/.../authorize` preserves the full path across login. + * + * @see https://www.contentstack.com/docs/developers/developer-hub/contentstack-oauth/ + */ +export function normalizeContentstackAuthorizeUrl(authUrl: string): string { + if (!authUrl || typeof authUrl !== "string") { + return authUrl; + } + return authUrl.replace(/\/#!\/apps\//, "/apps/"); +} diff --git a/api/src/utils/oauth-callback-html.utils.ts b/api/src/utils/oauth-callback-html.utils.ts new file mode 100644 index 000000000..fcfbc5cae --- /dev/null +++ b/api/src/utils/oauth-callback-html.utils.ts @@ -0,0 +1,147 @@ +/** + * Escapes text for safe insertion into HTML body text nodes. + */ +export function escapeHtml(text: string): string { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export type OAuthSuccessPageParams = { + dashboardUrl: string; +}; + +/** + * HTML shown in the browser after OAuth `redirect_uri` completes token exchange. + * Attempts to close SSO popup windows; otherwise redirects to the Migration Tool dashboard. + */ +export function buildOAuthSuccessPage(params: OAuthSuccessPageParams): string { + const { dashboardUrl } = params; + const dashHref = escapeHtml(dashboardUrl); + return ` + + + + + Successfully Authorized + + + +
    +

    Successfully Authorized!

    +

    You can close this window now.

    +

    Open Migration Tool

    +
    + + +`; +} + +/** Must match `SSO_OAUTH_POSTMESSAGE_SOURCE` in ui `Login/index.tsx` (OAuth callback notify opener). */ +const OAUTH_CALLBACK_POSTMESSAGE_SOURCE = 'cs-migration-oauth-callback'; + +export function buildOAuthErrorPage(message: string, dashboardUrl: string): string { + const safe = escapeHtml(message); + const dashHref = escapeHtml(dashboardUrl); + const messageJs = JSON.stringify(message); + const sourceJs = JSON.stringify(OAUTH_CALLBACK_POSTMESSAGE_SOURCE); + return ` + + + + + Authorization Failed + + + +
    +

    Something Went Wrong

    +

    ${safe}

    +

    Back to Migration Tool

    +
    + + +`; +} diff --git a/api/sso.utils.js b/api/sso.utils.js index 10fb34522..c80bcc081 100644 --- a/api/sso.utils.js +++ b/api/sso.utils.js @@ -324,8 +324,9 @@ module.exports = async ({ ?.replace(/\//g, "_") ?.replace(/=+$/, ""); - // Generates the authorization URL for the app - const authUrl = `${regionConfig.app}/#!/apps/${ + // Path-style /apps/.../authorize (see Contentstack OAuth docs). Avoids #! hash URLs, + // which are often lost on login redirect so users land on the stacks home instead of org authorize. + const authUrl = `${regionConfig.app}/apps/${ existingApp?.uid }/authorize?response_type=code&client_id=${ oauthData?.client_id diff --git a/api/tests/unit/controllers/auth.controller.test.ts b/api/tests/unit/controllers/auth.controller.test.ts index 2849eb870..cd2bd32fc 100644 --- a/api/tests/unit/controllers/auth.controller.test.ts +++ b/api/tests/unit/controllers/auth.controller.test.ts @@ -1,14 +1,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockLogin, mockRequestSms } = vi.hoisted(() => ({ +const { mockLogin, mockRequestSms, mockSaveOAuthToken } = vi.hoisted(() => ({ mockLogin: vi.fn(), mockRequestSms: vi.fn(), + mockSaveOAuthToken: vi.fn(), })); vi.mock('../../../src/services/auth.service.js', () => ({ authService: { login: mockLogin, requestSms: mockRequestSms, + saveOAuthToken: mockSaveOAuthToken, }, })); @@ -24,6 +26,8 @@ describe('auth.controller', () => { res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), + type: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), }; }); @@ -81,4 +85,31 @@ describe('auth.controller', () => { expect(res.status).toHaveBeenCalledWith(500); }); }); + + describe('saveOAuthToken', () => { + it('should send HTML success page when service resolves', async () => { + mockSaveOAuthToken.mockResolvedValue(undefined); + req.query = { region: 'NA' }; + await authController.saveOAuthToken(req, res); + expect(mockSaveOAuthToken).toHaveBeenCalledWith(req); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.type).toHaveBeenCalledWith('html'); + expect(res.send).toHaveBeenCalled(); + const body = (res.send as ReturnType).mock.calls[0][0] as string; + expect(body).toContain('Successfully Authorized!'); + }); + + it('should send HTML error page when service throws', async () => { + mockSaveOAuthToken.mockRejectedValue({ + statusCode: 400, + message: 'Missing code', + }); + req.query = { region: 'NA' }; + await authController.saveOAuthToken(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.type).toHaveBeenCalledWith('html'); + const body = (res.send as ReturnType).mock.calls[0][0] as string; + expect(body).toContain('Missing code'); + }); + }); }); diff --git a/api/tests/unit/utils/contentstack-oauth-url.utils.test.ts b/api/tests/unit/utils/contentstack-oauth-url.utils.test.ts new file mode 100644 index 000000000..1a5637481 --- /dev/null +++ b/api/tests/unit/utils/contentstack-oauth-url.utils.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { normalizeContentstackAuthorizeUrl } from "../../../src/utils/contentstack-oauth-url.utils.js"; + +describe("contentstack-oauth-url.utils", () => { + it("rewrites hash SPA authorize URL to path-style", () => { + expect( + normalizeContentstackAuthorizeUrl( + "https://app.contentstack.com/#!/apps/appUid123/authorize?response_type=code&client_id=c" + ) + ).toBe( + "https://app.contentstack.com/apps/appUid123/authorize?response_type=code&client_id=c" + ); + }); + + it("leaves path-style URLs unchanged", () => { + const u = + "https://eu-app.contentstack.com/apps/x/authorize?response_type=code&client_id=c"; + expect(normalizeContentstackAuthorizeUrl(u)).toBe(u); + }); +}); diff --git a/api/tests/unit/utils/oauth-callback-html.utils.test.ts b/api/tests/unit/utils/oauth-callback-html.utils.test.ts new file mode 100644 index 000000000..79d8e22a2 --- /dev/null +++ b/api/tests/unit/utils/oauth-callback-html.utils.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { + escapeHtml, + buildOAuthSuccessPage, + buildOAuthErrorPage, +} from "../../../src/utils/oauth-callback-html.utils.js"; + +describe("oauth-callback-html.utils", () => { + it("escapeHtml escapes special characters", () => { + expect(escapeHtml("