diff --git a/.gitignore b/.gitignore index 838c8eabf..21e267287 100644 --- a/.gitignore +++ b/.gitignore @@ -372,3 +372,4 @@ app.json # Test coverage (global) coverage/ +export-stack/ diff --git a/api/src/utils/entry-update.utils.ts b/api/src/utils/entry-update.utils.ts index 3c871df30..79cf3a641 100644 --- a/api/src/utils/entry-update.utils.ts +++ b/api/src/utils/entry-update.utils.ts @@ -122,20 +122,21 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: for (const localeDir of localeDirs) { const localeCode = localeDir.name; - // Skip delta cleanup only when this is a restart AND the locale was never migrated - // before — newly-added locales have no prior state to diff against, so the regular - // full-import pipeline should pick them up untouched. On iteration 1 we always - // process (the function may be a no-op then, but tests/legacy code can still call it). - if ( + // True when this locale is brand-new in this delta run (wasn't migrated before). + // We still need to process its entry files: source entries that already exist in + // Contentstack (migrated in a prior iteration with a different locale mapping) must + // be LOCALIZED on their existing CS UID rather than imported as duplicate new entries. + // Entries with no prior CS UID are left in the import data to be created fresh. + const isNewLocaleInDelta = (projectData?.iteration ?? 1) > 1 && - isFullMigrationForLocale(projectData ?? {}, localeCode) - ) { + isFullMigrationForLocale(projectData ?? {}, localeCode); + + if (isNewLocaleInDelta) { writeLogEntry( - `Skipping delta cleanup for new locale "${localeCode}" — full import.`, + `New locale "${localeCode}" in delta run — routing existing entries through localize path instead of re-creating.`, "removeEntriesFromDatabase", loggerPath, ); - continue; } // entry_mapper rows are tagged with the SOURCE locale code (e.g. "en-IN"); // directories on disk use the DESTINATION code (e.g. "en-in"). Translate. @@ -195,7 +196,10 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: const row = (sourceLocale && rowByUidAndLang.get(`${key}::${sourceLocale}`)) || rowByUid.get(key); - if (row?.isUpdate) { + // For a new locale in a delta run, ALL entries with an existing CS UID + // must be localized (not re-created). For existing locales the user must + // explicitly mark entries with isUpdate to include them in the update pass. + if (row?.isUpdate || isNewLocaleInDelta) { const entryData = { ...data[key], __locale: localeCode, __csUid: csEntryUid }; delete entryData?.uid; @@ -203,8 +207,8 @@ export const removeEntriesFromDatabase = async (projectId: string, loggerPath?: entriesToUpdate[contentTypeName] = {}; } entriesToUpdate[contentTypeName][`${csEntryUid}::${localeCode}`] = entryData; - writeLogEntry(`Collected update entry "${csEntryUid}" (locale "${localeCode}") for content type "${contentTypeName}"`, "removeEntriesFromDatabase", loggerPath); - writeLogEntry(`Entry "${key}" has been prepared for update in Contentstack as "${csEntryUid}" (locale "${localeCode}")`, "removeEntriesFromDatabase", loggerPath); + writeLogEntry(`Collected ${isNewLocaleInDelta ? 'localize' : 'update'} entry "${csEntryUid}" (locale "${localeCode}") for content type "${contentTypeName}"`, "removeEntriesFromDatabase", loggerPath); + writeLogEntry(`Entry "${key}" has been prepared for ${isNewLocaleInDelta ? 'localization' : 'update'} in Contentstack as "${csEntryUid}" (locale "${localeCode}")`, "removeEntriesFromDatabase", loggerPath); } // Existing entry → remove from import data so it is NOT re-created. diff --git a/ui/src/components/ContentMapper/entryMapper.tsx b/ui/src/components/ContentMapper/entryMapper.tsx index 62d833c49..2dec101c1 100644 --- a/ui/src/components/ContentMapper/entryMapper.tsx +++ b/ui/src/components/ContentMapper/entryMapper.tsx @@ -661,7 +661,7 @@ const EntryMapper = ({ handleStepChange }: entryMapperProps) => { plural: `${totalCounts === 0 ? 'Count' : ''}` }} /> - {totalCounts > 0 && ( + {(totalCounts > 0 || (tableData?.length ?? 0) > 0) && (
-master_locale`) must rebuild with
+ // value='master_locale' — the marker string the render layer keys off to
+ // hardcode the CS Select as disabled and mark this row as master. Using
+ // the raw source value here (e.g. 'en') would drop the master row into the
+ // normal-row render branch, which is editable when the parent isDisabled
+ // prop isn't set (a race window during the Step 1 → Step 2 navigation on
+ // an iteration-1 revisit). Keep master rows locked by preserving the marker.
+ const isMasterKey = typeof key === 'string' && key.endsWith('-master_locale');
const labelKey = key?.replace(/-master_locale$/, '');
const exists = prevList?.some((item) => item?.label === labelKey);
if (!exists) {
@@ -702,7 +797,7 @@ const LanguageMapper = ({stack, uid} :{ stack : IDropDown, uid : string}) => {
...prevList,
{
label: labelKey,
- value: String(value)
+ value: isMasterKey ? 'master_locale' : String(value)
}
];
}
@@ -794,13 +889,40 @@ const LanguageMapper = ({stack, uid} :{ stack : IDropDown, uid : string}) => {
}
// Restart iteration: button must remain available so users can add a new
// destination locale before the delta run. Block when (a) there's already an
- // empty in-progress row waiting to be filled (avoid stacking empties) OR (b)
- // every available source locale is already mapped — otherwise clicking Add
- // Language would surface a row whose source dropdown has no unmapped option
+ // in-progress row not yet backed by a completed mapping (avoid stacking empties)
+ // OR (b) every available source locale is already mapped — otherwise clicking
+ // Add Language would surface a row whose source dropdown has no unmapped option
// to pick (e.g. a single-locale source where `en` is already in use).
- const hasEmptyRow = cmsLocaleOptions?.some((o) => !o?.value);
+ //
+ // Row-completeness derives from Redux (`localeMapping`) rather than
+ // `cmsLocaleOptions[i].value`, because the row-object's `value` field is only
+ // populated on rebuild — the per-row handlers (handleSelectedCsLocale /
+ // handleSelectedSourceLocale) update `selectedMappings` → Redux but never write
+ // back into `cmsLocaleOptions`, so a freshly-filled row would otherwise still
+ // read as "empty" here and lock Add Language forever after one add.
+ //
+ // Treat the master row as always-filled: its value is auto-managed by the
+ // parent (stack master locale), and on a delta-restart race the source can be
+ // momentarily blank in Redux before the sync-from-Redux effect merges it back.
+ // Counting master as filled prevents that transient state from disabling the
+ // button on the very first render after restart.
+ const savedMapping = newMigrationData?.destination_stack?.localeMapping || {};
+ // Exclude keys that are source locale codes — handleSelectedSourceLocale
+ // previously wrote under a fallback key (source label lowercased) when the
+ // user picked source before CS, inflating the count and re-enabling Add Language
+ // for an incomplete row. Filter those out so only proper CS locale keys count.
+ const sourceLocaleLabels = new Set(
+ sourceLocales?.map((l: { label: string }) => l.label) ?? []
+ );
+ const filledMappingCount = Object.entries(savedMapping).filter(([k, v]) => {
+ const isMasterKey = typeof k === 'string' && k.endsWith('-master_locale');
+ if (isMasterKey) return true;
+ if (sourceLocaleLabels.has(k)) return false;
+ return typeof v === 'string' && v.length > 0;
+ }).length;
+ const hasIncompleteRow = (cmsLocaleOptions?.length ?? 0) > filledMappingCount;
const mappedSources = new Set(
- Object.values(newMigrationData?.destination_stack?.localeMapping || {}).filter(
+ Object.values(savedMapping).filter(
(v): v is string => typeof v === 'string' && v?.length > 0
)
);
@@ -811,7 +933,7 @@ const LanguageMapper = ({stack, uid} :{ stack : IDropDown, uid : string}) => {
const totalSources = sourceLocales?.length ?? 0;
const sourcesNotReady = totalSources === 0;
const allSourcesMapped = totalSources > 0 && mappedSources.size >= totalSources;
- return hasEmptyRow || sourcesNotReady || allSourcesMapped;
+ return hasIncompleteRow || sourcesNotReady || allSourcesMapped;
})()}
>
Add Language
diff --git a/ui/src/pages/Migration/index.tsx b/ui/src/pages/Migration/index.tsx
index 92e4581df..5b2a13d8c 100644
--- a/ui/src/pages/Migration/index.tsx
+++ b/ui/src/pages/Migration/index.tsx
@@ -660,8 +660,30 @@ const Migration = () => {
// On a restart (iteration > 1) we must NOT skip Step 2 — that's where the user
// can review/adjust locale mapping (e.g. add a new locale before a delta run).
const isRestart = (newMigrationData?.iteration ?? 1) > 1;
- // Otherwise, if a stack is already chosen we can jump straight to Step 3.
- if (!isRestart && newMigrationData?.destination_stack?.selectedStack?.value) {
+ // Also require every source locale to have a filled mapping before we skip Step 2.
+ // Step 2 is where BOTH the destination stack AND per-locale mappings get configured;
+ // a project where the stack is chosen but some source locales are still unmapped
+ // (e.g. multi-locale source with only master mapped so far) needs a Step 2 visit to
+ // finish the mapping — the earlier `selectedStack.value` alone was too permissive
+ // and could skip users past locale mapping they hadn't completed yet.
+ const savedMapping = newMigrationData?.destination_stack?.localeMapping || {};
+ const sourceLocales: string[] = newMigrationData?.destination_stack?.sourceLocale ?? [];
+ const sourceLocaleSet = new Set(sourceLocales);
+ const mappedSourceValues = new Set(
+ Object.entries(savedMapping)
+ .filter(([k]) => k && k.trim() && k !== CS_ENTRIES.UNMAPPED_LOCALE_KEY && !sourceLocaleSet.has(k))
+ .map(([, v]) => v)
+ .filter((v): v is string => typeof v === 'string' && v.length > 0)
+ );
+ const allSourceLocalesMapped =
+ sourceLocales.length > 0 && sourceLocales.every((loc: string) => mappedSourceValues.has(loc));
+ // Otherwise, if a stack is already chosen AND every locale is mapped, we can jump
+ // straight to Step 3.
+ if (
+ !isRestart &&
+ newMigrationData?.destination_stack?.selectedStack?.value &&
+ allSourceLocalesMapped
+ ) {
const url = `/projects/${projectId}/migration/steps/3`;
// Bump current_step a second time so backend lands on Step 3 (Content Mapping)
// — we're skipping Step 2 because the destination stack is already configured.
diff --git a/upload-api/migration-contentful/index.js b/upload-api/migration-contentful/index.js
index babec0d9b..304304296 100644
--- a/upload-api/migration-contentful/index.js
+++ b/upload-api/migration-contentful/index.js
@@ -5,11 +5,13 @@ const createInitialMapper = require('./libs/createInitialMapper');
const extractLocale = require('./libs/extractLocale');
const extractTaxonomy = require('./libs/extractTaxonomy');
const extractEntries = require('./libs/extractEntries');
+const extractAssets = require('./libs/extractAssets');
module.exports = {
extractContentTypes,
createInitialMapper,
extractLocale,
extractTaxonomy,
- extractEntries
+ extractEntries,
+ extractAssets
};
diff --git a/upload-api/migration-contentful/libs/extractAssets.js b/upload-api/migration-contentful/libs/extractAssets.js
new file mode 100644
index 000000000..a77c8f5b3
--- /dev/null
+++ b/upload-api/migration-contentful/libs/extractAssets.js
@@ -0,0 +1,106 @@
+'use strict';
+/* eslint-disable @typescript-eslint/no-var-requires */
+
+const { readFile } = require('../utils/helper');
+
+/**
+ * Extracts asset mapping rows from a Contentful export.
+ *
+ * Discovery mirrors extractEntries in this same package: the Contentful export
+ * is a single JSON file read via readFile(cleanLocalPath), with top-level
+ * `entries`, `locales` and `assets` arrays (standard `contentful-export`
+ * output). Each asset carries a stable `sys.id` and locale-keyed fields, so
+ * file metadata lives under `fields.file[locale]` and the title under
+ * `fields.title[locale]`:
+ *
+ * {
+ * sys: { id: '' },
+ * fields: {
+ * title: { 'en-US': 'My Asset' },
+ * file: {
+ * 'en-US': {
+ * fileName: 'my-asset.png',
+ * url: '//images.ctfassets.net/.../my-asset.png',
+ * details: { size: 12345 }
+ * }
+ * }
+ * }
+ * }
+ *
+ * We dedupe by sys.id and use it as both `id` and `otherCmsAssetUid` so the row
+ * stays stable across delta iterations. Returned rows are consumed by
+ * putTestData on the main API to populate the asset_mapper DB (AssetMapper UI),
+ * matching the AEM/Sitecore AssetMappingRow shape.
+ *
+ * @param {string} cleanLocalPath - Path to the Contentful export JSON file.
+ * @returns {Array<{id:string,otherCmsAssetUid:string,filename:string,title:string,file_size:(number|string),assetPath:string,isUpdate:boolean}>}
+ */
+const extractAssets = (cleanLocalPath) => {
+ try {
+ const alldata = readFile(cleanLocalPath);
+ const assets = alldata?.assets;
+ const locales = Array.isArray(alldata?.locales)
+ ? alldata?.locales?.map((locale) => locale?.code).filter(Boolean)
+ : [];
+
+ if (!assets || !Array?.isArray(assets) || assets?.length === 0) {
+ console.info('No assets found in Contentful export');
+ return [];
+ }
+
+ const rows = [];
+ const seenIds = new Set();
+
+ // Prefer a locale-keyed value, falling back to the first available locale.
+ const pickLocalized = (field) => {
+ if (!field || typeof field !== 'object') return undefined;
+ for (const locale of locales) {
+ if (field[locale] !== undefined) return field[locale];
+ }
+ const firstKey = Object.keys(field)[0];
+ return firstKey !== undefined ? field[firstKey] : undefined;
+ };
+
+ for (const asset of assets) {
+ const id = asset?.sys?.id;
+ if (!id || seenIds.has(id)) {
+ continue;
+ }
+
+ const file = pickLocalized(asset?.fields?.file);
+ const titleValue = pickLocalized(asset?.fields?.title);
+ const title = typeof titleValue === 'string' ? titleValue : '';
+
+ const filename =
+ (typeof file?.fileName === 'string' && file?.fileName) || title || '';
+ const fileSize = file?.details?.size ?? '';
+ // Contentful serves assets from a protocol-relative CDN URL ("//...");
+ // normalize to https so downstream consumers get an absolute URL.
+ let assetPath = typeof file?.url === 'string' ? file?.url : '';
+ if (assetPath.startsWith('//')) {
+ assetPath = `https:${assetPath}`;
+ }
+
+ seenIds.add(id);
+
+ rows.push({
+ id,
+ otherCmsAssetUid: id,
+ filename,
+ title: title || filename,
+ file_size: fileSize,
+ assetPath,
+ isUpdate: false,
+ });
+ }
+
+ console.info(`extractAssets: Extracted ${rows.length} Contentful assets`);
+
+ return rows;
+ } catch (err) {
+ console.error('Error extracting Contentful assets:', err);
+ return [];
+ }
+};
+
+module.exports = extractAssets;
\ No newline at end of file
diff --git a/upload-api/migration-drupal/index.js b/upload-api/migration-drupal/index.js
index ec551137e..c46e7049b 100644
--- a/upload-api/migration-drupal/index.js
+++ b/upload-api/migration-drupal/index.js
@@ -4,11 +4,13 @@ const extractTaxonomy = require('./libs/extractTaxonomy');
const createInitialMapper = require('./libs/createInitialMapper');
const extractLocale = require('./libs/extractLocale');
const extractEntries = require('./libs/extractEntries');
+const extractAssets = require('./libs/extractAssets');
module.exports = {
// extractContentTypes,
extractTaxonomy,
createInitialMapper,
extractLocale,
- extractEntries
+ extractEntries,
+ extractAssets
};
diff --git a/upload-api/migration-drupal/libs/extractAssets.js b/upload-api/migration-drupal/libs/extractAssets.js
new file mode 100644
index 000000000..d6878028b
--- /dev/null
+++ b/upload-api/migration-drupal/libs/extractAssets.js
@@ -0,0 +1,109 @@
+'use strict';
+/* eslint-disable @typescript-eslint/no-var-requires */
+
+/**
+ * Builds the assetMapping array consumed by putTestData on the main API to
+ * populate the asset_mapper DB shown on the AssetMapper UI — same flow/shape
+ * as AEM/Sitecore/Contentful/WordPress.
+ *
+ * Drupal reads from MySQL (not files). Files live in the `file_managed` table
+ * (fid, uuid, filename, uri, filesize, filemime). We surface every managed
+ * file, deduped by its Drupal file id, using the fid as the stable
+ * id/otherCmsAssetUid so it lines up across delta iterations.
+ *
+ * Row shape mirrors migration-sitecore/libs/extractAssets.js:
+ * { id, otherCmsAssetUid, filename, title, file_size, assetPath, isUpdate }
+ */
+
+const { dbConnection } = require('../utils/helper');
+
+/**
+ * Derive a display-friendly path from the Drupal file uri.
+ * e.g. "public://2023-01/photo.jpg" -> "public/2023-01"
+ */
+const assetPathFromUri = (uri, filename) => {
+ if (!uri || typeof uri !== 'string') {
+ return '';
+ }
+ // Strip the stream wrapper scheme ("public://", "private://", "s3://", ...)
+ let cleaned = uri.replace(/^[a-z0-9]+:\/\//i, '');
+ // Drop the filename to leave only the directory portion
+ if (filename && cleaned.endsWith(filename)) {
+ cleaned = cleaned.slice(0, cleaned.length - filename.length);
+ }
+ return cleaned.replace(/\/+$/, '');
+};
+
+/**
+ * @param {Object} config - Migration config; must contain config.mysql (DB
+ * connection details). config.assetsConfig is accepted but unused for now.
+ * @returns {Promise>} assetMapping rows
+ */
+const extractAssets = async (config) => {
+ const rows = [];
+ let connection;
+
+ try {
+ connection = dbConnection(config);
+
+ // Bound the result set so a very large media library can't be pulled fully into
+ // memory. Configurable via assetsConfig.maxAssets; parameterized to keep it out of
+ // the SQL string.
+ const maxAssets = Number(config?.assetsConfig?.maxAssets) || 100000;
+ const query = `
+ SELECT fid, uuid, filename, uri, filesize, filemime
+ FROM file_managed
+ ORDER BY fid
+ LIMIT ?
+ `;
+ const [results] = await connection.promise().query(query, [maxAssets]);
+
+ if (Array.isArray(results) && results.length === maxAssets) {
+ console.warn(
+ `extractAssets (Drupal): hit maxAssets cap of ${maxAssets}; some assets may be omitted`
+ );
+ }
+
+ const seenIds = new Set();
+
+ for (const file of results || []) {
+ if (!file) {
+ continue;
+ }
+
+ const id = file?.fid != null ? String(file?.fid) : '';
+ if (!id || seenIds.has(id)) {
+ continue;
+ }
+ seenIds.add(id);
+
+ const filename = file?.filename ? String(file?.filename) : `File ${id}`;
+
+ rows.push({
+ id,
+ otherCmsAssetUid: id,
+ filename,
+ title: filename,
+ file_size: file?.filesize != null ? String(file?.filesize) : '',
+ assetPath: assetPathFromUri(file?.uri, filename),
+ isUpdate: false
+ });
+ }
+
+ console.info(`extractAssets (Drupal): ${rows.length} asset(s)`);
+ return rows;
+ } catch (err) {
+ console.error('extractAssets (Drupal) error:', err?.message || err);
+ return rows;
+ } finally {
+ if (connection) {
+ try {
+ connection.end();
+ } catch (endErr) {
+ console.error('extractAssets (Drupal) connection close error:', endErr?.message || endErr);
+ }
+ }
+ }
+};
+
+module.exports = extractAssets;
\ No newline at end of file
diff --git a/upload-api/migration-wordpress/index.ts b/upload-api/migration-wordpress/index.ts
index 7b4b9eaf7..86192d64f 100644
--- a/upload-api/migration-wordpress/index.ts
+++ b/upload-api/migration-wordpress/index.ts
@@ -2,10 +2,12 @@ import extractContentTypes from './libs/contentTypes';
//import contentTypeMaker from './libs/contentTypeMapper';
import extractLocale from './libs/extractLocale';
import extractEntries from './libs/extractEntries';
+import extractAssets from './libs/extractAssets';
export {
extractContentTypes,
//contentTypeMaker,
extractLocale,
- extractEntries
+ extractEntries,
+ extractAssets
}
\ No newline at end of file
diff --git a/upload-api/migration-wordpress/libs/extractAssets.ts b/upload-api/migration-wordpress/libs/extractAssets.ts
new file mode 100644
index 000000000..35bfd0e46
--- /dev/null
+++ b/upload-api/migration-wordpress/libs/extractAssets.ts
@@ -0,0 +1,97 @@
+import fs from 'fs';
+
+export interface AssetMappingRow {
+ id: string;
+ otherCmsAssetUid: string;
+ filename: string;
+ title: string;
+ file_size: number | string;
+ assetPath: string;
+ isUpdate: boolean;
+}
+
+const normalizeArray = (value: T | T[] | undefined): T[] => {
+ if (!value) return [];
+ return Array.isArray(value) ? value : [value];
+};
+
+/**
+ * WordPress WXR exports are parsed upstream into JSON before reaching here, so
+ * we load the file exactly like extractEntries/extractItems do: read the file
+ * and JSON.parse it, then walk rss.channel.item. Media are `item` elements with
+ * `wp:post_type === 'attachment'`.
+ */
+const getAssetId = (item: any): string => {
+ const postId = item?.['wp:post_id'];
+ if (postId != null && String(postId).trim() !== '') {
+ return String(postId).trim();
+ }
+ const guid = item?.guid?.text ?? item?.guid;
+ return guid != null ? String(guid).trim() : '';
+};
+
+const getAssetUrl = (item: any): string => {
+ const url = item?.['wp:attachment_url'] ?? item?.guid?.text ?? item?.guid;
+ return url != null ? String(url).trim() : '';
+};
+
+const getFilename = (item: any, assetUrl: string): string => {
+ const fromUrl = assetUrl?.split?.('/')?.pop?.();
+ if (typeof fromUrl === 'string' && fromUrl.trim() !== '') {
+ return fromUrl.trim();
+ }
+ const title = item?.title?.text ?? item?.title;
+ return typeof title === 'string' ? title.trim() : '';
+};
+
+const getTitle = (item: any, filename: string): string => {
+ const title = item?.title?.text ?? item?.title;
+ if (typeof title === 'string' && title.trim() !== '') {
+ return title.trim();
+ }
+ return filename.split('.').slice(0, -1).join('.') || filename;
+};
+
+const extractAssets = async (filePath: string): Promise => {
+ const rows: AssetMappingRow[] = [];
+ try {
+ const rawData = await fs.promises.readFile(filePath, 'utf8');
+ const jsonData = JSON.parse(rawData);
+ const items = normalizeArray(jsonData?.rss?.channel?.item);
+
+ const seenIds = new Set();
+
+ for (const item of items) {
+ if (item?.['wp:post_type'] !== 'attachment') {
+ continue;
+ }
+
+ const id = getAssetId(item);
+ if (!id || seenIds.has(id)) {
+ continue;
+ }
+ seenIds.add(id);
+
+ const assetPath = getAssetUrl(item);
+ const filename = getFilename(item, assetPath);
+ const title = getTitle(item, filename);
+
+ rows.push({
+ id,
+ otherCmsAssetUid: id,
+ filename,
+ title,
+ file_size: '',
+ assetPath,
+ isUpdate: false,
+ });
+ }
+
+ return rows;
+ } catch (error: any) {
+ console.error('Error while extracting WordPress assets:', error?.message || error);
+ return rows;
+ }
+};
+
+export default extractAssets;
\ No newline at end of file
diff --git a/upload-api/src/controllers/wordpress/index.ts b/upload-api/src/controllers/wordpress/index.ts
index bf86c1361..73a1f4465 100644
--- a/upload-api/src/controllers/wordpress/index.ts
+++ b/upload-api/src/controllers/wordpress/index.ts
@@ -2,7 +2,7 @@ import axios from "axios";
import logger from "../../utils/logger";
import { HTTP_CODES, HTTP_TEXTS, MIGRATION_DATA_CONFIG } from "../../constants";
// eslint-disable-next-line @typescript-eslint/no-var-requires
-import { extractContentTypes, extractLocale, extractEntries } from 'migration-wordpress';
+import { extractContentTypes, extractLocale, extractEntries, extractAssets } from 'migration-wordpress';
import { deleteFolderSync } from "../../helper";
import path from "path";
@@ -44,7 +44,8 @@ const createWordpressMapper = async (filePath: string = "", projectId: string |
}
if(contentTypeData){
- const fieldMapping: any = { contentTypes: [], extractPath: filePath };
+ const assetMapping = await extractAssets(filePath);
+ const fieldMapping: any = { contentTypes: [], extractPath: filePath, assetMapping };
contentTypeData.forEach((contentType: any) => {
const jsonfileContent = contentType;
jsonfileContent.type = "content_type";
diff --git a/upload-api/src/services/contentful/index.ts b/upload-api/src/services/contentful/index.ts
index 0f02c2338..81e57a23d 100644
--- a/upload-api/src/services/contentful/index.ts
+++ b/upload-api/src/services/contentful/index.ts
@@ -7,12 +7,13 @@ import logger from '../../utils/logger';
import { HTTP_CODES, HTTP_TEXTS } from '../../constants';
import { Config } from '../../models/types';
-const {
+import {
extractContentTypes,
createInitialMapper,
extractLocale,
- extractTaxonomy
-} = require('migration-contentful');
+ extractTaxonomy,
+ extractAssets
+} from 'migration-contentful';
const createContentfulMapper = async (
projectId: string | string[],
@@ -23,7 +24,7 @@ const createContentfulMapper = async (
try {
const { localPath } = config;
const cleanLocalPath = localPath?.replace?.(/\/$/, '');
- const fetchedLocales: [] = await extractLocale(cleanLocalPath);
+ const fetchedLocales: string[] = await extractLocale(cleanLocalPath);
const mapperConfig = {
method: 'post',
@@ -46,11 +47,14 @@ const createContentfulMapper = async (
});
}
- await extractContentTypes(cleanLocalPath, affix);
- const initialMapper = await createInitialMapper(cleanLocalPath, affix);
+ await extractContentTypes(cleanLocalPath, affix as string);
+ const initialMapper = await createInitialMapper(cleanLocalPath, affix as string);
// Must run after createInitialMapper: that step deletes contentfulMigrationData (contentfulSchema) and would remove taxonomy files written earlier.
await extractTaxonomy(cleanLocalPath);
+ // Asset mapping rows for the AssetMapper UI (same flow as AEM/Sitecore).
+ const assetMapping = await extractAssets(cleanLocalPath);
+
let taxonomies: any[] = [];
try {
const taxonomyPath = path.join(
@@ -77,7 +81,8 @@ const createContentfulMapper = async (
},
data: JSON.stringify({
...initialMapper,
- taxonomies
+ taxonomies,
+ assetMapping
})
};
const { data} = await axios.request(req);
diff --git a/upload-api/src/services/drupal/index.ts b/upload-api/src/services/drupal/index.ts
index 63b33ad0f..ec95bf4da 100644
--- a/upload-api/src/services/drupal/index.ts
+++ b/upload-api/src/services/drupal/index.ts
@@ -7,7 +7,7 @@ import logger from '../../utils/logger';
import { HTTP_CODES, HTTP_TEXTS } from '../../constants';
import { Config } from '../../models/types';
-const { createInitialMapper, extractLocale, extractTaxonomy } = require('migration-drupal');
+import { createInitialMapper, extractLocale, extractTaxonomy, extractAssets } from 'migration-drupal';
const createDrupalMapper = async (
config: Config,
@@ -52,10 +52,13 @@ const createDrupalMapper = async (
}
// Extract taxonomy vocabularies and save to drupalMigrationData
- await extractTaxonomy(config?.mysql);
+ await extractTaxonomy(config?.mysql as object);
const initialMapper = await createInitialMapper(config, affix);
+ // Asset mapping rows for the AssetMapper UI (same flow as AEM/Sitecore/Contentful/WordPress).
+ const assetMapping = await extractAssets(config);
+
// Read extracted taxonomies from file
let taxonomies: any[] = [];
try {
@@ -84,7 +87,8 @@ const createDrupalMapper = async (
contentTypes: initialMapper.contentTypes, // All content types (no profile)
assetsConfig: config.assetsConfig,
mySQLDetails: config.mysql,
- taxonomies: taxonomies // Add taxonomies to payload
+ taxonomies: taxonomies, // Add taxonomies to payload
+ assetMapping // Asset rows for the AssetMapper UI
};
const req = {
diff --git a/upload-api/tests/__mocks__/migration-contentful.ts b/upload-api/tests/__mocks__/migration-contentful.ts
index 21764d913..02ec5690f 100644
--- a/upload-api/tests/__mocks__/migration-contentful.ts
+++ b/upload-api/tests/__mocks__/migration-contentful.ts
@@ -3,3 +3,5 @@ import { vi } from 'vitest';
export const extractLocale = vi.fn().mockResolvedValue([]);
export const extractContentTypes = vi.fn().mockResolvedValue(undefined);
export const createInitialMapper = vi.fn().mockResolvedValue({ contentTypes: [] });
+export const extractTaxonomy = vi.fn().mockResolvedValue(undefined);
+export const extractAssets = vi.fn().mockReturnValue([]);
diff --git a/upload-api/tests/__mocks__/migration-drupal.ts b/upload-api/tests/__mocks__/migration-drupal.ts
index 386318efd..4df9c9204 100644
--- a/upload-api/tests/__mocks__/migration-drupal.ts
+++ b/upload-api/tests/__mocks__/migration-drupal.ts
@@ -3,3 +3,4 @@ import { vi } from 'vitest';
export const extractLocale = vi.fn().mockResolvedValue(new Set());
export const extractTaxonomy = vi.fn().mockResolvedValue(undefined);
export const createInitialMapper = vi.fn().mockResolvedValue({ contentTypes: [] });
+export const extractAssets = vi.fn().mockResolvedValue([]);
diff --git a/upload-api/tests/unit/controllers/wordpress.controller.test.ts b/upload-api/tests/unit/controllers/wordpress.controller.test.ts
index c15351e25..e3e8aef5d 100644
--- a/upload-api/tests/unit/controllers/wordpress.controller.test.ts
+++ b/upload-api/tests/unit/controllers/wordpress.controller.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-const { mockAxiosRequest, mockDeleteFolderSync, mockExtractLocale, mockExtractContentTypes, mockExtractEntries } = vi.hoisted(() => ({
+const { mockAxiosRequest, mockDeleteFolderSync, mockExtractLocale, mockExtractContentTypes, mockExtractEntries, mockExtractAssets } = vi.hoisted(() => ({
mockAxiosRequest: vi.fn(),
mockDeleteFolderSync: vi.fn(),
mockExtractLocale: vi.fn().mockResolvedValue([]),
@@ -8,12 +8,15 @@ const { mockAxiosRequest, mockDeleteFolderSync, mockExtractLocale, mockExtractCo
// `extractEntries` is called between extractContentTypes and the POST to enrich each
// content type with its entry list. Default: pass the input through unchanged.
mockExtractEntries: vi.fn().mockImplementation((_p: string, ct: any) => ct),
+ // `extractAssets` builds the assetMapping rows sent with the createDummyData payload.
+ mockExtractAssets: vi.fn().mockResolvedValue([]),
}));
vi.mock('migration-wordpress', () => ({
extractLocale: mockExtractLocale,
extractContentTypes: mockExtractContentTypes,
extractEntries: mockExtractEntries,
+ extractAssets: mockExtractAssets,
}));
vi.mock('axios', () => ({ default: { request: mockAxiosRequest } }));
diff --git a/upload-api/tests/unit/services/contentful.service.test.ts b/upload-api/tests/unit/services/contentful.service.test.ts
index 8c1aa57ec..66af0546b 100644
--- a/upload-api/tests/unit/services/contentful.service.test.ts
+++ b/upload-api/tests/unit/services/contentful.service.test.ts
@@ -1,13 +1,27 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-const { mockAxiosRequest } = vi.hoisted(() => ({
+const { mockAxiosRequest, mockExtractLocale, mockExtractContentTypes, mockCreateInitialMapper, mockExtractTaxonomy, mockExtractAssets } = vi.hoisted(() => ({
mockAxiosRequest: vi.fn(),
+ mockExtractLocale: vi.fn().mockResolvedValue([]),
+ mockExtractContentTypes: vi.fn().mockResolvedValue(undefined),
+ mockCreateInitialMapper: vi.fn().mockResolvedValue({ contentTypes: [] }),
+ mockExtractTaxonomy: vi.fn().mockResolvedValue(undefined),
+ mockExtractAssets: vi.fn().mockReturnValue([]),
}));
+// Mock the connector explicitly (the service uses require('migration-contentful')).
+vi.mock('migration-contentful', () => ({
+ extractLocale: mockExtractLocale,
+ extractContentTypes: mockExtractContentTypes,
+ createInitialMapper: mockCreateInitialMapper,
+ extractTaxonomy: mockExtractTaxonomy,
+ extractAssets: mockExtractAssets,
+}));
vi.mock('axios', () => ({ default: { request: mockAxiosRequest } }));
vi.mock('../../../src/utils/logger', () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() } }));
import createContentfulMapper from '../../../src/services/contentful/index';
+const extractAssets = mockExtractAssets;
describe('createContentfulMapper', () => {
beforeEach(() => {
@@ -46,4 +60,26 @@ describe('createContentfulMapper', () => {
createContentfulMapper('proj-1', 'token', 'csm', config)
).resolves.toBeUndefined();
});
+
+ it('sends the extracted assetMapping in the createDummyData payload', async () => {
+ const config: any = { localPath: '/tmp/export.json' };
+ const assetRows = [
+ { id: 'a1', otherCmsAssetUid: 'a1', filename: 'a.png', title: 'a', file_size: 1, assetPath: 'https://x/a.png', isUpdate: false },
+ ];
+ (extractAssets as any).mockReturnValueOnce(assetRows);
+ // localeMapper POST first, createDummyData POST second.
+ mockAxiosRequest
+ .mockResolvedValueOnce({ status: 200, data: {} })
+ .mockResolvedValueOnce({ data: { data: { content_mapper: [1] } } });
+
+ await createContentfulMapper('proj-1', 'token', 'csm', config);
+
+ expect(extractAssets).toHaveBeenCalledWith('/tmp/export.json');
+ const dummyDataCall = mockAxiosRequest.mock.calls.find(
+ ([req]) => typeof req?.url === 'string' && req.url.includes('createDummyData')
+ );
+ expect(dummyDataCall).toBeTruthy();
+ const payload = JSON.parse((dummyDataCall as any[])[0].data as string);
+ expect(payload.assetMapping).toEqual(assetRows);
+ });
});
diff --git a/upload-api/tests/unit/services/drupal.service.test.ts b/upload-api/tests/unit/services/drupal.service.test.ts
index 774ddeccf..69edd799b 100644
--- a/upload-api/tests/unit/services/drupal.service.test.ts
+++ b/upload-api/tests/unit/services/drupal.service.test.ts
@@ -1,9 +1,20 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-const { mockAxiosRequest } = vi.hoisted(() => ({
+const { mockAxiosRequest, mockExtractLocale, mockExtractTaxonomy, mockCreateInitialMapper, mockExtractAssets } = vi.hoisted(() => ({
mockAxiosRequest: vi.fn(),
+ mockExtractLocale: vi.fn().mockResolvedValue(new Set()),
+ mockExtractTaxonomy: vi.fn().mockResolvedValue(undefined),
+ mockCreateInitialMapper: vi.fn().mockResolvedValue({ contentTypes: [] }),
+ mockExtractAssets: vi.fn().mockResolvedValue([]),
}));
+// Mock the connector explicitly (the service imports from 'migration-drupal').
+vi.mock('migration-drupal', () => ({
+ extractLocale: mockExtractLocale,
+ extractTaxonomy: mockExtractTaxonomy,
+ createInitialMapper: mockCreateInitialMapper,
+ extractAssets: mockExtractAssets,
+}));
vi.mock('axios', () => ({ default: { request: mockAxiosRequest } }));
vi.mock('../../../src/utils/logger', () => ({ default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() } }));
vi.mock('fs', () => ({
@@ -13,6 +24,7 @@ vi.mock('fs', () => ({
}));
import createDrupalMapper from '../../../src/services/drupal/index';
+const extractAssets = mockExtractAssets;
describe('createDrupalMapper', () => {
const config: any = {
@@ -54,4 +66,25 @@ describe('createDrupalMapper', () => {
createDrupalMapper(config, 'proj-1', 'token', 'csm')
).resolves.toBeUndefined();
});
+
+ it('sends the extracted assetMapping in the createDummyData payload', async () => {
+ const assetRows = [
+ { id: '5', otherCmsAssetUid: '5', filename: 'a.jpg', title: 'a.jpg', file_size: '10', assetPath: '2023', isUpdate: false },
+ ];
+ (extractAssets as any).mockResolvedValueOnce(assetRows);
+ // localeMapper POST first, createDummyData POST second.
+ mockAxiosRequest
+ .mockResolvedValueOnce({ status: 200, data: {} })
+ .mockResolvedValueOnce({ data: { data: { content_mapper: [1] } } });
+
+ await createDrupalMapper(config, 'proj-1', 'token', 'csm');
+
+ expect(extractAssets).toHaveBeenCalledWith(config);
+ const dummyDataCall = mockAxiosRequest.mock.calls.find(
+ ([req]) => typeof req?.url === 'string' && req.url.includes('createDummyData')
+ );
+ expect(dummyDataCall).toBeTruthy();
+ const payload = JSON.parse((dummyDataCall as any[])[0].data as string);
+ expect(payload.assetMapping).toEqual(assetRows);
+ });
});