Skip to content
Merged
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
74 changes: 73 additions & 1 deletion packages/contentstack-export/src/export/modules/taxonomies.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import cloneDeep from 'lodash/cloneDeep';
import omit from 'lodash/omit';
import keys from 'lodash/keys';
import isEmpty from 'lodash/isEmpty';
Expand All @@ -16,8 +17,12 @@ import {
import { ModuleClassParams, ExportConfig } from '../../types';

export default class ExportTaxonomies extends BaseClass {
private static readonly PUBLISH_DETAILS_DEFAULT_LOCALE = '_default';

private taxonomies: Record<string, Record<string, string>>;
private taxonomiesByLocale: Record<string, Set<string>>;
/** List API `publish_details` keyed by non-localized bucket or locale code, then taxonomy uid */
private publishDetailsByLocale: Record<string, Record<string, unknown>>;
private taxonomiesConfig: ExportConfig['modules']['taxonomies'];
private isLocaleBasedExportSupported: boolean = true; // Flag to track if locale-based export is supported
private qs: {
Expand All @@ -39,6 +44,7 @@ export default class ExportTaxonomies extends BaseClass {
super({ exportConfig, stackAPIClient });
this.taxonomies = {};
this.taxonomiesByLocale = {};
this.publishDetailsByLocale = {};
this.taxonomiesConfig = exportConfig.modules.taxonomies;
this.qs = {
include_count: true,
Expand Down Expand Up @@ -143,6 +149,7 @@ export default class ExportTaxonomies extends BaseClass {
log.debug('Falling back to legacy export (non-localized)', this.exportConfig.context);
this.taxonomies = {};
this.taxonomiesByLocale = {};
this.publishDetailsByLocale = {};
} else {
log.debug('Localization enabled, proceeding with locale-based export', this.exportConfig.context);
}
Expand Down Expand Up @@ -330,11 +337,21 @@ export default class ExportTaxonomies extends BaseClass {
log.debug(`Processing ${taxonomies.length} taxonomies${localeInfo}`, this.exportConfig.context);

for (const taxonomy of taxonomies) {
const taxonomyRow = taxonomy as Record<string, unknown>;
const taxonomyUID = taxonomy.uid;
const taxonomyName = taxonomy.name;

log.debug(`Processing taxonomy: ${taxonomyName} (${taxonomyUID})${localeInfo}`, this.exportConfig.context);

// Store list API publish_details for merge into per-uid export files (per locale or default bucket)
if (taxonomyRow.publish_details != null) {
const bucket = localeCode ?? ExportTaxonomies.PUBLISH_DETAILS_DEFAULT_LOCALE;
if (!this.publishDetailsByLocale[bucket]) {
this.publishDetailsByLocale[bucket] = {};
}
this.publishDetailsByLocale[bucket][taxonomyUID] = taxonomyRow.publish_details;
}

// Store taxonomy metadata (only once per taxonomy)
if (!this.taxonomies[taxonomyUID]) {
this.taxonomies[taxonomyUID] = omit(taxonomy, this.taxonomiesConfig.invalidKeys);
Expand Down Expand Up @@ -374,8 +391,9 @@ export default class ExportTaxonomies extends BaseClass {
const onSuccess = ({ response, uid }: any) => {
const taxonomyName = this.taxonomies[uid]?.name;
const filePath = pResolve(exportFolderPath, `${uid}.json`);
const merged = this.mergeListPublishDetailsIntoExportPayload(response, uid, localeCode);
log.debug(`Writing detailed taxonomy data to: ${filePath}`, this.exportConfig.context);
fsUtil.writeFile(filePath, response);
fsUtil.writeFile(filePath, merged);

// Track progress for each exported taxonomy
this.progressManager?.tick(
Expand Down Expand Up @@ -463,6 +481,60 @@ export default class ExportTaxonomies extends BaseClass {
return localesToExport;
}

/**
* List `find` may include `publish_details` while `export` may not; we copy list data into the
* written file when export omits or has an empty `taxonomy.publish_details`.
*/
private getListPublishDetailsForExport(taxonomyUid: string, localeCode?: string): unknown | undefined {
const bucket = localeCode ?? ExportTaxonomies.PUBLISH_DETAILS_DEFAULT_LOCALE;
return this.publishDetailsByLocale[bucket]?.[taxonomyUid];
}

private isPublishDetailsValueEmpty(publishDetails: unknown): boolean {
if (publishDetails == null) {
return true;
}
if (Array.isArray(publishDetails)) {
return publishDetails.length === 0;
}
if (typeof publishDetails === 'object') {
return Object.keys(publishDetails as object).length === 0;
}
return false;
}

private mergeListPublishDetailsIntoExportPayload(
response: any,
taxonomyUid: string,
localeCode?: string,
): any {
const fromList = this.getListPublishDetailsForExport(taxonomyUid, localeCode);
if (fromList == null) {
return response;
}

const merged = cloneDeep(response);
const applyToTaxonomyObject = (tax: Record<string, unknown> | undefined | null) => {
if (!tax || typeof tax !== 'object') {
return;
}
if (this.isPublishDetailsValueEmpty(tax.publish_details)) {
tax.publish_details = fromList;
}
};

if (merged && typeof merged === 'object' && 'taxonomy' in merged && (merged as any).taxonomy) {
applyToTaxonomyObject((merged as any).taxonomy);
return merged;
}

log.debug(
'Taxonomy export response has no taxonomy object; skipping publish_details merge from list',
this.exportConfig.context,
);
return merged;
}

private isLocalePlanLimitationError(error: any): boolean {
return (
error?.status === 403 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -826,4 +826,136 @@ describe('ExportTaxonomies', () => {
mockFetchTaxonomies.restore();
});
});

describe('Detail file: merge list publish_details into export payload', () => {
it('should write merged file with list publish_details when export response omits them (legacy)', async () => {
const writeFileStub = FsUtility.prototype.writeFile as sinon.SinonStub;
const listPublish = [{ environment: 'bltEnv1', locale: 'en-us' }];

exportTaxonomies.taxonomies = { 'tax-1': { uid: 'tax-1', name: 'T' } };
exportTaxonomies.publishDetailsByLocale = {
_default: { 'tax-1': listPublish },
} as any;
exportTaxonomies.taxonomiesFolderPath = '/test/export/taxonomies';

const makeAPICallStub = sinon.stub(exportTaxonomies, 'makeAPICall').callsFake((opts: any) => {
return Promise.resolve(
opts.resolve({
response: { taxonomy: { uid: 'tax-1', name: 'T' }, terms: {} },
uid: 'tax-1',
}),
);
});

await exportTaxonomies.exportTaxonomies();

const detailWrite = writeFileStub
.getCalls()
.find((c) => typeof c.args[0] === 'string' && c.args[0].endsWith('tax-1.json'));
expect(detailWrite, 'writeFile for tax-1.json').to.exist;
const payload = detailWrite!.args[1];
expect(payload.taxonomy.publish_details).to.deep.equal(listPublish);

makeAPICallStub.restore();
});

it('should prefer export taxonomy.publish_details when already present and non-empty', async () => {
const writeFileStub = FsUtility.prototype.writeFile as sinon.SinonStub;
const fromExport = [{ environment: 'from-export' }];

exportTaxonomies.taxonomies = { 'tax-1': { uid: 'tax-1' } };
exportTaxonomies.publishDetailsByLocale = {
_default: { 'tax-1': [{ from: 'list' }] },
} as any;
exportTaxonomies.taxonomiesFolderPath = '/test/export/taxonomies';

const makeAPICallStub = sinon.stub(exportTaxonomies, 'makeAPICall').callsFake((opts: any) => {
return Promise.resolve(
opts.resolve({
response: { taxonomy: { uid: 'tax-1', publish_details: fromExport }, terms: {} },
uid: 'tax-1',
}),
);
});

await exportTaxonomies.exportTaxonomies();

const detailWrite = writeFileStub
.getCalls()
.find((c) => typeof c.args[0] === 'string' && c.args[0].endsWith('tax-1.json'));
const payload = detailWrite!.args[1];
expect(payload.taxonomy.publish_details).to.deep.equal(fromExport);

makeAPICallStub.restore();
});

it('should fill from list when export has empty array publish_details', async () => {
const writeFileStub = FsUtility.prototype.writeFile as sinon.SinonStub;
const fromList = [{ from: 'list' }];

exportTaxonomies.taxonomies = { 'tax-1': { uid: 'tax-1' } };
exportTaxonomies.publishDetailsByLocale = { _default: { 'tax-1': fromList } } as any;
exportTaxonomies.taxonomiesFolderPath = '/test/export/taxonomies';

const makeAPICallStub = sinon.stub(exportTaxonomies, 'makeAPICall').callsFake((opts: any) => {
return Promise.resolve(
opts.resolve({
response: { taxonomy: { uid: 'tax-1', publish_details: [] }, terms: {} },
uid: 'tax-1',
}),
);
});

await exportTaxonomies.exportTaxonomies();

const detailWrite = writeFileStub
.getCalls()
.find((c) => typeof c.args[0] === 'string' && c.args[0].endsWith('tax-1.json'));
expect(detailWrite!.args[1].taxonomy.publish_details).to.deep.equal(fromList);

makeAPICallStub.restore();
});

it('should use per-locale list publish_details when exporting locale folder', async () => {
const writeFileStub = FsUtility.prototype.writeFile as sinon.SinonStub;
const frPublish = [{ locale: 'fr-fr' }];

exportTaxonomies.taxonomies = { 'tax-1': { uid: 'tax-1' } };
exportTaxonomies.taxonomiesByLocale['fr-fr'] = new Set(['tax-1']);
exportTaxonomies.publishDetailsByLocale = { 'fr-fr': { 'tax-1': frPublish } } as any;
exportTaxonomies.taxonomiesFolderPath = '/test/export/taxonomies';

const makeAPICallStub = sinon.stub(exportTaxonomies, 'makeAPICall').callsFake((opts: any) => {
return Promise.resolve(
opts.resolve({
response: { taxonomy: { uid: 'tax-1' }, terms: {} },
uid: 'tax-1',
}),
);
});

await exportTaxonomies.exportTaxonomies('fr-fr');

const detailWrite = writeFileStub
.getCalls()
.find((c) => typeof c.args[0] === 'string' && c.args[0].includes('fr-fr') && c.args[0].endsWith('tax-1.json'));
expect(detailWrite, 'fr-fr/tax-1.json').to.exist;
expect(detailWrite!.args[1].taxonomy.publish_details).to.deep.equal(frPublish);

makeAPICallStub.restore();
});

it('should store publish_details per locale bucket in sanitizeTaxonomiesAttribs', () => {
const listPd = [{ env: 'a' }];
const taxonomies = [
{ uid: 't-loc', name: 'L', publish_details: listPd },
];
exportTaxonomies.publishDetailsByLocale = {};
exportTaxonomies.taxonomiesByLocale['es-es'] = new Set<string>();

exportTaxonomies.sanitizeTaxonomiesAttribs(taxonomies, 'es-es');

expect((exportTaxonomies as any).publishDetailsByLocale['es-es']['t-loc']).to.deep.equal(listPd);
});
});
});
Loading