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,