diff --git a/api/src/services/contentful.service.ts b/api/src/services/contentful.service.ts index 611efd67..37af61b8 100644 --- a/api/src/services/contentful.service.ts +++ b/api/src/services/contentful.service.ts @@ -99,6 +99,36 @@ const mapLocales = ({ masterLocale, locale, locales, isNull = false }: any) => { } } +function resolveEntryFieldKey(entry: Record, baseKey: string): string | undefined { + if (baseKey in entry) return baseKey; + const snake = baseKey.replace(/([A-Z])/g, (m) => `_${m.toLowerCase()}`); + if (snake in entry) return snake; + return undefined; +} + +/** + * Maps Contentful content type id → field id → whether that field is localized in the export schema. + * Used so we only fan out values for fields with `localized: false`, not for localized fields that + * happen to have a single locale in the entry (missing translations). + */ +function buildContentfulFieldLocalizedByContentType( + contentTypesFromPackage: any[] +): Map> { + const byCt = new Map>(); + for (const ct of contentTypesFromPackage ?? []) { + const ctId = ct?.sys?.id; + if (!ctId) continue; + const byField = new Map(); + for (const f of ct?.fields ?? []) { + if (f?.id != null) { + byField.set(f.id, f.localized === true); + } + } + byCt.set(ctId, byField); + } + return byCt; +} + const transformCloudinaryObject = (input: any) => { const result: any = []; if (!Array.isArray(input)) { @@ -777,6 +807,7 @@ const createEntry = async (packagePath: any, destination_stack_id: string, proje const data = await fs.promises.readFile(packagePath, "utf8"); const entries = JSON.parse(data)?.entries; const content = JSON.parse(data)?.contentTypes; + const cfFieldLocalizedByCt = buildContentfulFieldLocalizedByContentType(content); const LocaleMapper = { masterLocale: project?.master_locale ?? LOCALE_MAPPER?.masterLocale, ...project?.locales ?? {} }; if (entries && entries.length > 0) { const assetId = await readFile(assetsSave, ASSETS_SCHEMA_FILE) ?? []; @@ -814,7 +845,7 @@ const createEntry = async (packagePath: any, destination_stack_id: string, proje entryData[name][lang] ??= {}; entryData[name][lang][id] ??= {}; locales.push(lang); - const fieldData = currentCT?.fieldMapping?.find?.((item: any) => (key === item?.uid) && (!["text", "url"]?.includes?.(item?.backupFieldType))); + const fieldData = currentCT?.fieldMapping?.find?.((item: any) => key === item?.uid); const newId = fieldData?.contentstackFieldUid ?? `${key}`?.replace?.(/[^a-zA-Z0-9]+/g, "_"); entryData[name][lang][id][newId] = processField( langValue, @@ -860,6 +891,44 @@ const createEntry = async (packagePath: any, destination_stack_id: string, proje ); }); }); + + // Non-localized Contentful fields (`localized: false` in the content type) only appear under + // one locale in exports. Copy them to every other locale branch so each slice is complete. + // Do not infer non-localized-ness from a single locale key — localized fields can legitimately + // have only one locale when translations are missing. + const entryLocaleKeys = new Set(); + for (const [, v] of Object?.entries?.(fields)) { + for (const lang of Object.keys(v as object)) { + entryLocaleKeys.add(lang); + } + } + const ct = contentTypes?.find((c: any) => c?.otherCmsUid === name); + for (const [key, value] of Object?.entries?.(fields)) { + const langs = Object?.keys(value as object); + if (langs?.length !== 1) continue; + const fd = ct?.fieldMapping?.find?.((item: any) => key === item?.uid); + const localizedInCf = cfFieldLocalizedByCt.get(name)?.get(key); + const explicitlyNonLocalized = + localizedInCf === false || + (localizedInCf === undefined && fd?.advanced?.nonLocalizable === true); + if (!explicitlyNonLocalized) continue; + const srcLang = langs[0]; + const newId = fd?.contentstackFieldUid ?? `${key}`?.replace?.(/[^a-zA-Z0-9]+/g, "_"); + const srcEntry = entryData[name][srcLang]?.[id] as Record | undefined; + if (!srcEntry) continue; + const fk = resolveEntryFieldKey(srcEntry, newId); + if (fk === undefined) continue; + for (const tgtLang of entryLocaleKeys) { + if (tgtLang === srcLang) continue; + entryData[name][tgtLang] ??= {}; + entryData[name][tgtLang][id] ??= {}; + const tgt = entryData[name][tgtLang][id] as Record; + if (tgt[fk] === undefined) { + tgt[fk] = srcEntry[fk]; + } + } + } + return entryData; }, {} @@ -867,11 +936,28 @@ const createEntry = async (packagePath: any, destination_stack_id: string, proje for await (const [newKey, values] of Object.entries(result)) { const currentCT = contentTypes?.find((ct: any) => ct?.otherCmsUid === newKey); const ctName = currentCT?.contentstackUid in mapperKeys ? - mapperKeys?.[currentCT?.contentstackUid] : (currentCT?.contentstackUid ?? newKey.replace(/([A-Z])/g, "_$1").toLowerCase()); - for await (const [localeKey, localeValues] of Object.entries( - values as { [key: string]: any } - )) { - const localeCode = mapLocales({ masterLocale: master_locale, locale: localeKey, locales: LocaleMapper, isNull: true }); + mapperKeys?.[currentCT?.contentstackUid] : (currentCT?.contentstackUid ?? newKey?.replace?.(/([A-Z])/g, "_$1")?.toLowerCase?.()); + const valuesByCfLocale = values as { [key: string]: { [uid: string]: Record } }; + const mergedByDestinationLocale: { [localeCode: string]: { [uid: string]: Record } } = {}; + for (const localeKey of Object.keys(valuesByCfLocale)) { + const localeValues = valuesByCfLocale[localeKey]; + if (!localeValues) continue; + const localeCode = mapLocales({ + masterLocale: master_locale, + locale: localeKey, + locales: LocaleMapper, + isNull: true, + }); + if (!localeCode) continue; + mergedByDestinationLocale[localeCode] ??= {}; + for (const [uid, entry] of Object.entries(localeValues)) { + mergedByDestinationLocale[localeCode][uid] = { + ...(mergedByDestinationLocale[localeCode][uid] ?? {}), + ...(entry ?? {}), + }; + } + } + for await (const [localeCode, localeValues] of Object.entries(mergedByDestinationLocale)) { const chunks = makeChunks(localeValues); for (const [entryKey, entryValue] of Object.entries(localeValues)) { const message = getLogMessage( @@ -883,18 +969,12 @@ const createEntry = async (packagePath: any, destination_stack_id: string, proje } const refs: { [key: string]: any } = {}; let chunkIndex = 1; - if (localeCode) { - const filePath = path.join( - entriesSave, - ctName, - localeCode - ); - for await (const [chunkId, chunkData] of Object.entries(chunks)) { - refs[chunkIndex++] = `${chunkId}-entries.json`; - await writeFile(filePath, `${chunkId}-entries.json`, chunkData); - } - await writeFile(filePath, ENTRIES_MASTER_FILE, refs); + const filePath = path.join(entriesSave, ctName, localeCode); + for await (const [chunkId, chunkData] of Object.entries(chunks)) { + refs[chunkIndex++] = `${chunkId}-entries.json`; + await writeFile(filePath, `${chunkId}-entries.json`, chunkData); } + await writeFile(filePath, ENTRIES_MASTER_FILE, refs); } } } else { diff --git a/ui/package-lock.json b/ui/package-lock.json index e6f6b801..5a7bd808 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -29,7 +29,7 @@ "sass": "^1.68.0", "socket.io-client": "^4.7.5", "typescript": "^4.9.5", - "vite": "^7.3.1", + "vite": "^7.3.2", "vite-tsconfig-paths": "^6.1.1" }, "devDependencies": { @@ -9094,9 +9094,10 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "license": "MIT", "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/ui/package.json b/ui/package.json index 5339c3e1..52f916ee 100644 --- a/ui/package.json +++ b/ui/package.json @@ -24,7 +24,7 @@ "sass": "^1.68.0", "socket.io-client": "^4.7.5", "typescript": "^4.9.5", - "vite": "^7.3.1", + "vite": "^7.3.2", "vite-tsconfig-paths": "^6.1.1" }, "scripts": { diff --git a/ui/src/components/ContentMapper/index.tsx b/ui/src/components/ContentMapper/index.tsx index 4b22641c..0798e395 100644 --- a/ui/src/components/ContentMapper/index.tsx +++ b/ui/src/components/ContentMapper/index.tsx @@ -230,7 +230,7 @@ const Fields: MappingFields = { 'taxonomy':{ label: 'Taxonomy', options: {'Taxonomy':'taxonomy'}, - type:'taxonomy' + type:'' } } type contentMapperProps = {