diff --git a/api/src/services/drupal/entries.service.ts b/api/src/services/drupal/entries.service.ts
index 84b4b5587..3368226e6 100644
--- a/api/src/services/drupal/entries.service.ts
+++ b/api/src/services/drupal/entries.service.ts
@@ -441,11 +441,18 @@ const processFieldByType = (
case 'file': {
// File/Asset processing with proper validation and cleanup
+ // Note: earlier processing (processFieldData) may have already resolved
+ // the raw target_id into an asset reference object - pass those through
+ // as-is instead of re-deriving `assets_${value}` from the object.
if (fieldMapping.advanced?.multiple) {
// Multiple files
if (Array.isArray(value)) {
const validAssets = value
?.map((assetRef) => {
+ if (assetRef && typeof assetRef === 'object' && assetRef?.uid) {
+ return assetRef; // Already resolved
+ }
+
const assetKey = `assets_${assetRef}`;
const assetReference = assetId?.[assetKey];
@@ -462,8 +469,29 @@ const processFieldByType = (
return validAssets?.length > 0 ? validAssets : undefined; // Return undefined if no valid assets
}
+
+ // processFieldData resolves asset target_ids to a single reference
+ // object even for multiple-value fields - normalize into an array
+ // instead of returning a bare object where Contentstack expects one.
+ if (value && typeof value === 'object' && value?.uid) {
+ return [value];
+ }
+
+ const assetKey = `assets_${value}`;
+ const assetReference = assetId?.[assetKey];
+
+ if (assetReference && typeof assetReference === 'object') {
+ return [assetReference];
+ }
+
+ console.error(`Asset ${assetKey} not found or invalid, removing field`);
+ return undefined;
} else {
// Single file
+ if (value && typeof value === 'object' && value?.uid) {
+ return value; // Already resolved
+ }
+
const assetKey = `assets_${value}`;
const assetReference = assetId?.[assetKey];
@@ -479,16 +507,36 @@ const processFieldByType = (
case 'reference': {
// Reference processing
+ // Note: earlier processing (processFieldData) may have already resolved
+ // reference ids into reference objects - pass those through as-is.
if (fieldMapping.advanced?.multiple) {
// Multiple references
if (Array.isArray(value)) {
- return value?.map(
- (refId) =>
- referenceId?.[`content_type_entries_title_${refId}`] || refId,
- );
+ return value?.map((refId) => {
+ if (refId && typeof refId === 'object' && refId?.uid) {
+ return refId; // Already resolved
+ }
+ return referenceId?.[`content_type_entries_title_${refId}`] || refId;
+ });
}
} else {
// Single reference
+ if (Array.isArray(value)) {
+ // processFieldData normalizes reference target_ids into an array of
+ // resolved reference objects even for single-value fields - return
+ // as-is instead of wrapping again into a nested array [[{uid,...}]].
+ // Mirror the `multiple` branch above and resolve any raw (unresolved)
+ // IDs still present, e.g. from _tid fields built in the ctValue loop.
+ return value.map((refId) => {
+ if (refId && typeof refId === 'object' && refId?.uid) {
+ return refId; // Already resolved
+ }
+ return referenceId?.[`content_type_entries_title_${refId}`] || refId;
+ });
+ }
+ if (value && typeof value === 'object' && value?.uid) {
+ return [value]; // Already resolved
+ }
return [referenceId?.[`content_type_entries_title_${value}`] || value];
}
return value;
@@ -671,8 +719,11 @@ const processFieldData = async (
const assetReference = assetId?.[assetKey];
if (assetReference && typeof assetReference === 'object') {
processedData[dataKey] = assetReference;
+ } else {
+ console.error(`Asset ${assetKey} not found or invalid, removing field`);
}
- // If asset reference is not properly structured, skip the field
+ } else {
+ console.error(`Asset ${assetKey} not found or invalid, removing field`);
}
// If asset not found in assets index, mark field as skipped
skippedFields?.add(dataKey);
diff --git a/ui/src/components/LegacyCms/Actions/LoadFileFormat.tsx b/ui/src/components/LegacyCms/Actions/LoadFileFormat.tsx
index 98453bc9c..7669c4a4f 100644
--- a/ui/src/components/LegacyCms/Actions/LoadFileFormat.tsx
+++ b/ui/src/components/LegacyCms/Actions/LoadFileFormat.tsx
@@ -50,12 +50,38 @@ const LoadFileFormat = (_props: LoadFileFormatProps) => {
}, [newMigrationData]);
// Handle file format extraction - RUN IMMEDIATELY ON MOUNT AND WHENEVER THE FILE PATH CHANGES.
- // The displayed format is always derived from the ACTUAL uploaded file extension, never from a
- // stale selectedFileFormat (which gets pre-seeded to the CMS default on CMS selection). This is
- // why editing the file path (e.g. zip → json) now updates the label, icon, and Redux in sync.
+ // Most CMS types have exactly one allowed format (e.g. Sitecore is always Zip) — for those, the
+ // displayed format must stay locked to that fixed format regardless of what extension the user
+ // types in the path; the separate validation effect below already flags a mismatched upload.
+ // Only when the selected CMS allows more than one format (currently just stack-to-stack
+ // Contentstack, which accepts JSON or Zip) do we derive the displayed format from the actual
+ // uploaded file extension, since there's genuinely more than one valid answer to show.
useEffect(() => {
const filePath = newMigrationData?.legacy_cms?.uploadedFile?.file_details?.localPath || '';
const currentFormat = newMigrationData?.legacy_cms?.selectedFileFormat?.title;
+ const allowedFormats = newMigrationData?.legacy_cms?.selectedCms?.allowed_file_formats;
+
+ // Lock only when the CMS has EXACTLY one allowed format. An empty array means the CMS
+ // isn't resolved yet (e.g. DEFAULT_CMS_TYPE while a multi-version CMS like Sitecore is
+ // still waiting on the user to pick a version card) — that's "unknown", not "one fixed
+ // format", and must fall through to the extension-derived behavior below rather than
+ // lock to a blank format and blank the field.
+ if (validateArray(allowedFormats) && allowedFormats.length === 1) {
+ const fixedFormat = allowedFormats[0];
+ setFileIcon(fixedFormat?.title);
+ setFileDisplayTitle(getDisplayTitle(fixedFormat?.title));
+ if (newMigrationData?.legacy_cms?.selectedFileFormat?.fileformat_id?.toLowerCase() !== fixedFormat?.fileformat_id?.toLowerCase()) {
+ const latest = newMigrationDataRef.current;
+ dispatch(updateNewMigrationData({
+ ...latest,
+ legacy_cms: {
+ ...latest?.legacy_cms,
+ selectedFileFormat: fixedFormat
+ }
+ }));
+ }
+ return;
+ }
// No file yet — fall back to whatever format is already in Redux (e.g. SQL/directory CMS types
// that don't carry a localPath).
@@ -110,7 +136,8 @@ const LoadFileFormat = (_props: LoadFileFormatProps) => {
}, [
newMigrationData?.legacy_cms?.uploadedFile?.file_details?.localPath,
newMigrationData?.legacy_cms?.selectedFileFormat?.fileformat_id,
- newMigrationData?.legacy_cms?.selectedFileFormat?.title
+ newMigrationData?.legacy_cms?.selectedFileFormat?.title,
+ newMigrationData?.legacy_cms?.selectedCms?.allowed_file_formats
]);
// Validate the uploaded file's format against the selected CMS's allowed formats.
diff --git a/upload-api/migration-wordpress/libs/extractAssets.ts b/upload-api/migration-wordpress/libs/extractAssets.ts
index 0136c36de..59a966da3 100644
--- a/upload-api/migration-wordpress/libs/extractAssets.ts
+++ b/upload-api/migration-wordpress/libs/extractAssets.ts
@@ -1,4 +1,5 @@
import fs from 'fs';
+import * as cheerio from 'cheerio';
export interface AssetMappingRow {
id: string;
@@ -52,14 +53,136 @@ const getTitle = (item: any, filename: string): string => {
return filename.split('.').slice(0, -1).join('.') || filename;
};
+// Mirrors wordpress.service.ts's isValidImageUrl — keep in sync.
+const isValidImageUrl = (url: string): boolean => {
+ if (!url || typeof url !== 'string') return false;
+ if (url.trim().startsWith('data:')) return false;
+ if (url.trim().length < 5) return false;
+ const lowerUrl = url.toLowerCase().trim();
+ if (lowerUrl.startsWith('javascript:') || lowerUrl.startsWith('mailto:') || lowerUrl.startsWith('tel:')) {
+ return false;
+ }
+ return true;
+};
+
+/** True if URL path ends with a common image extension (for image links). Mirrors
+ * wordpress.service.ts's looksLikeImageFileUrl — keep in sync. */
+const looksLikeImageFileUrl = (url: string): boolean => {
+ if (!url || typeof url !== 'string') return false;
+ const pathOnly = url.trim().split('?')[0].split('#')[0];
+ return /\.(jpe?g|png|gif|webp|svg|bmp|ico|avif|heic|heif)$/i.test(pathOnly);
+};
+
+// Mirrors wordpress.service.ts's toCheckUrl, except a relative URL with no baseSiteUrl to
+// resolve against returns null instead of building an unreachable "undefined/..." string —
+// the real run's own toCheckUrl produces exactly that unreachable URL in this case, so a row
+// here would describe an asset the run can never actually create.
+const toCheckUrl = (url: string, baseSiteUrl: string | undefined): string | null => {
+ const validPattern = /^(https?:\/\/|www\.)/;
+ if (validPattern.test(url)) return url;
+ if (!baseSiteUrl) return null;
+ return `${baseSiteUrl}${url.replace(/^\/+/, '')}`;
+};
+
+/**
+ * Finds embedded image (and audio) URLs in a post's content:encoded. Mirrors
+ * wordpress.service.ts's extractImageUrlsFromContent — img src/data-src/srcset,
+ * links to image files,