Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions api/src/services/wordpress.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) : "");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: String(value) yields "[object Object]" if wp:meta_value ever arrives as an xml2js node rather than a plain string. The parser is configured with attrkey: 'attributes' / charkey: 'text' (upload-api/migration-wordpress/utils/helper.ts:16), so any element carrying an attribute becomes { text, attributes } β€” which is exactly why saveEntry reads tag?.text and cat?.attributes?.nicename further down this file.

Standard WXR writes <wp:meta_value> with no attributes, so this is unlikely rather than broken. It just fails quietly if it does happen: the lookup misses and the warning reads assets_[object Object]. Cheap guard:

Suggested change
return typeof value === "string" ? value : (value != null ? String(value) : "");
if (typeof value === "string") return value;
const raw = value?.text ?? value;
return raw != null ? String(raw) : "";

Generated by Claude Code

};

/**
* 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_<wp:post_id>`,
* 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');
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: saveEntry writes featured_image without consulting fields (the content type's fieldMapping), while the schema side only emits the field when hasFeaturedImage && !isAllContentEmpty (upload-api/migration-wordpress/libs/extractItems.ts:580). The two can disagree.

Concrete case: a post type whose items all have empty content:encoded. extractItems then skips the featured_image push (along with title/url), but if any of those items carries _thumbnail_id and the attachment downloaded, this line still puts featured_image on the entry β€” an entry key with no matching field in the generated content type. The same holds if the field is marked deleted at the Field Mapping step: buildFieldSchema drops isDeleted === true fields (api/src/utils/content-type-creator.utils.ts:136), but nothing filters the entry side.

The author / tags writes on the lines just above have the same shape, so this may well be pre-existing and tolerated by the importer β€” which is why this is a question rather than a blocker. Worth confirming an orphan key is genuinely ignored on import; if it isn't, gating this on a lookup for a featured_image entry in fields would close it.


Generated by Claude Code

}
entryData[uid]['locale'] = locale;
entryData[uid]['publish_details'] = [];

Expand Down Expand Up @@ -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"] ||
Expand Down
126 changes: 126 additions & 0 deletions api/tests/unit/services/wordpress.service.featuredImage.test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
27 changes: 27 additions & 0 deletions upload-api/migration-wordpress/libs/extractItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: otherCmsField: '_thumbnail_id' becomes a user-visible label in one path. updateContentType resets a field with contentstackField: field?.otherCmsField (api/src/services/contentMapper.service.ts:1010), and contentstackField is what buildSchemaTree turns into the Contentstack display_name (api/src/utils/content-type-creator.utils.ts:428). A field mapping that goes through that reset therefore produces a field literally named _thumbnail_id in the destination stack instead of "Featured Image".

The sibling fields sidestep this by putting a human label in otherCmsField β€” the author push at line 551 uses 'Author' rather than dc:creator, and terms uses 'terms'. Using 'Featured Image' here would match them and keep the reset path honest; the _thumbnail_id provenance is already captured in the comment above and in backupFieldUid.


Generated by Claude Code

"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<string, any> = {
"status": 1,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wp="http://wordpress.org/export/1.2/">
<channel>
<item>
<title>Joint Pain &amp; Mobility Support in Colder Weather</title>
<content:encoded><![CDATA[<p class="wp-block-paragraph">When temperatures start to fall.</p>]]></content:encoded>
<wp:post_id>18098</wp:post_id>
<wp:post_type><![CDATA[post]]></wp:post_type>
</item>
</channel>
</rss>`;

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<string, any> = {}) => ({
title: 'Joint Pain & Mobility Support in Colder Weather',
'content:encoded': '<p class="wp-block-paragraph">When temperatures start to fall.</p>',
'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);
});
});
Loading