diff --git a/api/src/services/aem.service.ts b/api/src/services/aem.service.ts index 8631dd52e..f3776074f 100644 --- a/api/src/services/aem.service.ts +++ b/api/src/services/aem.service.ts @@ -1,3 +1,6 @@ +/* eslint-disable no-inner-declarations */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-unsafe-optional-chaining */ import fs from 'fs'; import path from 'path'; import read from 'fs-readdir-recursive'; @@ -86,6 +89,12 @@ interface AssetJSON { _version?: number; } + +/** + * + * @param assetJsonPath - The path to the asset JSON file. + * @returns True if the asset JSON file exists, false otherwise. + */ async function isAssetJsonCreated(assetJsonPath: string): Promise { try { await fs.promises.access(assetJsonPath, fs.constants.F_OK); @@ -144,7 +153,6 @@ function getFieldValue(items: any, fieldName: string): any { return undefined; } - function getActualFieldUid(uid: string, fieldUid: string): string { if (RESERVED_FIELD_MAPPINGS[uid]) { return RESERVED_FIELD_MAPPINGS[uid]; @@ -219,11 +227,11 @@ function slugify(text: unknown): string { if (typeof text !== 'string') return ''; return text .toLowerCase() - .replace(/\|/g, '') // Remove pipe characters - .replace(/[^\w\s-]/g, '') // Remove non-word, non-space, non-hyphen chars - .replace(/\s+/g, '-') // Replace spaces with hyphens - .replace(/-+/g, '-') // Replace multiple hyphens with one - .replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens + .replace(/\|/g, '') // Remove pipe characters + .replace(/[^\w\s-]/g, '') // Remove special characters + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Replace multiple hyphens with a single hyphen + .replace(/^-+|-+$/g, ''); // Remove leading and trailing hyphens } function addEntryToEntriesData( @@ -260,7 +268,6 @@ function getLocaleFromMapper(mapper: Record, locale: string): st return Object.keys(mapper).find(key => mapper[key] === locale); } - const deepFlattenObject = (obj: any, prefix = '', res: any = {}) => { if (Array.isArray(obj) || (obj && typeof obj === 'object')) { const entries = Array.isArray(obj) ? obj.map((v, i) => [i, v]) @@ -287,7 +294,6 @@ export function isExperienceFragment(data: any) { if (data?.templateType && data[':type']) { // Check templateType starts with 'xf-' const hasXfTemplate = data?.templateType?.startsWith('xf-'); - // Check :type contains 'components/xfpage' const hasXfComponent = data[':type']?.includes('components/xfpage'); @@ -305,21 +311,20 @@ export function isExperienceFragment(data: any) { return null; } - /** - * Ensures the directory exists at the given path. + * Ensures the assets directory exists. * If it does not exist, creates it recursively. - * @param assetsSave - The relative path to the assets directory. + * @param assetsSave The path to the assets directory. */ async function ensureAssetsDirectory(assetsSave: string): Promise { const fullPath = path.join(process.cwd(), assetsSave); try { await fs.promises.access(fullPath); - // Directory exists + // Directory exists, log it console.info(`Directory exists: ${fullPath}`); } catch (err) { - // Directory does not exist, create it await fs.promises.mkdir(fullPath, { recursive: true }); + // Directory created, log it console.info(`Directory created: ${fullPath}`); } } @@ -361,8 +366,24 @@ function addUidToEntryMapping( entryMapping[contentType.contentstackUid].push(uid); } +function uidCorrector(str: string): string { + return str?.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase(); +} +declare const contentstackComponents: Record | undefined; +/** + * Function to create assets for the given destination stack. + * @param destinationStackId - The ID of the destination stack. + * @param projectId - The ID of the project. + * @param packagePath - The path to the package. + * @returns void - The function does not return a value. + * @description - This function creates the assets for the given destination stack. + * The assets are created in the following files: + * - index.json - The asset index. + * - path-mapping.json - The path to UID mapping. + * @throws Will log an error if the assets are not created. + */ const createAssets = async ({ destinationStackId, projectId, @@ -376,7 +397,7 @@ const createAssets = async ({ const assetsDir = path.resolve(packagePath); const allAssetJSON: Record = {}; // UID-based index.json - const pathToUidMap: Record = {}; // Path-to-UID mapping + const pathToUidMap: Record = {}; // Path to UID mapping const seenFilenames = new Map(); const pathToFilenameMap = new Map(); @@ -404,10 +425,8 @@ const createAssets = async ({ if (typeof contentAst === 'string') { const parseData = JSON.parse(contentAst); const filename = parseData?.asset?.name; - // Store mapping from this AEM path to filename pathToFilenameMap.set(value, filename); - // Only create asset ONCE per unique filename if (!seenFilenames?.has(filename)) { const uid = uuidv4?.()?.replace?.(/-/g, ''); @@ -418,8 +437,6 @@ const createAssets = async ({ metadata: parseData, blobPath }); - } else { - console.info(`Reusing asset: ${filename} → ${seenFilenames?.get(filename)?.uid}`); } } } @@ -435,10 +452,10 @@ const createAssets = async ({ // Create physical asset files (one per unique filename) for (const [filename, assetInfo] of seenFilenames?.entries()) { const { uid, metadata, blobPath } = assetInfo; - const nameWithoutExt = typeof filename === 'string' - ? filename.split('.').slice(0, -1).join('.') + const nameWithoutExt = typeof filename === 'string' + ? filename.split('.').slice(0, -1).join('.') : filename; - + try { const assets = fs.readFileSync(path.join(blobPath)); fs.mkdirSync(path.join(assetsSave, 'files', uid), { @@ -448,14 +465,14 @@ const createAssets = async ({ path.join(process.cwd(), assetsSave, 'files', uid, filename), assets ); - + const message = getLogMessage( srcFunc, `Asset "${filename}" has been successfully transformed.`, {} ); await customLogger(projectId, destinationStackId, 'info', message); - + } catch (err) { console.error(`Failed to create asset: ${filename}`, err); const message = getLogMessage( @@ -467,30 +484,30 @@ const createAssets = async ({ await customLogger(projectId, destinationStackId, 'error', message); } } - + // Track first path for each asset and build mappings const assetFirstPath = new Map(); - + // Build path-to-UID mapping (ALL paths map to the SAME deduplicated UID) for (const [aemPath, filename] of pathToFilenameMap.entries()) { const assetInfo = seenFilenames?.get(filename); if (assetInfo) { pathToUidMap[aemPath] = assetInfo.uid; - + // Track first path for index.json if (!assetFirstPath.has(assetInfo.uid)) { assetFirstPath.set(assetInfo.uid, aemPath); } } } - + // Create UID-based index.json for (const [filename, assetInfo] of seenFilenames?.entries()) { const { uid, metadata } = assetInfo; - const nameWithoutExt = typeof filename === 'string' - ? filename?.split('.').slice(0, -1).join('.') + const nameWithoutExt = typeof filename === 'string' + ? filename?.split('.').slice(0, -1).join('.') : filename; - + allAssetJSON[uid] = { urlPath: `/assets/${uid}`, uid: uid, @@ -515,13 +532,13 @@ const createAssets = async ({ path.join(process.cwd(), assetsSave, ASSETS_FILE_NAME), JSON.stringify(fileMeta) ); - + // index.json - UID-based await fs.promises.writeFile( path.join(process.cwd(), assetsSave, ASSETS_SCHEMA_FILE), JSON.stringify(allAssetJSON, null, 2) ); - + // path-mapping.json - For entry transformation await fs.promises.writeFile( path.join(process.cwd(), assetsSave, 'path-mapping.json'), @@ -529,17 +546,44 @@ const createAssets = async ({ ); }; +/** + * + * @param fields - The fields object. + * @param items - The items object. + * @param title - The title of the entry. + * @param pathToUidMap - The path to UID map. + * @param assetDetailsMap - The asset details map. + * @returns The processed fields object. + * @description - This function processes the fields recursively. + * The fields are processed in the following order: + * - Modular blocks + * - Group + * - Container + * - Single line text + * - Multiple line text + * - Rich text + * - Date + * - Number + * - Boolean + * - Image + * - Video + * - Audio + * - Link + * - Embed + * - Custom + * @throws Will log an error if the fields are not processed. + */ function processFieldsRecursive( - fields: any[], - items: any, - title: string, + fields: any[], + items: any, + title: string, pathToUidMap: Record, assetDetailsMap: Record ) { if (!fields) return; const obj: any = {}; const data: any = []; - + for (const field of fields) { switch (field?.contentstackFieldType) { case 'modular_blocks': { @@ -552,58 +596,304 @@ function processFieldsRecursive( } break; } - + case 'modular_blocks_child': { - for (const [, value] of Object.entries(items)) { - const objData: any = {}; - const typeValue = (value as { [key: string]: string })[':type']; + const list: any[] = (() => { + if (Array.isArray(items)) return items; + const order = Array.isArray(items?.[':itemsOrder']) ? items[':itemsOrder'] : null; + const map = items?.[':items'] || items; + if (order && map) return order.map((k: string) => map?.[k]).filter(Boolean); + return Object.values(map || {}); + })(); + + const uid = getLastKey(field?.contentstackFieldUid); + const blockTypeUid = field?.uid; + + for (const value of list) { + if (!value || typeof value !== 'object') continue; + const typeValue = (value as any)[':type'] || ''; const getTypeComp = getLastKey(typeValue, '/'); - const uid = getLastKey(field?.contentstackFieldUid); - if (getTypeComp === field?.uid) { - const compValue = processFieldsRecursive(field.schema, value, title, pathToUidMap, assetDetailsMap); - if (Object?.keys?.(compValue)?.length) { - objData[uid] = compValue; - data?.push(objData); - } + if (getTypeComp !== blockTypeUid) continue; + + const compValue = processFieldsRecursive(field.schema, value, title, pathToUidMap, assetDetailsMap); + if (compValue && Object.keys(compValue).length) { + const objData: any = {}; + objData[uid] = compValue; + data.push(objData); } } break; } - + case 'group': { - const groupData: unknown[] = []; - const groupValue = items?.[field?.uid]?.items ?? items?.[field?.uid]; const uid = getLastKey(field?.contentstackFieldUid); - if (Array.isArray(groupValue)) { - for (const element of groupValue) { + + const isMultiple = + (field?.multiple === true) || + (field?.advanced && field.advanced.multiple === true) || + (field?.maxInstance && field.maxInstance > 1); + + const isCarouselItems = + uid === 'items' && + items?.[':items'] && + items?.[':itemsOrder']; + + const carouselPreferredTypeHint = + (items && (items['activeItem'] || items['active_item'])) || + (items && items[':type'] && String(items[':type']).split('/').pop()) || + null; + + let groupValue; + if (isCarouselItems) { + groupValue = items; + } else { + groupValue = items?.[field?.uid]?.items ?? items?.[field?.uid]; + } + + if (isMultiple) { + const groupData: any[] = []; + + if (isCarouselItems) { + const order = items[':itemsOrder'] as string[]; + const map = items[':items'] as Record; + if (!Array.isArray(order) || !map) { + obj[uid] = groupData; + break; + } + + const childSchemasByUid = new Map(); if (Array.isArray(field?.schema)) { - const value = processFieldsRecursive(field.schema, element, title, pathToUidMap, assetDetailsMap); - groupData?.push(value); + for (const child of field.schema) { + const childUid = getLastKey(child?.contentstackFieldUid) || child?.uid; + if (childUid) childSchemasByUid.set(childUid, child); + if (child?.title) childSchemasByUid.set(uidCorrector(child.title), child); + } + } + + const globalCanonical = (typeof contentstackComponents !== 'undefined' && contentstackComponents) ? contentstackComponents : null; + const canonicalTeaserFromGlobal = globalCanonical && (globalCanonical['teaser'] || globalCanonical['Teaser'] || globalCanonical[uidCorrector('teaser')]) || null; + + function buildMinimalSchemaFromSlide(slide: any = {}, hintSchema: any = null) { + const fields: any[] = []; + + if (hintSchema && typeof hintSchema === 'object') { + const candidateKeys = Object.keys(hintSchema).filter(k => !k.startsWith(':') && k !== 'id' && k !== 'dataLayer').slice(0, 12); + for (const k of candidateKeys) { + fields.push({ uid: k, contentstackFieldUid: k, contentstackFieldType: 'single_line_text' }); + } + if (fields.length) return fields; + } + + if (slide?.title || slide?.titleType) fields.push({ uid: 'title', contentstackFieldUid: 'title', contentstackFieldType: 'single_line_text' }); + if (slide?.description) fields.push({ uid: 'description', contentstackFieldUid: 'description', contentstackFieldType: 'single_line_text' }); + if (slide?.imagePath || slide?.src || slide?.image) { + fields.push({ uid: 'src', contentstackFieldUid: 'src', contentstackFieldType: 'file' }); + fields.push({ uid: 'alt', contentstackFieldUid: 'alt', contentstackFieldType: 'single_line_text' }); + } + if (slide?.linkURL || slide?.actions) fields.push({ uid: 'linkURL', contentstackFieldUid: 'linkURL', contentstackFieldType: 'single_line_text' }); + + if (fields.length === 0) fields.push({ uid: 'content', contentstackFieldUid: 'content', contentstackFieldType: 'single_line_text' }); + + return fields; + } + + function findSchemaCandidate(candidateKey: string | null) { + if (!candidateKey) return null; + const cand = String(candidateKey); + const variants = [ + cand, + cand.toLowerCase(), + uidCorrector(cand), + cand.replace(/[^a-zA-Z0-9]/g, ''), + cand.split('_')[0], + cand.split('-')[0] + ]; + for (const v of variants) { + if (childSchemasByUid.has(v)) return childSchemasByUid.get(v); + } + return null; + } + + const preferredHint = carouselPreferredTypeHint ? String(carouselPreferredTypeHint).split('/').pop() : null; + + if (!childSchemasByUid.has('teaser') && canonicalTeaserFromGlobal) { + childSchemasByUid.set('teaser', canonicalTeaserFromGlobal); + } + + for (const key of order) { + const slide = map?.[key]; + if (!slide || typeof slide !== 'object') { + continue; + } + + const explicitType = slide[':type'] || slide[':Type'] || null; + let typeTail = explicitType ? String(explicitType).split('/').pop() : ''; + + if (!typeTail) { + const fromKeyMatch = String(key).match(/^[a-zA-Z]+/); + if (fromKeyMatch) typeTail = fromKeyMatch[0]; + } + if (!typeTail && preferredHint) typeTail = preferredHint; + typeTail = String(typeTail || '').toLowerCase(); + + let targetSchema = findSchemaCandidate(typeTail); + + if (!targetSchema) { + if (typeTail.includes('image')) targetSchema = findSchemaCandidate('image'); + else if (typeTail.includes('teaser')) targetSchema = findSchemaCandidate('teaser'); + else if (typeTail.includes('button')) targetSchema = findSchemaCandidate('button'); + else if (typeTail.includes('textbanner')) targetSchema = findSchemaCandidate('textBanner'); + else if (typeTail.includes('title')) targetSchema = findSchemaCandidate('title'); + else if (typeTail.includes('text')) targetSchema = findSchemaCandidate('text'); + else if (typeTail.includes('search')) targetSchema = findSchemaCandidate('search'); + else if (typeTail.includes('separator')) targetSchema = findSchemaCandidate('separator'); + else if (typeTail.includes('spacer')) targetSchema = findSchemaCandidate('spacer'); + } + + if (!targetSchema && preferredHint) { + targetSchema = findSchemaCandidate(preferredHint) || findSchemaCandidate(uidCorrector(String(preferredHint))); + } + + if (!targetSchema && canonicalTeaserFromGlobal) { + targetSchema = canonicalTeaserFromGlobal; + } + + if (!targetSchema && childSchemasByUid.size > 0) { + targetSchema = Array.from(childSchemasByUid.values())[0]; + } + + if (!targetSchema) { + const generatedSchema = buildMinimalSchemaFromSlide(slide); + const processed = processFieldsRecursive(generatedSchema, slide, title, pathToUidMap, assetDetailsMap); + if (processed && Object.keys(processed).length) { + groupData.push(processed); + } + continue; + } + + let schemaArray = null; + if (Array.isArray(targetSchema)) schemaArray = targetSchema; + else if (Array.isArray(targetSchema?.schema)) schemaArray = targetSchema.schema; + else if (Array.isArray(targetSchema?.fields)) schemaArray = targetSchema.fields; + else if (Array.isArray(targetSchema?.schemaFields)) schemaArray = targetSchema.schemaFields; + else if (Array.isArray(targetSchema?.content)) schemaArray = targetSchema.content; + else if (Array.isArray(targetSchema?.schemaDefinition)) schemaArray = targetSchema.schemaDefinition; + + if (!schemaArray || schemaArray.length === 0) { + const generatedSchema = buildMinimalSchemaFromSlide(slide, targetSchema); + const processed = processFieldsRecursive(generatedSchema, slide, title, pathToUidMap, assetDetailsMap); + if (processed && Object.keys(processed).length) { + groupData.push(processed); + } + continue; + } + + try { + const processed = processFieldsRecursive(schemaArray, slide, title, pathToUidMap, assetDetailsMap); + + if (processed && Object.keys(processed).length) { + groupData.push(processed); + } else { + const generatedSchema2 = buildMinimalSchemaFromSlide(slide, targetSchema); + const processed2 = processFieldsRecursive(generatedSchema2, slide, title, pathToUidMap, assetDetailsMap); + if (processed2 && Object.keys(processed2).length) { + groupData.push(processed2); + } else { + if (canonicalTeaserFromGlobal) { + const fallbackSchemaArray = canonicalTeaserFromGlobal?.schema || canonicalTeaserFromGlobal?.fields || canonicalTeaserFromGlobal; + if (Array.isArray(fallbackSchemaArray) && fallbackSchemaArray.length) { + const processed3 = processFieldsRecursive(fallbackSchemaArray, slide, title, pathToUidMap, assetDetailsMap); + if (processed3 && Object.keys(processed3).length) { + groupData.push(processed3); + } + } + } + } + } + } catch (err) { + console.error(`Error processing slide ${key}:`, err); + } } + + obj[uid] = groupData; + break; } - obj[uid] = groupData; - } else { + + if (Array.isArray(groupValue)) { + if (Array.isArray(field?.schema)) { + for (const element of groupValue) { + groupData.push( + processFieldsRecursive(field.schema, element, title, pathToUidMap, assetDetailsMap) + ); + } + } + obj[uid] = groupData; + break; + } + + const order2 = Array.isArray(items?.[':itemsOrder']) ? items[':itemsOrder'] : null; + const map2 = items?.[':items'] || items; + if (order2 && map2) { + const baseUid = field?.uid; + const keysForThisGroup = order2.filter( + (k) => k === baseUid || new RegExp(`^${baseUid}_`).test(k) + ); + if (Array.isArray(field?.schema) && keysForThisGroup.length > 0) { + for (const k of keysForThisGroup) { + const el = map2[k]; + if (!el || typeof el !== 'object') continue; + const processed = processFieldsRecursive( + field.schema, + el, + title, + pathToUidMap, + assetDetailsMap + ); + if (processed && Object.keys(processed).length) groupData.push(processed); + } + } + obj[uid] = groupData; + break; + } + if (Array.isArray(field?.schema)) { const value = processFieldsRecursive(field.schema, groupValue, title, pathToUidMap, assetDetailsMap); - obj[uid] = value; + obj[uid] = Array.isArray(value) ? value : (value ? [value] : []); + } + } else { + if (Array.isArray(groupValue)) { + const groupData: unknown[] = []; + if (Array.isArray(field?.schema)) { + for (const element of groupValue) { + groupData.push( + processFieldsRecursive(field.schema, element, title, pathToUidMap, assetDetailsMap) + ); + } + } + obj[uid] = groupData; + } else { + if (Array.isArray(field?.schema)) { + const value = processFieldsRecursive(field.schema, groupValue, title, pathToUidMap, assetDetailsMap); + obj[uid] = value; + } } } break; - } + } case 'boolean': { - const aemFieldName = field?.otherCmsField - ? getLastKey(field.otherCmsField, ' > ') + const aemFieldName = field?.otherCmsField + ? getLastKey(field.otherCmsField, ' > ') : getLastKey(field?.uid); const uid = getLastKey(field?.contentstackFieldUid); - const value = getFieldValue(items, aemFieldName); - + const value = getFieldValue(items, aemFieldName); + if (typeof value === 'boolean') { obj[uid] = value; - } + } else if (typeof value === 'object' && value !== null && value?.[':type']?.includes('separator')) { obj[uid] = true; - } + } else if (typeof value === 'string') { const lowerValue = value?.toLowerCase()?.trim(); if (lowerValue === 'true' || lowerValue === 'yes' || lowerValue === '1') { @@ -622,30 +912,30 @@ function processFieldsRecursive( } break; } - + case 'single_line_text': { const aemFieldName = field?.otherCmsField ? getLastKey(field?.otherCmsField, ' > ') : getLastKey(field?.uid); - let value = getFieldValue(items, aemFieldName); + let value = getFieldValue(items, aemFieldName); const uid = getLastKey(field?.contentstackFieldUid); - + const actualUid = getActualFieldUid(uid, field?.uid); if (value && typeof value === 'string' && /<[^>]+>/.test(value)) { value = stripHtmlTags(value); } - + obj[actualUid] = value !== null && value !== undefined ? String(value) : ""; break; } case 'multi_line_text': { const aemFieldName = field?.otherCmsField ? getLastKey(field.otherCmsField, ' > ') : getLastKey(field?.uid); - let value = getFieldValue(items, aemFieldName); + let value = getFieldValue(items, aemFieldName); const uid = getLastKey(field?.contentstackFieldUid); - + if (value && typeof value === 'string' && /<[^>]+>/.test(value)) { value = stripHtmlTags(value); } - + obj[uid] = value !== null && value !== undefined ? String(value) : ""; break; } @@ -654,16 +944,16 @@ function processFieldsRecursive( const uid = getLastKey(field?.contentstackFieldUid); obj[uid] = title ?? ''; break; - } + } case 'url': { const uid = getLastKey(field?.contentstackFieldUid); obj[uid] = `/${slugify(title)}`; break; - } + } case 'reference': { const fieldKey = getLastKey(field?.contentstackFieldUid); const refCtUid = field?.referenceTo?.[0] || field?.uid; - const references = []; + const references = []; for (const [key, val] of Object.entries(items) as [string, Record][]) { if (!val?.configured || (val[':type'] as string) === 'nt:folder') { continue; @@ -688,9 +978,9 @@ function processFieldsRecursive( } case 'number': { const aemFieldName = field?.otherCmsField ? getLastKey(field.otherCmsField, ' > ') : getLastKey(field?.uid); - const value = getFieldValue(items, aemFieldName); + const value = getFieldValue(items, aemFieldName); const uid = getLastKey(field?.contentstackFieldUid); - + if (value !== null && value !== undefined && value !== '') { const numValue = typeof value === 'number' ? value : Number(value); if (!isNaN(numValue)) { @@ -705,88 +995,173 @@ function processFieldsRecursive( } case 'json': { const aemFieldName = field?.otherCmsField ? getLastKey(field.otherCmsField, ' > ') : field?.uid; - const value = getFieldValue(items, aemFieldName); + const value = getFieldValue(items, aemFieldName); const uid = getLastKey(field?.contentstackFieldUid); - + const actualUid = getActualFieldUid(uid, field?.uid); + let htmlContent = ''; - + if (typeof value === 'string') { htmlContent = value; } else if (value && typeof value === 'object') { htmlContent = value.text || value.content || ''; } - + const jsonData = attachJsonRte({ content: htmlContent }); - obj[uid] = jsonData; + obj[actualUid] = jsonData; break; } - case 'html': { + case 'html': { const aemFieldName = field?.otherCmsField ? getLastKey(field?.otherCmsField, ' > ') : field?.uid; - const value = getFieldValue(items, aemFieldName); + const value = getFieldValue(items, aemFieldName); const uid = getLastKey(field?.contentstackFieldUid); - + const actualUid = getActualFieldUid(uid, field?.uid); let htmlContent = ''; - + if (typeof value === 'string') { htmlContent = value; } else if (value && typeof value === 'object') { htmlContent = value?.text || value?.content || ''; } - obj[uid] = htmlContent; + obj[actualUid] = htmlContent; break; } case 'link': { - const value = { - title: getFieldValue(items, 'title') ?? '', - href: getFieldValue(items, 'url') ?? '' - }; const uid = getLastKey(field?.contentstackFieldUid); + + const aemFieldName = field?.otherCmsField + ? getLastKey(field.otherCmsField, ' > ') + : 'link'; + + let linkUrl = getFieldValue(items, aemFieldName); + + if (!linkUrl) { + const urlCandidates = ['link', 'url', 'href', 'linkURL']; + for (const candidate of urlCandidates) { + const val = getFieldValue(items, candidate); + if (val) { + linkUrl = val; + break; + } + } + } + + let linkTitle = getFieldValue(items, 'title'); + + if (!linkTitle) { + const titleCandidates = ['text', 'label', 'linkText']; + for (const candidate of titleCandidates) { + const val = getFieldValue(items, candidate); + if (val) { + linkTitle = val; + break; + } + } + } + + const value = { + title: linkTitle ?? '', + href: linkUrl ?? '' + }; + obj[uid] = value; break; - } + } case 'file': { const uid = getLastKey(field?.contentstackFieldUid); - const aemFieldName = field?.otherCmsField ? getLastKey(field.otherCmsField, ' > ') : 'src'; - const imageSrc = getFieldValue(items, aemFieldName) || getFieldValue(items, 'src'); - - if (!imageSrc || !Object?.keys(pathToUidMap)?.length) { - obj[uid] = null; - break; + + const candidateKeys = [ + field?.otherCmsField ? getLastKey(field.otherCmsField, ' > ') : 'src', + 'src', + 'imagePath', + 'fileReference', + 'file', + 'path', + 'href', + 'url' + ]; + + let val: any = undefined; + for (const key of candidateKeys) { + const v = getFieldValue(items, key); + if (v !== undefined && v !== null && v !== '') { val = v; break; } } - const assetUid = pathToUidMap[imageSrc]; - - if (assetUid) { - const assetDetails = assetDetailsMap?.[assetUid]; - - if (assetDetails) { - obj[uid] = { - uid: assetDetails?.uid, - filename: assetDetails?.filename, - content_type: assetDetails?.content_type, - file_size: assetDetails?.file_size, - title: assetDetails?.title, - url: assetDetails?.url, - tags: assetDetails?.tags || [], - publish_details: assetDetails?.publish_details || [], - parent_uid: assetDetails?.parent_uid || null, - is_dir: false, - ACL: assetDetails?.ACL || [] - }; - } else { - obj[uid] = { - uid: assetUid - }; + + const toArray = (v: any) => Array.isArray(v) ? v : (v != null ? [v] : []); + const rawSrcs = toArray(val).map(v => { + if (v && typeof v === 'object') { + return ( + v.src || + v.imagePath || + v.fileReference || + v.path || + v.url || + v.href || + null + ); } - } else { + return v; + }).filter(Boolean); + + if (!rawSrcs.length || !Object?.keys(pathToUidMap)?.length) { obj[uid] = null; + break; } + + const normalize = (p: string) => (p || '') + .trim() + .replace(/\?.*$/, '') + .replace(/^https?:\/\/[^/]+/, '') + .replace(/\/jcr:content.*$/, '') + .replace(/\/_jcr_content.*$/, '') + .replace(/\.transform\/.*$/, ''); + + const mapOne = (s: string) => { + const n = normalize(s); + const candidates = [s, n, decodeURI(n), encodeURI(n)]; + + let assetUid: string | undefined; + for (const c of candidates) { + if (pathToUidMap?.[c]) { + assetUid = pathToUidMap[c]; + break; + } + } + + if (!assetUid) { + console.warn('[ASSET MISS file]', { raw: s, normalized: n }); + return null; + } + + const det = assetDetailsMap?.[assetUid]; + + return det ? { + uid: det.uid, + filename: det.filename, + content_type: det.content_type, + file_size: det.file_size, + title: det.title, + url: det.url, + tags: det.tags || [], + publish_details: det.publish_details || [], + parent_uid: det.parent_uid || null, + is_dir: false, + ACL: det.ACL || [] + } : { uid: assetUid }; + }; + + const mapped = rawSrcs.map(mapOne).filter(Boolean); + const isMultiple = Boolean(field?.multiple || field?.maxInstance > 1 || field?.advanced?.multiple); + + obj[uid] = isMultiple ? mapped : (mapped[0] || null); break; - } + } + case 'app': { break; - } + } default: { - console.info("🚀 ~ processFieldsRecursive ~ childItems:", field?.uid, field?.contentstackFieldType); + console.info("🚀 ~ processFieldsRecursive ~ childItems", field?.uid, field?.contentstackFieldType); break; } } @@ -795,9 +1170,9 @@ function processFieldsRecursive( } const containerCreator = ( - fieldMapping: any, - items: any, - title: string, + fieldMapping: any, + items: any, + title: string, pathToUidMap: Record, assetDetailsMap: Record ) => { @@ -809,6 +1184,20 @@ const getTitle = (parseData: any) => { return parseData?.title ?? parseData?.templateType; } + +/** + * + * @param packagePath - The path to the package. + * @param contentTypes - The content types. + * @param destinationStackId - The ID of the destination stack. + * @param projectId - The ID of the project. + * @param project - The project object. + * @returns void - The function does not return a value. + * @description - This function creates the entry for the given destination stack. + * The entry is created in the following files: + * - index.json - The entry index. + * @throws Will log an error if the entry is not created. + */ const createEntry = async ({ packagePath, contentTypes, @@ -822,7 +1211,7 @@ const createEntry = async ({ const assetsSave = path.join(baseDir, ASSETS_DIR_NAME); const pathMappingFile = path.join(assetsSave, 'path-mapping.json'); const assetIndexFile = path.join(assetsSave, ASSETS_SCHEMA_FILE); - + let pathToUidMap: Record = {}; let assetDetailsMap: Record = {}; @@ -848,7 +1237,7 @@ const createEntry = async ({ const allLocales: object = { ...project?.master_locale, ...project?.locales }; const entryMapping: Record = {}; - // FIRST PASS: Process all entries and build mappings + // Process each entry file for await (const fileName of read(entriesDir)) { const filePath = path.join(entriesDir, fileName); if (filePath?.startsWith?.(damPath)) { @@ -924,7 +1313,20 @@ const createEntry = async ({ } } - +/** + * + * @param req - The request object. + * @param destinationStackId - The ID of the destination stack. + * @param projectId - The ID of the project. + * @param project - The project object. + * @returns void - The function does not return a value. + * @description - This function creates the locales for the given destination stack. + * The locales are created in the following files: + * - locale.json - The master locale. + * - allLocales.json - All the locales. + * @throws Will log an error if the file writing operation fails. + * @throws Will log an error if the locales are not created. + */ const createLocale = async ( req: Request, destinationStackId: string, @@ -999,7 +1401,16 @@ const createLocale = async ( } }; - +/** + * + * @param destinationStackId - The ID of the destination stack. + * @returns void - The function does not return a value. + * @description - This function creates a version file for the given destination stack. + * The version file contains the following information: + * - contentVersion: The version of the content schema (set to `2`). + * - logsPath: An empty string reserved for future log path information. + * @throws Will log an error if the file writing operation fails. + */ const createVersionFile = async (destinationStackId: string) => { const baseDir = path.join(baseDirName, destinationStackId); fs.writeFile( @@ -1016,7 +1427,6 @@ const createVersionFile = async (destinationStackId: string) => { ); }; - export const aemService = { createAssets, createEntry, diff --git a/upload-api/migration-aem/helper/index.ts b/upload-api/migration-aem/helper/index.ts index 011402e4f..ba7a9cbeb 100644 --- a/upload-api/migration-aem/helper/index.ts +++ b/upload-api/migration-aem/helper/index.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import read from 'fs-readdir-recursive'; import { IReadFiles } from "./types/index.interface"; import { MergeStrategy } from "./types/index.interface"; import { v4 as uuidv4 } from "uuid"; @@ -242,10 +243,9 @@ export function countComponentTypes(component: any, result: Record): void { + if (!obj || typeof obj !== 'object') return; + + const typeValue = obj[':type'] || obj['type']; + const isCarousel = typeof typeValue === 'string' && typeValue.includes('carousel'); + + if (isCarousel && obj[':items']) { + // Get the items object + const items = obj[':items']; + + if (items && typeof items === 'object') { + // Iterate through each item in the carousel + for (const [key, value] of Object.entries(items)) { + if (value && typeof value === 'object') { + const itemType = (value as any)[':type']; + + if (itemType && typeof itemType === 'string') { + // Extract component name from path + const componentName = itemType.split('/').pop(); + if (componentName) { + itemTypes.add(componentName); + } + } + } + } + } + } + + // Recursively search nested objects + if (Array.isArray(obj)) { + obj.forEach(item => findCarouselsRecursive(item, itemTypes)); + } else { + Object.values(obj).forEach(value => findCarouselsRecursive(value, itemTypes)); + } +} + +/** + * Scans all JSON files to find all unique component types used in carousel items + * @param packagePath - Path to the AEM package + * @returns Set of all component types found in any carousel + */ +export async function scanAllCarouselItemTypes(packagePath: string): Promise> { + const carouselItemTypes = new Set(); + const filesDir = path.resolve(packagePath); + + try { + const allFiles = read(filesDir); + const jsonFiles = allFiles.filter(f => f.endsWith('.json')); + + for (const fileName of jsonFiles) { + const filePath = path.join(filesDir, fileName); + + try { + const content = await fs.promises.readFile(filePath, 'utf-8'); + const data = JSON.parse(content); + + // Recursively search for carousel components + findCarouselsRecursive(data, carouselItemTypes); + + } catch (err) { + // Skip invalid JSON files + continue; + } + } + return carouselItemTypes; + } catch (err) { + console.error('❌ Error scanning carousel items:', err); + return carouselItemTypes; + } } \ No newline at end of file diff --git a/upload-api/migration-aem/libs/contentType/components/CarouselComponent.ts b/upload-api/migration-aem/libs/contentType/components/CarouselComponent.ts index ee9212c0d..a0885bc7c 100644 --- a/upload-api/migration-aem/libs/contentType/components/CarouselComponent.ts +++ b/upload-api/migration-aem/libs/contentType/components/CarouselComponent.ts @@ -1,4 +1,4 @@ -import { countComponentTypes, findFirstComponentByType, uidCorrector } from "../../../helper"; +import { countComponentTypes, findFirstComponentByType, uidCorrector, scanAllCarouselItemTypes } from "../../../helper"; import { ContentstackComponent } from "../fields"; import { BooleanField, GroupField, TextField } from "../fields/contentstackFields"; import { ButtonComponent, TeaserComponent, ImageComponent, TextBannerComponent, TextComponent, TitleComponent, SearchComponent, SpacerComponent, SeparatorComponent } from "./index"; @@ -12,14 +12,45 @@ const carouselExclude = [ ':itemsOrder' ]; +let globalCarouselItemTypes: Set | null = null; + +const globalComponentSchemas: Map = new Map(); + export class CarouselComponent extends ContentstackComponent { + + /** + * Initialize carousel item types by scanning all files + * Call this BEFORE processing any components + */ + static async initializeCarouselItemTypes(packagePath: string): Promise { + if (!globalCarouselItemTypes) { + globalCarouselItemTypes = await scanAllCarouselItemTypes(packagePath); + } + } + + /** + * Store a component schema for later reuse + */ + static storeComponentSchema(componentType: string, schema: any): void { + if (!globalComponentSchemas?.has(componentType)) { + globalComponentSchemas.set(componentType, schema); + } + } + + /** + * Get a stored component schema + */ + static getStoredComponentSchema(componentType: string): any | null { + return globalComponentSchemas?.get(componentType) || null; + } + static isCarousel(component: any): boolean { const properties = component?.convertedSchema?.properties; if (properties && typeof properties === 'object') { const typeField = properties[":type"]; if ( - (typeof typeField === "string" && typeField.includes("/components/carousel")) || - (typeof typeField === "object" && typeField.value?.includes("/components/carousel")) + (typeof typeField === "string" && typeField?.includes("/components/carousel")) || + (typeof typeField === "object" && typeField?.value?.includes("/components/carousel")) ) { return true; } @@ -48,81 +79,222 @@ export class CarouselComponent extends ContentstackComponent { defaultValue: "" }).toContentstack(), object: (fieldKey: string, schemaProp: SchemaProperty): any => { - const normalizedUid = uidCorrector(fieldKey) + const normalizedUid = uidCorrector(fieldKey); const countObject = countComponentTypes(schemaProp.properties); const schema: any[] = []; - for (const [type, count] of Object.entries(countObject as Record)) { - const data = findFirstComponentByType(schemaProp?.properties, type); - if (!data) continue; // skip if not found + // Check if this is the carousel items object + const isCarouselItems = normalizedUid === 'items' || fieldKey === 'items' || fieldKey === ':items'; - // Correct way to build the component object - const component = { convertedSchema: { type: 'object', properties: data } }; - const isMultiple = count > 1; - - const componentActions: [ - any, string, (comp: any, isMultiple?: boolean) => void - ][] = [ - [ImageComponent, "isImage", (comp, isMultiple) => { - const imageData = ImageComponent.mapImageToContentstack(comp, "image"); - if (imageData && isMultiple) imageData.advanced.multiple = true; - if (imageData) schema.push(imageData); - }], - [TeaserComponent, "isTeaser", (comp, isMultiple) => { - const teaserData = TeaserComponent.mapTeaserToContentstack(comp, "teaser"); - if (teaserData && isMultiple) teaserData.advanced.multiple = true; - if (teaserData) schema.push(teaserData); - }], - [ButtonComponent, "isButton", (comp, isMultiple) => { - const buttonData = ButtonComponent.mapButtonToContentstack(comp, "button"); - if (buttonData && isMultiple) buttonData.advanced.multiple = true; - if (buttonData) schema.push(buttonData); - }], - [TextBannerComponent, "isTextBanner", (comp, isMultiple) => { - const textBannerData = TextBannerComponent.mapTextBannerToContentstack(comp, "textBanner"); - if (textBannerData && isMultiple) textBannerData.advanced.multiple = true; - if (textBannerData) schema.push(textBannerData); - }], - [TextComponent, "isText", (comp, isMultiple) => { - const textData = TextComponent.mapTextToContentstack(comp); - if (textData && isMultiple) textData.multiple = true; - if (textData) schema.push(textData); - }], - [TitleComponent, "isTitle", (comp, isMultiple) => { - const titleData = TitleComponent.mapTitleToContentstack(comp, "title"); - if (titleData && isMultiple) titleData.multiple = true; - if (titleData) schema.push(titleData); - }], - [SearchComponent, "isSearch", (comp, isMultiple) => { - const searchData = SearchComponent.mapSearchToContentstack(comp, "search"); - if (searchData && isMultiple) searchData.multiple = true; - if (searchData) schema.push(searchData); - }], - [SpacerComponent, "isSpacer", (comp, isMultiple) => { - const spacerData = SpacerComponent.mapSpacerToContentstack(comp, "spacer"); - if (spacerData && isMultiple) spacerData.multiple = true; - if (spacerData) schema.push(spacerData); - }], - [SeparatorComponent, "isSeparator", (comp, isMultiple) => { - const separatorData = SeparatorComponent.mapSeparatorToContentstack(comp, "separator"); - if (separatorData && isMultiple) separatorData.multiple = true; - if (separatorData) schema.push(separatorData); - }], - ]; - - componentActions.forEach(([Comp, method, action]) => { - if (Comp?.[method]?.(component)) { - action(component, isMultiple); + if (isCarouselItems) { + const typesToProcess = globalCarouselItemTypes && globalCarouselItemTypes?.size > 0 + ? globalCarouselItemTypes + : new Set(Object.keys(countObject).map(t => t.split('/').pop()).filter(Boolean)); + + // Process each component type + for (const componentType of typesToProcess) { + const fullType = `baem/components/${componentType}`; + const currentData = findFirstComponentByType(schemaProp?.properties, fullType); + const currentComponent = currentData ? { convertedSchema: { type: 'object', properties: currentData } } : null; + + // Map each component type + if (componentType === 'teaser' || componentType === 'heroTeaser' || componentType === 'overlayBoxTeaser') { + + let teaserData = null; + if (currentComponent && TeaserComponent?.isTeaser(currentComponent)) { + teaserData = TeaserComponent?.mapTeaserToContentstack(currentComponent, "teaser"); + if (teaserData) { + //store for reuse + CarouselComponent?.storeComponentSchema('teaser', teaserData); + } + } else { + // Try to get from stored schemas + teaserData = CarouselComponent?.getStoredComponentSchema('teaser'); + if (teaserData) { + console.log('Reusing previously stored teaser schema'); + } else { + console.warn('No teaser schema available (not in current file and not stored yet)'); + } + } + + if (teaserData) { + const clonedTeaserData = JSON.parse(JSON.stringify(teaserData)); + clonedTeaserData.advanced = { + ...clonedTeaserData.advanced, + multiple: true, + mandatory: false + }; + schema.push(clonedTeaserData); + } + } else if (componentType === 'image') { + + let imageData = null; + if (currentComponent && ImageComponent?.isImage(currentComponent)) { + imageData = ImageComponent.mapImageToContentstack(currentComponent, "image"); + if (imageData) { + // Store for future reuse + CarouselComponent?.storeComponentSchema('image', imageData); + } + } else { + // Try to get from stored schemas + imageData = CarouselComponent?.getStoredComponentSchema('image'); + if (imageData) { + console.log('Reusing previously stored image schema'); + } + } + + if (imageData) { + const clonedImageData = JSON.parse(JSON.stringify(imageData)); + clonedImageData.advanced = { + ...clonedImageData.advanced, + multiple: true, + mandatory: false + }; + schema.push(clonedImageData); + } + } else if (componentType === 'button') { + let buttonData = null; + + if (currentComponent && ButtonComponent?.isButton(currentComponent)) { + buttonData = ButtonComponent.mapButtonToContentstack(currentComponent, "button"); + if (buttonData) { + CarouselComponent?.storeComponentSchema('button', buttonData); + } + } else { + buttonData = CarouselComponent?.getStoredComponentSchema('button'); + } + + if (buttonData) { + const clonedData = JSON.parse(JSON.stringify(buttonData)); + clonedData.advanced = { + ...clonedData.advanced, + multiple: true, + mandatory: false + }; + schema.push(clonedData); + } + } else if (componentType === 'textbanner' || componentType === 'textBanner') { + let textBannerData = null; + + if (currentComponent && TextBannerComponent?.isTextBanner(currentComponent)) { + textBannerData = TextBannerComponent?.mapTextBannerToContentstack(currentComponent, "textBanner"); + if (textBannerData) { + CarouselComponent?.storeComponentSchema('textbanner', textBannerData); + } + } else { + textBannerData = CarouselComponent?.getStoredComponentSchema('textbanner'); + } + + if (textBannerData) { + const clonedData = JSON.parse(JSON.stringify(textBannerData)); + clonedData.advanced = { + ...clonedData.advanced, + multiple: true, + mandatory: false + }; + schema.push(clonedData); + } + } else if (componentType === 'text') { + let textData = null; + + if (currentComponent && TextComponent?.isText(currentComponent)) { + textData = TextComponent?.mapTextToContentstack(currentComponent); + if (textData) { + CarouselComponent?.storeComponentSchema('text', textData); + } + } else { + textData = CarouselComponent?.getStoredComponentSchema('text'); + } + + if (textData) { + const clonedData = JSON.parse(JSON.stringify(textData)); + clonedData.multiple = true; + schema.push(clonedData); + } + } else if (componentType === 'title') { + let titleData = null; + + if (currentComponent && TitleComponent?.isTitle(currentComponent)) { + titleData = TitleComponent?.mapTitleToContentstack(currentComponent, "title"); + if (titleData) { + CarouselComponent?.storeComponentSchema('title', titleData); + } + } else { + titleData = CarouselComponent?.getStoredComponentSchema('title'); + } + + if (titleData) { + const clonedData = JSON.parse(JSON.stringify(titleData)); + clonedData.multiple = true; + schema.push(clonedData); + } + } else if (componentType === 'search') { + let searchData = null; + + if (currentComponent && SearchComponent?.isSearch(currentComponent)) { + searchData = SearchComponent?.mapSearchToContentstack(currentComponent, "search"); + if (searchData) { + CarouselComponent?.storeComponentSchema('search', searchData); + } + } else { + searchData = CarouselComponent?.getStoredComponentSchema('search'); + } + + if (searchData) { + const clonedData = JSON.parse(JSON.stringify(searchData)); + clonedData.multiple = true; + schema.push(clonedData); + } + } else if (componentType === 'spacer') { + let spacerData = null; + + if (currentComponent && SpacerComponent?.isSpacer(currentComponent)) { + spacerData = SpacerComponent?.mapSpacerToContentstack(currentComponent, "spacer"); + if (spacerData) { + CarouselComponent?.storeComponentSchema('spacer', spacerData); + } + } else { + spacerData = CarouselComponent?.getStoredComponentSchema('spacer'); + } + + if (spacerData) { + const clonedData = JSON.parse(JSON.stringify(spacerData)); + clonedData.multiple = true; + schema.push(clonedData); + } + } else if (componentType === 'separator') { + let separatorData = null; + + if (currentComponent && SeparatorComponent?.isSeparator(currentComponent)) { + separatorData = SeparatorComponent?.mapSeparatorToContentstack(currentComponent, "separator"); + if (separatorData) { + CarouselComponent?.storeComponentSchema?.('separator', separatorData); + } + } else { + separatorData = CarouselComponent?.getStoredComponentSchema?.('separator'); + } + + if (separatorData) { + const clonedData = JSON.parse(JSON.stringify(separatorData)); + clonedData.multiple = true; + schema.push(clonedData); + } + } else { + console.warn(`Unknown carousel item type: ${componentType}`); } - }); + } } + if (schema?.length === 0) { + console.warn('Carousel items schema is empty! No components were mapped.'); + } + return new GroupField({ uid: normalizedUid, displayName: normalizedUid, fields: schema, required: false, multiple: false - }).toContentstack() + }).toContentstack(); }, array: () => null, }; @@ -131,14 +303,17 @@ export class CarouselComponent extends ContentstackComponent { const componentSchema = component?.convertedSchema; if (componentSchema?.type === 'object' && componentSchema?.properties) { const fields: any[] = []; - for (const [key, value] of Object.entries(componentSchema.properties)) { + for (const [key, value] of Object.entries(componentSchema?.properties)) { if (!carouselExclude.includes(key)) { const schemaProp = value as SchemaProperty; - if (schemaProp?.type && CarouselComponent.fieldTypeMap[schemaProp.type]) { - fields.push(CarouselComponent.fieldTypeMap[schemaProp.type](key, schemaProp)); + if (schemaProp?.type && CarouselComponent?.fieldTypeMap?.[schemaProp?.type]) { + const mappedField = CarouselComponent?.fieldTypeMap?.[schemaProp?.type](key, schemaProp); + if (mappedField) { + fields.push(mappedField); + } } } - } + } return { ...new GroupField({ uid: parentKey, diff --git a/upload-api/migration-aem/libs/contentType/components/TeaserComponent.ts b/upload-api/migration-aem/libs/contentType/components/TeaserComponent.ts index 757b5684d..effefe873 100644 --- a/upload-api/migration-aem/libs/contentType/components/TeaserComponent.ts +++ b/upload-api/migration-aem/libs/contentType/components/TeaserComponent.ts @@ -1,6 +1,7 @@ + import { isImageType } from "../../../helper"; import { ContentstackComponent } from "../fields"; -import { BooleanField, GroupField, ImageField, LinkField, TextField } from "../fields/contentstackFields"; +import { BooleanField, Field, GroupField, ImageField, LinkField, TextField } from "../fields/contentstackFields"; import { SchemaProperty } from "./index.interface"; @@ -22,26 +23,21 @@ function uidContainsNumber(uid: string): boolean { export class TeaserComponent extends ContentstackComponent { static isTeaser(component: any): boolean { - const properties = component?.convertedSchema?.properties; + const properties = component?.convertedSchema?.properties; if (properties && typeof properties === 'object') { const typeField = properties[":type"]; - if ( - (typeof typeField === "string" && - (/\/components\/(teaser|heroTeaser|overlayBoxTeaser)/.test(typeField) || - typeField.includes("/productCategoryTeaserList")) - ) || - (typeof typeField === "object" && - (/\/components\/(teaser|heroTeaser|overlayBoxTeaser)/.test(typeField.value ?? "") || - (typeField.value ?? "").includes("/productCategoryTeaserList")) - ) - ) { - return true; - } + const typeValue = typeof typeField === "string" ? typeField : typeField?.value; + + const isTeaser = + /\/components\/(teaser|heroTeaser|overlayBoxTeaser)/.test(typeValue ?? "") || + (typeValue ?? "").includes("/productCategoryTeaserList"); + + return isTeaser; } + return false; } - static fieldTypeMap: Record any> = { string: (key, schemaProp, isImg) => isImg ? @@ -69,14 +65,21 @@ export class TeaserComponent extends ContentstackComponent { defaultValue: "" }).toContentstack(), object: (key, schemaProp) => { - const data = { convertedSchema: schemaProp } - const objectData = this.mapTeaserToContentstack(data, key); - if (objectData?.uid && (uidContainsNumber(objectData?.uid) === false) && objectData?.schema?.length) { + const data = { convertedSchema: schemaProp }; + const objectData = TeaserComponent.mapTeaserToContentstack(data, key); + // Accept either `schema` or `fields` depending on what toContentstack returns + const hasFieldsArray = + !!objectData && ( + (Array.isArray((objectData as any).schema) && (objectData as any).schema.length > 0) || + (Array.isArray((objectData as any).fields) && (objectData as any).fields.length > 0) + ); + + if (objectData?.uid && !uidContainsNumber(objectData?.uid) && hasFieldsArray) { return objectData; } const urlValue = schemaProp?.properties?.url?.value; if (urlValue !== undefined) { - return new LinkField({ + return new TextField({ uid: key, displayName: key, description: "", @@ -84,58 +87,145 @@ export class TeaserComponent extends ContentstackComponent { }).toContentstack(); } return null; - }, + }, array: (key, schemaProp) => { - if ( - schemaProp?.type === 'array' && - schemaProp?.items?.properties && - Object.keys(schemaProp?.items?.properties)?.length - ) { - const componentsData: any[] = []; - for (const [key, value] of Object.entries(schemaProp.items.properties)) { - const schemaProp = value as SchemaProperty; - if ( - !teaserExclude.includes(key) && - schemaProp?.type && - TeaserComponent.fieldTypeMap[schemaProp.type] - ) { - const isImg = isImageType(schemaProp?.value) - componentsData.push( - TeaserComponent.fieldTypeMap[schemaProp.type](key, schemaProp, isImg) - ); - } - } - return componentsData?.length ? new GroupField({ - uid: key, + // Special-case for actions array + if (key === 'actions') { + + const actionFields = [ + new TextField({ + uid: 'title', + displayName: 'title', + description: '', + defaultValue: '', + }), + new TextField({ + uid: 'url', + displayName: 'url', + description: '', + defaultValue: '' + }) + ]; + + // Return GroupField instance + return new GroupField({ + uid: key, displayName: key, - fields: componentsData, + fields: actionFields, required: false, multiple: true - }).toContentstack() : null; + }).toContentstack(); + } + const inferItemSample = (): any | null => { + if (schemaProp?.items?.properties && Object.keys(schemaProp.items.properties).length) { + return { from: 'items.properties', properties: schemaProp.items.properties }; + } + if (schemaProp?.items?.value && Array.isArray(schemaProp.items.value) && schemaProp.items.value[0] && typeof schemaProp.items.value[0] === 'object') { + return { from: 'items.value', sample: schemaProp.items.value[0] }; + } + if (Array.isArray(schemaProp?.value) && schemaProp.value[0] && typeof schemaProp.value[0] === 'object') { + return { from: 'value', sample: schemaProp.value[0] }; + } + return null; + }; + + const inferred = inferItemSample(); + if (!inferred) { + console.warn(`Array field "${key}" had no schema or sample items to infer from`); + return null; + } + + let itemProperties: Record | null = null; + + if (inferred.from === 'items.properties') { + itemProperties = inferred.properties; + } else if (inferred.from === 'items.value' || inferred.from === 'value') { + const sample = inferred.sample; + itemProperties = {}; + for (const k of Object.keys(sample)) { + itemProperties[k] = { type: typeof sample[k] === 'number' ? 'integer' : 'string', value: sample[k] }; + } + } + + if (!itemProperties || !Object.keys(itemProperties).length) { + console.warn(`After inference, no item properties found for "${key}"`); + return null; } + + const componentsData: Field[] = []; // Array of Field instances + + for (const [itemKey, itemProp] of Object.entries(itemProperties)) { + const ik = String(itemKey); + const inferredType = (itemProp?.type ?? 'string').toLowerCase(); + + if (inferredType === 'string' || inferredType === 'integer') { + // Create Field instance + componentsData.push(new TextField({ + uid: ik, + displayName: ik, + description: "", + defaultValue: "", + isNumber: inferredType === 'integer' + })); + continue; + } + + if (inferredType === 'object' && itemProp?.properties) { + const nested = TeaserComponent.fieldTypeMap.object(ik, itemProp as SchemaProperty, false); + if (nested) { + componentsData.push(nested); + } + continue; + } + } + if (!componentsData.length) { + console.warn(`No components generated for array field "${key}" after inference`); + return null; + } + + // Return GroupField instance + return new GroupField({ + uid: key, + displayName: key, + fields: componentsData, + required: false, + multiple: true + }).toContentstack(); }, }; - - static mapTeaserToContentstack(component: any, parentKey: any) { const componentSchema = component?.convertedSchema; if (componentSchema?.type === 'object' && componentSchema?.properties) { - const componentsData: any[] = []; + const componentsData: any[] = []; for (const [key, value] of Object.entries(componentSchema.properties)) { const schemaProp = value as SchemaProperty; - if ( - !teaserExclude.includes(key) && - schemaProp?.type && - TeaserComponent.fieldTypeMap[schemaProp.type] - ) { - const isImg = isImageType(schemaProp?.value) - componentsData.push( - TeaserComponent.fieldTypeMap[schemaProp.type](key, schemaProp, isImg) - ); + if (teaserExclude.includes(key)) { + continue; + } + + if (schemaProp?.type && TeaserComponent.fieldTypeMap[schemaProp.type]) { + const isImg = isImageType(schemaProp?.value); + const fieldData = TeaserComponent.fieldTypeMap[schemaProp.type](key, schemaProp, isImg); + + if (fieldData) { + componentsData.push(fieldData); + } else { + console.warn(`Field mapping returned null for: ${key} (type: ${schemaProp.type})`); + } + } else { + console.warn(`No field type mapper for: ${key}`, { + type: schemaProp?.type, + availableMappers: Object.keys(TeaserComponent.fieldTypeMap) + }); } + } + if (componentsData.length === 0) { + console.warn('No fields were generated for teaser component!'); + return null; } - return componentsData?.length ? { + + return { ...new GroupField({ uid: parentKey, displayName: parentKey, @@ -144,7 +234,8 @@ export class TeaserComponent extends ContentstackComponent { multiple: true }).toContentstack(), type: component?.convertedSchema?.properties?.[":type"]?.value - } : null; + }; } + return null; } } \ No newline at end of file diff --git a/upload-api/migration-aem/libs/contentType/components/TextBannerComponent.ts b/upload-api/migration-aem/libs/contentType/components/TextBannerComponent.ts index 04fd54c99..4273cf709 100644 --- a/upload-api/migration-aem/libs/contentType/components/TextBannerComponent.ts +++ b/upload-api/migration-aem/libs/contentType/components/TextBannerComponent.ts @@ -14,12 +14,11 @@ export class TextBannerComponent extends ContentstackComponent { const properties = component?.convertedSchema?.properties; if (properties && typeof properties === 'object') { const typeField = properties[":type"]; - if ( + const isTextBanner = ( (typeof typeField === "string" && typeField.includes("/components/textbanner")) || (typeof typeField === "object" && typeField.value?.includes("/components/textbanner")) - ) { - return true; - } + ); + return isTextBanner; } return false; } @@ -85,20 +84,41 @@ export class TextBannerComponent extends ContentstackComponent { static mapTextBannerToContentstack(component: any, parentKey: any) { const componentSchema = component?.convertedSchema; if (componentSchema?.type === 'object' && componentSchema?.properties) { - const fields: any[] = []; + const fields: any[] = []; for (const [key, value] of Object.entries(componentSchema.properties)) { - if (!textBannerExclude.includes(key)) { - const schemaProp = value as SchemaProperty; - if (schemaProp?.type && TextBannerComponent.fieldTypeMap[schemaProp.type]) { - fields.push(TextBannerComponent.fieldTypeMap[schemaProp.type](key, schemaProp)); + const schemaProp = value as SchemaProperty; + + if (textBannerExclude.includes(key)) { + console.log(`⏭️ Skipping excluded key: ${key}`); + continue; + } + + if (schemaProp?.type && TextBannerComponent.fieldTypeMap[schemaProp.type]) { + const field = TextBannerComponent.fieldTypeMap[schemaProp.type](key, schemaProp); + + if (field) { + fields.push(field); + } else { + console.warn(`Field mapping returned null for: ${key} (type: ${schemaProp.type})`); } + } else { + console.warn(`No field type mapper for: ${key}`, { + type: schemaProp?.type, + availableMappers: Object.keys(TextBannerComponent.fieldTypeMap) + }); } } + + if (fields.length === 0) { + console.warn('No fields were generated for textbanner component!'); + return null; + } + return { ...new GroupField({ uid: parentKey, displayName: parentKey, - fields, + fields: fields, required: false, multiple: false }).toContentstack(), diff --git a/upload-api/migration-aem/libs/contentType/fields/contentstackFields/index.ts b/upload-api/migration-aem/libs/contentType/fields/contentstackFields/index.ts index a6d00ed88..034f024e6 100644 --- a/upload-api/migration-aem/libs/contentType/fields/contentstackFields/index.ts +++ b/upload-api/migration-aem/libs/contentType/fields/contentstackFields/index.ts @@ -184,6 +184,12 @@ export class GroupField extends Field { } toContentstack() { + const processedSchema = this.fields.filter(Boolean).map(field => { + if (field && typeof field.toContentstack === 'function') { + return field.toContentstack(); + } + return field; + }); return { id: this.generateId(), uid: this.uid, @@ -194,7 +200,7 @@ export class GroupField extends Field { contentstackFieldType: 'group', backupFieldType: 'group', backupFieldUid: this.uid, - schema: this.fields.filter(Boolean), + schema: processedSchema, advanced: { mandatory: !!this.required, multiple: this.multiple, diff --git a/upload-api/migration-aem/libs/contentType/index.ts b/upload-api/migration-aem/libs/contentType/index.ts index 2f3defee9..6e7533a8d 100644 --- a/upload-api/migration-aem/libs/contentType/index.ts +++ b/upload-api/migration-aem/libs/contentType/index.ts @@ -42,6 +42,7 @@ function processComponents(components: Record | Record const mappingRules = [ () => BreadcrumbComponent.isBreadcrumb(component) && BreadcrumbComponent.mapBreadcrumbToContentstack(component, key), () => TitleComponent.isTitle(component) && TitleComponent.mapTitleToContentstack(component, key), + () => TextBannerComponent.isTextBanner(component) && TextBannerComponent.mapTextBannerToContentstack(component, key), () => TextComponent.isText(component) && TextComponent.mapTextToContentstack(component), () => NavigationComponent.isNavigation(component) && NavigationComponent.mapNavigationTOContentstack(component, key), () => NavigationComponent.isLanguageNavigation(component) && NavigationComponent.mapNavigationTOContentstack(component, key), @@ -53,7 +54,6 @@ function processComponents(components: Record | Record () => CustomEmbedComponent.isCustomEmbed(component) && CustomEmbedComponent.mapCustomEmbedToContentstack(component, key), () => ProductListingComponent.isProductListing(component) && ProductListingComponent.mapProductListingToContentstack(component, key), () => ButtonComponent.isButton(component) && ButtonComponent.mapButtonToContentstack(component, key), - () => TextBannerComponent.isTextBanner(component) && TextBannerComponent.mapTextBannerToContentstack(component, key), () => ImageComponent.isImage(component) && ImageComponent.mapImageToContentstack(component, key), () => CarouselComponent.isCarousel(component) && CarouselComponent.mapCarouselToContentstack(component, key), @@ -84,9 +84,78 @@ const mergeChildComponent = (contentstackComponents: any) => { return contentstackComponents; } +/** + * Pre-process components to build schema cache for carousel items + * This ensures all standalone components are mapped before carousels + */ +async function preBuildComponentSchemas(mergedComponents: Record) { + let schemasBuilt = 0; + + for (const [key, component] of Object.entries(mergedComponents)) { + // Process standalone components and store their schemas + if (TeaserComponent.isTeaser(component)) { + const schema = TeaserComponent.mapTeaserToContentstack(component, key); + if (schema) { + CarouselComponent.storeComponentSchema('teaser', schema); + schemasBuilt++; + } + } else if (ImageComponent.isImage(component)) { + const schema = ImageComponent.mapImageToContentstack(component, key); + if (schema) { + CarouselComponent.storeComponentSchema('image', schema); + schemasBuilt++; + } + } else if (ButtonComponent.isButton(component)) { + const schema = ButtonComponent.mapButtonToContentstack(component, key); + if (schema) { + CarouselComponent.storeComponentSchema('button', schema); + schemasBuilt++; + } + } else if (TextBannerComponent.isTextBanner(component)) { + const schema = TextBannerComponent.mapTextBannerToContentstack(component, key); + if (schema) { + CarouselComponent.storeComponentSchema('textbanner', schema); + schemasBuilt++; + } + } else if (TextComponent.isText(component)) { + const schema = TextComponent.mapTextToContentstack(component); + if (schema) { + CarouselComponent.storeComponentSchema('text', schema); + schemasBuilt++; + } + } else if (TitleComponent.isTitle(component)) { + const schema = TitleComponent.mapTitleToContentstack(component, key); + if (schema) { + CarouselComponent.storeComponentSchema('title', schema); + schemasBuilt++; + } + } else if (SearchComponent.isSearch(component)) { + const schema = SearchComponent.mapSearchToContentstack(component, key); + if (schema) { + CarouselComponent.storeComponentSchema('search', schema); + schemasBuilt++; + } + } else if (SpacerComponent.isSpacer(component)) { + const schema = SpacerComponent.mapSpacerToContentstack(component, key); + if (schema) { + CarouselComponent.storeComponentSchema('spacer', schema); + schemasBuilt++; + } + } else if (SeparatorComponent.isSeparator(component)) { + const schema = SeparatorComponent.mapSeparatorToContentstack(component, key); + if (schema) { + CarouselComponent.storeComponentSchema('separator', schema); + schemasBuilt++; + } + } + } + +} const convertContentType: IConvertContentType = async (dirPath) => { const templatesDir = path.resolve(dirPath); + await CarouselComponent.initializeCarouselItemTypes(templatesDir); + const templateFiles = read(templatesDir); const damPath = path?.resolve?.(path?.join?.(templatesDir, CONSTANTS.AEM_DAM_DIR)); const allComponentData: Record[] = []; @@ -100,7 +169,9 @@ const convertContentType: IConvertContentType = async (dirPath) => { const trackerData = tracker.getAllComponents(); allComponentData.push(trackerData); } + const mergedComponents = mergeComponentObjects(allComponentData); + await preBuildComponentSchemas(mergedComponents); const contentstackComponents: any = processComponents(mergedComponents); const mergeChildData = mergeChildComponent(contentstackComponents); await writeJsonFile(mergeChildData, CONSTANTS.TMP_FILE);