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,