diff --git a/api/src/services/wordpress.service.ts b/api/src/services/wordpress.service.ts index ee090dcdb..0ffbf75f5 100644 --- a/api/src/services/wordpress.service.ts +++ b/api/src/services/wordpress.service.ts @@ -1189,6 +1189,51 @@ const extractCategoryReference = (categories: any) => { } +/** xml2js emits a lone repeated child as an object rather than a one-element array. */ +const normalizePostMeta = (postMeta: any): any[] => { + if (!postMeta) return []; + return Array.isArray(postMeta) ? postMeta : [postMeta]; +}; + +const findPostMetaValue = (postMeta: any, metaKey: string): string => { + const match = normalizePostMeta(postMeta).find( + (meta: any) => meta?.["wp:meta_key"] === metaKey && meta?.["wp:meta_value"] + ); + const value = match?.["wp:meta_value"]; + return typeof value === "string" ? value : (value != null ? String(value) : ""); +}; + +/** + * WordPress keeps image alt text on the attachment as the `_wp_attachment_image_alt` postmeta. It is + * the canonical accessibility/SEO text for an image, so it is preferred over the usually-empty + * description/content fallbacks when setting the Contentstack asset's description. For a featured + * image this is what surfaces as `featured_image.description` on the entry. + */ +export const getAttachmentAltText = (assets: any): string => + findPostMetaValue(assets?.["wp:postmeta"], "_wp_attachment_image_alt"); + +/** + * Resolve a post's featured image to an already-downloaded Contentstack asset. + * + * WordPress points at the post thumbnail via the `_thumbnail_id` postmeta, whose value is the + * attachment's `wp:post_id` — and saveAsset registers every attachment under `assets_`, + * so the id resolves in one hop. Returns undefined when the post has no featured image, or when the + * attachment never made it into assetData (a failed download); a dangling uid would break the import. + */ +export const resolveFeaturedImageAsset = (item: any, assetData: any): any => { + const thumbnailId = findPostMetaValue(item?.["wp:postmeta"], "_thumbnail_id"); + if (!thumbnailId) return undefined; + + const asset = assetData?.[`assets_${thumbnailId}`]; + if (!asset) { + console.warn( + `Featured image asset assets_${thumbnailId} not found for post ${item?.["wp:post_id"]}; leaving featured_image unset.` + ); + return undefined; + } + return asset; +}; + const extractTermsReference = (terms: any) => { const termArray = Array?.isArray(terms) ? terms : [terms]; const termReference = termArray?.filter((term: any) => term?.attributes?.domain !== 'category'); @@ -1288,6 +1333,10 @@ async function saveEntry(fields: any, entry: any, file_path: string, assetData } entryData[uid]['tags'] = tags?.map((tag: any) => tag?.text); entryData[uid]['author'] = authorData; + const featuredImage = resolveFeaturedImageAsset(item, assetData); + if (featuredImage) { + entryData[uid]['featured_image'] = featuredImage; + } entryData[uid]['locale'] = locale; entryData[uid]['publish_details'] = []; @@ -1786,6 +1835,7 @@ async function saveAsset(assets: any, retryCount: number, affix: string, destina const nameWithoutExt = originalName.includes('.') ? originalName.substring(0, originalName.lastIndexOf('.')) : originalName; let description = + getAttachmentAltText(assets) || assets["description"] || assets["content:encoded"] || assets["excerpt:encoded"] || diff --git a/api/tests/unit/services/wordpress.service.featuredImage.test.ts b/api/tests/unit/services/wordpress.service.featuredImage.test.ts new file mode 100644 index 000000000..127ae96ef --- /dev/null +++ b/api/tests/unit/services/wordpress.service.featuredImage.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + resolveFeaturedImageAsset, + getAttachmentAltText +} from '../../../src/services/wordpress.service.js'; + +/** + * Shapes below mirror the real WXR export from ticket 00062373 (PetHealthMD), as xml2js parses it + * with `explicitArray: false`: post 18098 carries `_thumbnail_id` = 18113, and attachment 18113 is + * the image. saveAsset registers that attachment as `assets_18113`. + */ +const postMeta = (key: string, value: string) => ({ + 'wp:meta_key': key, + 'wp:meta_value': value +}); + +const post = (postmeta: any) => ({ + 'wp:post_id': '18098', + 'wp:post_type': 'post', + 'wp:postmeta': postmeta +}); + +const downloadedAsset = { + uid: 'assets_18113', + filename: 'assets_18113.jpg', + url: 'https://pethealthmd.com/wp-content/uploads/2025/11/retriever-and-snow-fall.jpg' +}; + +const assetData = { assets_18113: downloadedAsset }; + +describe('resolveFeaturedImageAsset', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('resolves _thumbnail_id to the downloaded asset', () => { + const result = resolveFeaturedImageAsset( + post([postMeta('_thumbnail_id', '18113')]), + assetData + ); + expect(result).toBe(downloadedAsset); + }); + + it('resolves when postmeta is a single object rather than an array', () => { + const result = resolveFeaturedImageAsset( + post(postMeta('_thumbnail_id', '18113')), + assetData + ); + expect(result).toBe(downloadedAsset); + }); + + it('picks _thumbnail_id out from among other postmeta keys', () => { + const result = resolveFeaturedImageAsset( + post([ + postMeta('_yoast_wpseo_title', 'Joint Pain & Mobility Support'), + postMeta('_thumbnail_id', '18113'), + postMeta('_yoast_wpseo_canonical', 'https://pethealthmd.com/cats/joint-pain/') + ]), + assetData + ); + expect(result).toBe(downloadedAsset); + }); + + it('coerces a numeric _thumbnail_id to the asset key', () => { + const result = resolveFeaturedImageAsset( + post([{ 'wp:meta_key': '_thumbnail_id', 'wp:meta_value': 18113 }]), + assetData + ); + expect(result).toBe(downloadedAsset); + }); + + it('returns undefined when the post has no featured image', () => { + expect(resolveFeaturedImageAsset(post([postMeta('_yoast_wpseo_title', 'x')]), assetData)) + .toBeUndefined(); + }); + + it('returns undefined when the post has no postmeta at all', () => { + expect(resolveFeaturedImageAsset({ 'wp:post_id': '18098' }, assetData)).toBeUndefined(); + }); + + it('returns undefined when _thumbnail_id is empty', () => { + expect(resolveFeaturedImageAsset(post([postMeta('_thumbnail_id', '')]), assetData)) + .toBeUndefined(); + }); + + it('returns undefined and warns when the asset failed to download', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(resolveFeaturedImageAsset(post([postMeta('_thumbnail_id', '99999')]), assetData)) + .toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('assets_99999')); + }); + + it('tolerates a missing assetData map', () => { + expect(resolveFeaturedImageAsset(post([postMeta('_thumbnail_id', '18113')]), undefined)) + .toBeUndefined(); + }); +}); + +describe('getAttachmentAltText', () => { + it('reads _wp_attachment_image_alt off the attachment', () => { + const alt = getAttachmentAltText({ + 'wp:postmeta': [ + postMeta('_wp_attached_file', '2025/11/retriever-and-snow-fall.jpg'), + postMeta('_wp_attachment_image_alt', 'A golden retriever in falling snow') + ] + }); + expect(alt).toBe('A golden retriever in falling snow'); + }); + + it('returns an empty string when the alt meta is present but blank', () => { + // This is the case in the ticket's own export — the alt value is an empty CDATA block. + expect(getAttachmentAltText({ + 'wp:postmeta': [postMeta('_wp_attachment_image_alt', '')] + })).toBe(''); + }); + + it('returns an empty string when there is no alt meta', () => { + expect(getAttachmentAltText({ + 'wp:postmeta': [postMeta('_wp_attached_file', 'x.jpg')] + })).toBe(''); + }); + + it('returns an empty string for an attachment with no postmeta', () => { + expect(getAttachmentAltText({})).toBe(''); + }); +}); diff --git a/upload-api/migration-wordpress/libs/extractItems.ts b/upload-api/migration-wordpress/libs/extractItems.ts index 594fe6179..b37d2818d 100644 --- a/upload-api/migration-wordpress/libs/extractItems.ts +++ b/upload-api/migration-wordpress/libs/extractItems.ts @@ -566,6 +566,33 @@ const extractItems = async (item: any, config: DataConfig, type: string, affix: } + // Featured image — WordPress stores the post thumbnail as a `_thumbnail_id` postmeta pointing at + // an attachment item. Add a single file field so saveEntry can attach the already-downloaded + // asset. Added only when an item in this post type actually declares a featured image. + const hasFeaturedImage = item?.some?.((data: any) => { + const postMeta = Array?.isArray(data?.['wp:postmeta']) + ? data['wp:postmeta'] + : (data?.['wp:postmeta'] ? [data['wp:postmeta']] : []); + return postMeta?.some?.( + (meta: any) => meta?.['wp:meta_key'] === '_thumbnail_id' && meta?.['wp:meta_value'] + ); + }); + if (hasFeaturedImage && !isAllContentEmpty) { + CT?.push?.({ + "isDeleted": false, + "uid": 'featured_image', + "backupFieldUid": 'featured_image', + "otherCmsField": '_thumbnail_id', + "otherCmsType": 'file', + "contentstackField": 'Featured Image', + "contentstackFieldUid": 'featured_image', + "contentstackFieldType": 'file', + "backupFieldType": 'file', + "advanced": { + "mandatory": false} + }); + } + const filePath = path.join(contentTypeFolderPath, `${type?.toLowerCase()}.json`); const contentType: Record = { "status": 1, diff --git a/upload-api/tests/unit/migration-wordpress/extractItems.featuredImage.test.ts b/upload-api/tests/unit/migration-wordpress/extractItems.featuredImage.test.ts new file mode 100644 index 000000000..8ebd6de0c --- /dev/null +++ b/upload-api/tests/unit/migration-wordpress/extractItems.featuredImage.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; + +const writeFileAsync = vi.fn(); + +vi.mock('../../../migration-wordpress/utils/parseUtil', () => ({ + setupWordPressBlocks: vi.fn(async () => []), +})); + +vi.mock('../../../migration-wordpress/utils/helper', () => ({ + default: { + writeFileAsync: (...args: any[]) => writeFileAsync(...args), + }, +})); + +vi.mock('mkdirp', () => ({ default: vi.fn() })); + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import extractItems from '../../../migration-wordpress/libs/extractItems'; + +/** + * extractItems re-reads the source XML off disk to pull each item's body, so point it at a real + * file. Only the body lookup uses it; the featured-image check reads the parsed item objects. + */ +const xmlFixture = ` + + + +Joint Pain & Mobility Support in Colder Weather +When temperatures start to fall.

]]>
+18098 + +
+
+
`; + +let localPath: string; + +beforeAll(() => { + localPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wp-featured-')), 'export.xml'); + fs.writeFileSync(localPath, xmlFixture, 'utf8'); +}); + +const postMeta = (key: string, value: string) => ({ + 'wp:meta_key': key, + 'wp:meta_value': value, +}); + +/** Mirrors the shape xml2js produces for a WXR item (explicitArray: false). */ +const makeItem = (overrides: Record = {}) => ({ + title: 'Joint Pain & Mobility Support in Colder Weather', + 'content:encoded': '

When temperatures start to fall.

', + 'wp:post_id': '18098', + 'wp:post_type': 'post', + ...overrides, +}); + +const writtenFieldMapping = () => { + const payload = JSON.parse(writeFileAsync.mock.calls.at(-1)?.[1]); + return payload.fieldMapping; +}; + +const run = (items: any[]) => + extractItems(items, { localPath } as any, 'post', '', [], []); + +describe('extractItems — featured image', () => { + beforeEach(() => { + writeFileAsync.mockClear(); + }); + + it('adds a featured_image file field when an item declares _thumbnail_id', async () => { + await run([makeItem({ 'wp:postmeta': [postMeta('_thumbnail_id', '18113')] })]); + + const featured = writtenFieldMapping().find((f: any) => f.uid === 'featured_image'); + expect(featured).toBeDefined(); + expect(featured.contentstackFieldType).toBe('file'); + expect(featured.contentstackFieldUid).toBe('featured_image'); + expect(featured.contentstackField).toBe('Featured Image'); + expect(featured.otherCmsField).toBe('_thumbnail_id'); + expect(featured.advanced?.mandatory).toBe(false); + }); + + it('handles a single (non-array) postmeta, as xml2js emits for one entry', async () => { + await run([makeItem({ 'wp:postmeta': postMeta('_thumbnail_id', '18113') })]); + + expect(writtenFieldMapping().some((f: any) => f.uid === 'featured_image')).toBe(true); + }); + + it('adds the field once when only some items have a featured image', async () => { + await run([ + makeItem({ 'wp:post_id': '1', 'wp:postmeta': [postMeta('_yoast_wpseo_title', 'x')] }), + makeItem({ 'wp:post_id': '2', 'wp:postmeta': [postMeta('_thumbnail_id', '18113')] }), + ]); + + const featured = writtenFieldMapping().filter((f: any) => f.uid === 'featured_image'); + expect(featured).toHaveLength(1); + }); + + it('does not add the field when no item declares a featured image', async () => { + await run([makeItem({ 'wp:postmeta': [postMeta('_yoast_wpseo_title', 'x')] })]); + + expect(writtenFieldMapping().some((f: any) => f.uid === 'featured_image')).toBe(false); + }); + + it('does not add the field when _thumbnail_id is present but empty', async () => { + await run([makeItem({ 'wp:postmeta': [postMeta('_thumbnail_id', '')] })]); + + expect(writtenFieldMapping().some((f: any) => f.uid === 'featured_image')).toBe(false); + }); + + it('leaves items with no postmeta at all untouched', async () => { + await run([makeItem()]); + + const mapping = writtenFieldMapping(); + expect(mapping.some((f: any) => f.uid === 'featured_image')).toBe(false); + // the pre-existing fields are still emitted + expect(mapping.some((f: any) => f.uid === 'title')).toBe(true); + expect(mapping.some((f: any) => f.uid === 'modular_blocks')).toBe(true); + }); +});