@contentstack/cli-variants
Version:
Variants plugin
599 lines (532 loc) • 25 kB
text/typescript
import omit from 'lodash/omit';
import chunk from 'lodash/chunk';
import entries from 'lodash/entries';
import isEmpty from 'lodash/isEmpty';
import forEach from 'lodash/forEach';
import indexOf from 'lodash/indexOf';
import { join, resolve } from 'path';
import { readFileSync, existsSync } from 'fs';
import { FsUtility, HttpResponse, sanitizePath, log, handleAndLogError } from '@contentstack/cli-utilities';
import VariantAdapter, { VariantHttpClient } from '../utils/variant-api-adapter';
import {
LogType,
APIConfig,
AdapterType,
AnyProperty,
ImportConfig,
ContentTypeStruct,
VariantEntryStruct,
ImportHelperMethodsConfig,
EntryDataForVariantEntries,
CreateVariantEntryDto,
PublishVariantEntryDto,
} from '../types';
import { fsUtil } from '../utils';
export default class VariantEntries extends VariantAdapter<VariantHttpClient<ImportConfig>> {
public entriesDirPath: string;
public entriesMapperPath: string;
public variantEntryBasePath!: string;
public variantIdList!: Record<string, unknown>;
public personalizeConfig: ImportConfig['modules']['personalize'];
public taxonomies!: Record<string, unknown>;
public assetUrlMapper!: Record<string, any>;
public assetUidMapper!: Record<string, any>;
public entriesUidMapper!: Record<string, any>;
private installedExtensions!: Record<string, any>[];
private failedVariantPath!: string;
private failedVariantEntries!: Record<string, any>;
private environments!: Record<string, any>;
constructor(readonly config: ImportConfig & { helpers?: ImportHelperMethodsConfig }) {
const conf: APIConfig & AdapterType<VariantHttpClient<ImportConfig>, APIConfig> = {
config,
httpClient: true,
baseURL: config.host,
Adapter: VariantHttpClient<ImportConfig>,
headers: {
api_key: config.apiKey,
branch: config.branchName,
organization_uid: config.org_uid,
'X-Project-Uid': config.modules.personalize.project_id,
},
};
super(Object.assign(omit(config, ['helpers']), conf));
this.entriesMapperPath = resolve(sanitizePath(config.backupDir), 'mapper', 'entries');
this.personalizeConfig = this.config.modules.personalize;
this.entriesDirPath = resolve(sanitizePath(config.backupDir), sanitizePath(config.modules.entries.dirName));
this.failedVariantPath = resolve(sanitizePath(this.entriesMapperPath), 'failed-entry-variants.json');
this.failedVariantEntries = new Map();
if (this.config && this.config.context) {
this.config.context.module = 'variant-entries';
}
}
/**
* This TypeScript function asynchronously imports backupDir from a JSON file and processes the entries
* for variant entries.
* @returns If the file at the specified file path exists and contains backupDir, the function will parse
* the JSON backupDir and iterate over each entry to import variant entries using the
* `importVariantEntries` method. If the `entriesForVariants` array is empty, the function will log a
* message indicating that no entries were found and return.
*/
async import() {
const filePath = resolve(sanitizePath(this.entriesMapperPath), 'data-for-variant-entry.json');
const variantIdPath = resolve(
sanitizePath(this.config.backupDir),
'mapper',
sanitizePath(this.personalizeConfig.dirName),
sanitizePath(this.personalizeConfig.experiences.dirName),
'variants-uid-mapping.json',
);
log.debug(`Checking for variant entry data file: ${filePath}`, this.config.context);
if (!existsSync(filePath)) {
log.warn(`Variant entry data file not found at path: ${filePath}`, this.config.context);
return;
}
log.debug(`Checking for variant ID mapping file: ${variantIdPath}`, this.config.context);
if (!existsSync(variantIdPath)) {
log.error('Variant UID mapping file not found', this.config.context);
return;
}
const entriesForVariants = fsUtil.readFile(filePath, true) as EntryDataForVariantEntries[];
log.debug(`Loaded ${entriesForVariants?.length || 0} entries for variant processing`, this.config.context);
if (isEmpty(entriesForVariants)) {
log.warn('No entries found for variant import', this.config.context);
return;
}
const entriesUidMapperPath = join(sanitizePath(this.entriesMapperPath), 'uid-mapping.json');
const assetUidMapperPath = resolve(sanitizePath(this.config.backupDir), 'mapper', 'assets', 'uid-mapping.json');
const assetUrlMapperPath = resolve(sanitizePath(this.config.backupDir), 'mapper', 'assets', 'url-mapping.json');
const taxonomiesPath = resolve(
sanitizePath(this.config.backupDir),
'mapper',
sanitizePath(this.config.modules.taxonomies.dirName),
'terms',
'success.json',
);
const marketplaceAppMapperPath = resolve(
sanitizePath(this.config.backupDir),
'mapper',
'marketplace_apps',
'uid-mapping.json',
);
const envPath = resolve(sanitizePath(this.config.backupDir), 'environments', 'environments.json');
log.debug('Loading variant ID mapping and dependency data', this.config.context);
// NOTE Read and store list of variant IDs
this.variantIdList = (fsUtil.readFile(variantIdPath, true) || {}) as Record<string, unknown>;
if (isEmpty(this.variantIdList)) {
log.warn('Empty variant UID data found', this.config.context);
return;
}
// NOTE entry relational data lookup dependencies.
this.entriesUidMapper = (fsUtil.readFile(entriesUidMapperPath, true) || {}) as Record<string, any>;
this.installedExtensions = ((fsUtil.readFile(marketplaceAppMapperPath) as any) || { extension_uid: {} })
.extension_uid as Record<string, any>[];
this.taxonomies = (fsUtil.readFile(taxonomiesPath, true) || {}) as Record<string, unknown>;
this.assetUidMapper = (fsUtil.readFile(assetUidMapperPath, true) || {}) as Record<string, any>;
this.assetUrlMapper = (fsUtil.readFile(assetUrlMapperPath, true) || {}) as Record<string, any>;
this.environments = (fsUtil.readFile(envPath, true) || {}) as Record<string, any>;
log.debug(
`Loaded dependency data - Entries: ${Object.keys(this.entriesUidMapper)?.length}, Assets: ${
Object.keys(this.assetUidMapper)?.length
}, Taxonomies: ${Object.keys(this.taxonomies)?.length}`,
this.config.context,
);
// set the token
await this.variantInstance.init();
log.info(`Processing ${entriesForVariants?.length} entries for variant import`, this.config.context);
for (const entriesForVariant of entriesForVariants) {
await this.importVariantEntries(entriesForVariant);
}
log.success('All variant entries have been imported and published successfully', this.config.context);
}
/**
* The function `importVariantEntries` asynchronously imports variant entries using file system
* utility in TypeScript.
* @param {EntryDataForVariantEntries} entriesForVariant - EntryDataForVariantEntries {
*/
async importVariantEntries(entriesForVariant: EntryDataForVariantEntries) {
const variantEntry = this.config.modules.variantEntry;
const { content_type, locale, entry_uid } = entriesForVariant;
log.info(`Importing variant entries for: ${content_type}/${locale}/${entry_uid}`, this.config.context);
const ctConfig = this.config.modules['content-types'];
const contentType: ContentTypeStruct = JSON.parse(
readFileSync(
resolve(
sanitizePath(this.config.backupDir),
sanitizePath(ctConfig.dirName),
`${sanitizePath(content_type)}.json`,
),
'utf8',
),
);
const variantEntryBasePath = join(
sanitizePath(this.entriesDirPath),
sanitizePath(content_type),
sanitizePath(locale),
sanitizePath(variantEntry.dirName),
sanitizePath(entry_uid),
);
log.debug(`Processing variant entries from: ${variantEntryBasePath}`, this.config.context);
const fs = new FsUtility({ basePath: variantEntryBasePath, createDirIfNotExist: false });
for (const _ in fs.indexFileContent) {
try {
const variantEntries = (await fs.readChunkFiles.next()) as VariantEntryStruct[];
if (variantEntries?.length) {
log.info(`Processing batch of ${variantEntries.length} variant entries`, this.config.context);
await this.handleConcurrency(contentType, variantEntries, entriesForVariant);
}
} catch (error) {
handleAndLogError(
error,
this.config.context,
`Failed to process variant entries for ${content_type}/${locale}/${entry_uid}`,
);
}
}
}
/**
* The function `handleConcurrency` processes variant entries in batches with a specified concurrency
* level and handles API calls for creating variant entries.
* @param {VariantEntryStruct[]} variantEntries - The `variantEntries` parameter is an array of
* `VariantEntryStruct` objects. It seems like this function is handling concurrency for processing
* these entries in batches. The function chunks the `variantEntries` array into smaller batches and
* then processes each batch asynchronously using `Promise.allSettled`. It also
* @param {EntryDataForVariantEntries} entriesForVariant - The `entriesForVariant` parameter seems to
* be an object containing the following properties:
* @returns The `handleConcurrency` function processes variant entries in batches, creating variant
* entries using the `createVariantEntry` method and handling the API response using
* `Promise.allSettled`. The function also includes logic to handle variant IDs and delays between
* batch processing.
*/
async handleConcurrency(
contentType: ContentTypeStruct,
variantEntries: VariantEntryStruct[],
entriesForVariant: EntryDataForVariantEntries,
) {
let batchNo = 0;
const variantEntryConfig = this.config.modules.variantEntry;
const { content_type, locale, entry_uid } = entriesForVariant;
const entryUid = this.entriesUidMapper[entry_uid];
const batches = chunk(variantEntries, variantEntryConfig.apiConcurrency || 5);
if (isEmpty(batches)) return;
log.debug(`Starting concurrent processing for ${variantEntries?.length} variant entries`, this.config.context);
for (const [, batch] of entries(batches)) {
batchNo += 1;
const allPromise = [];
const start = Date.now();
log.debug(
`Processing batch ${batchNo}/${batches?.length} with ${batch?.length} variant entries`,
this.config.context,
);
for (let [, variantEntry] of entries(batch)) {
const onSuccess = ({ response, apiData: { entryUid, variantUid } }: any) => {
log.info(
`Created entry variant: '${variantUid}' of entry uid ${entryUid} locale '${locale}'`,
this.config.context,
);
};
const onReject = ({ error, apiData }: any) => {
const { entryUid, variantUid } = apiData;
this.failedVariantEntries.set(variantUid, apiData);
handleAndLogError(
error,
this.config.context,
`Failed to create entry variant: '${variantUid}' of entry uid ${entryUid} locale '${locale}'`,
);
};
// NOTE Find new variant Id by old Id
const variantId = this.variantIdList[variantEntry._variant._uid] as string;
log.debug(
`Looking up variant ID for ${variantEntry._variant._uid}: ${variantId ? 'found' : 'not found'}`,
this.config.context,
);
// NOTE Replace all the relation data UID's
variantEntry = this.handleVariantEntryRelationalData(contentType, variantEntry);
const changeSet = this.serializeChangeSet(variantEntry);
const createVariantReq: CreateVariantEntryDto = {
_variant: variantEntry._variant,
...changeSet,
};
if (variantId) {
log.debug(`Creating variant entry for variant ID: ${variantId}`, this.config.context);
const promise = this.variantInstance.createVariantEntry(
createVariantReq,
{
locale,
entry_uid: entryUid,
variant_id: variantId,
content_type_uid: content_type,
},
{
reject: onReject.bind(this),
resolve: onSuccess.bind(this),
variantUid: variantId,
},
);
allPromise.push(promise);
} else {
log.error(`Variant ID not found for ${variantEntry._variant._uid}`, this.config.context);
}
}
// NOTE Handle the API response here
log.debug(`Waiting for ${allPromise.length} variant entry creation promises to complete`, this.config.context);
await Promise.allSettled(allPromise);
log.debug(`Batch ${batchNo} creation completed`, this.config.context);
// NOTE publish all the entries
await this.publishVariantEntries(batch, entryUid, content_type);
const end = Date.now();
const exeTime = end - start;
log.debug(`Batch ${batchNo} completed in ${exeTime}ms`, this.config.context);
this.variantInstance.delay(1000 - exeTime);
}
log.debug(`Writing failed variant entries to: ${this.failedVariantPath}`, this.config.context);
fsUtil.writeFile(this.failedVariantPath, this.failedVariantEntries);
}
/**
* Serializes the change set of a entry variant.
* @param variantEntry - The entry variant to serialize.
* @returns The serialized change set as a record.
*/
serializeChangeSet(variantEntry: VariantEntryStruct) {
let changeSet: Record<string, any> = {};
if (variantEntry?._variant?._change_set?.length) {
variantEntry._variant._change_set.forEach((key: string) => {
key = key.split('.')[0];
if (variantEntry[key]) {
changeSet[key] = variantEntry[key];
}
});
}
return changeSet;
}
/**
* The function `handleVariantEntryRelationalData` processes relational data for a entry variant
* based on the provided content type and configuration helpers.
* @param {ContentTypeStruct} contentType - The `contentType` parameter in the
* `handleVariantEntryRelationalData` function is of type `ContentTypeStruct`. It is used to define
* the structure of the content type being processed within the function. This parameter likely
* contains information about the schema and configuration of the content type.
* @param {VariantEntryStruct} variantEntry - The `variantEntry` parameter in the
* `handleVariantEntryRelationalData` function is a data structure that represents a entry variant.
* It is of type `VariantEntryStruct` and contains information related to a specific entry variant.
* This function is responsible for performing various operations on the `variantEntry`
* @returns The function `handleVariantEntryRelationalData` returns the `variantEntry` after
* performing various lookups and replacements on it based on the provided `contentType` and
* `config.helpers`.
*/
handleVariantEntryRelationalData(
contentType: ContentTypeStruct,
variantEntry: VariantEntryStruct,
): VariantEntryStruct {
log.debug(`Processing relational data for variant entry: ${variantEntry.uid}`, this.config.context);
if (this.config.helpers) {
const { lookUpTerms, lookupAssets, lookupExtension, lookupEntries, restoreJsonRteEntryRefs } =
this.config.helpers;
// FIXME Not sure why do we even need lookupExtension in entries [Ref taken from entries import]
// Feel free to remove this flow if it's not valid
// NOTE Find and replace extension's UID
if (lookupExtension) {
log.debug('Processing extension lookups for variant entry', this.config.context);
lookupExtension(this.config, contentType.schema, this.config.preserveStackVersion, this.installedExtensions);
}
// NOTE Find and replace RTE Ref UIDs
log.debug('Processing RTE reference lookups for variant entry', this.config.context);
variantEntry = restoreJsonRteEntryRefs(variantEntry, variantEntry, contentType.schema, {
uidMapper: this.entriesUidMapper,
mappedAssetUids: this.assetUidMapper,
mappedAssetUrls: this.assetUrlMapper,
}) as VariantEntryStruct;
// NOTE Find and replace Entry Ref UIDs
log.debug('Processing entry reference lookups for variant entry', this.config.context);
variantEntry = lookupEntries(
{
entry: variantEntry,
content_type: contentType,
},
this.entriesUidMapper,
resolve(sanitizePath(this.entriesMapperPath), sanitizePath(contentType.uid), sanitizePath(variantEntry.locale)),
);
// NOTE: will remove term if term doesn't exists in taxonomy
// FIXME: Validate if taxonomy support available for variant entries,
// if not, feel free to remove this lookup flow.
log.debug('Processing taxonomy term lookups for variant entry', this.config.context);
lookUpTerms(contentType.schema, variantEntry, this.taxonomies, this.config);
// update file fields of entry variants to support lookup asset logic
log.debug('Updating file fields for variant entry', this.config.context);
this.updateFileFields(variantEntry);
// NOTE Find and replace asset's UID
log.debug('Processing asset lookups for variant entry', this.config.context);
variantEntry = lookupAssets(
{
entry: variantEntry,
content_type: contentType,
},
this.assetUidMapper,
this.assetUrlMapper,
join(sanitizePath(this.entriesDirPath), sanitizePath(contentType.uid)),
this.installedExtensions,
);
}
return variantEntry;
}
/**
* Updates the file fields of a entry variant to support lookup asset logic.
* Lookup asset expects file fields to be an object instead of a string. So here we are updating the file fields to be an object. Object has two keys: `uid` and `filename`. `uid` is the asset UID and `filename` is the name of the file. Used a dummy value for the filename. This is a temporary fix and will be updated in the future.
* @param variantEntry - The entry variant to update.
*/
updateFileFields(variantEntry: VariantEntryStruct) {
log.debug(`Updating file fields for variant entry: ${variantEntry.uid}`, this.config.context);
const setValue = (currentObj: VariantEntryStruct, keys: string[]) => {
if (!currentObj || keys.length === 0) return;
const [firstKey, ...restKeys] = keys;
if (Array.isArray(currentObj)) {
for (const item of currentObj) {
setValue(item, [firstKey, ...restKeys]);
}
} else if (currentObj && typeof currentObj === 'object') {
if (firstKey in currentObj) {
if (keys.length === 1) {
// Check if the current property is already an object with uid and filename
const existingValue = currentObj[firstKey];
if (existingValue && typeof existingValue === 'object' && existingValue.uid) {
currentObj[firstKey] = { uid: existingValue.uid, filename: 'dummy.jpeg' };
} else {
currentObj[firstKey] = { uid: currentObj[firstKey], filename: 'dummy.jpeg' };
}
} else {
setValue(currentObj[firstKey], restKeys);
}
}
}
};
const pathsToUpdate =
variantEntry?._metadata?.references
?.filter((ref: any) => ref._content_type_uid === 'sys_assets')
.map((ref: any) => ref.path) || [];
log.debug(`Found ${pathsToUpdate?.length} file field paths to update`, this.config.context);
pathsToUpdate.forEach((path: string) => setValue(variantEntry, path.split('.')));
}
/**
* Publishes variant entries in batch for a given entry UID and content type.
* @param batch - An array of VariantEntryStruct objects representing the variant entries to be published.
* @param entryUid - The UID of the entry for which the variant entries are being published.
* @param content_type - The UID of the content type of the entry.
* @returns A Promise that resolves when all variant entries have been published.
*/
async publishVariantEntries(batch: VariantEntryStruct[], entryUid: string, content_type: string) {
const allPromise = [];
log.info(
`Publishing variant entries for entry uid '${entryUid}' of Content Type '${content_type}'`,
this.config.context,
);
log.debug(`Processing ${batch.length} variant entries for publishing`, this.config.context);
for (let [, variantEntry] of entries(batch)) {
const variantEntryUID = variantEntry.uid;
const oldVariantUid = variantEntry._variant._uid || '';
const newVariantUid = this.variantIdList[oldVariantUid] as string;
if (!newVariantUid) {
log.debug('Variant ID not found', this.config.context);
continue;
}
if (this.failedVariantEntries.has(variantEntryUID)) {
log.info(`Variant UID not found. Skipping entry variant publish for ${variantEntryUID}`, this.config.context);
continue;
}
if (this.environments?.length) {
log.info('No environments found. Skipping entry variant publishing...', this.config.context);
return;
}
const onSuccess = ({ response, apiData: { entryUid, variantUid } }: any) => {
log.info(
`Entry variant: '${variantUid}' of entry '${entryUid}' published on locales '${locales.join(',')}'`,
this.config.context,
);
};
const onReject = ({ error, apiData: { entryUid, variantUid } }: any) => {
handleAndLogError(
error,
this.config.context,
`Failed to publish entry variant: '${variantUid}' of entry uid ${entryUid} on locales '${locales.join(',')}'`,
);
};
const { environments, locales } = this.serializePublishEntries(variantEntry);
if (environments?.length === 0 || locales?.length === 0) {
log.info(`Skipping publish for variant ${newVariantUid} - no environments or locales`, this.config.context);
continue;
}
log.debug(
`Publishing variant ${newVariantUid} to environments: ${environments.join(', ')}, locales: ${locales.join(
', ',
)}`,
this.config.context,
);
const publishReq: PublishVariantEntryDto = {
entry: {
environments,
locales,
variants: [{ uid: newVariantUid, version: 1 }],
},
locale: variantEntry.locale,
};
const promise = this.variantInstance.publishVariantEntry(
publishReq,
{
entry_uid: entryUid,
content_type_uid: content_type,
},
{
reject: onReject.bind(this),
resolve: onSuccess.bind(this),
log: log,
variantUid: newVariantUid,
},
);
allPromise.push(promise);
}
await Promise.allSettled(allPromise);
log.info(
`Published variant entries for entry uid '${entryUid}' of Content Type '${content_type}'`,
this.config.context,
);
}
/**
* Serializes the publish entries of a variant.
* @param variantEntry - The entry variant to serialize.
* @returns An object containing the serialized publish entries.
*/
serializePublishEntries(variantEntry: VariantEntryStruct): {
environments: Array<string>;
locales: Array<string>;
} {
log.debug(`Serializing publish entries for variant: ${variantEntry.uid}`, this.config.context);
const requestObject: {
environments: Array<string>;
locales: Array<string>;
} = {
environments: [],
locales: [],
};
if (variantEntry.publish_details && variantEntry.publish_details?.length > 0) {
log.debug(`Processing ${variantEntry.publish_details.length} publish details`, this.config.context);
} else {
log.debug('No publish details found for variant entry.', this.config.context);
}
if (variantEntry.publish_details && variantEntry.publish_details?.length > 0) {
forEach(variantEntry.publish_details, (pubObject) => {
if (
this.environments.hasOwnProperty(pubObject.environment) &&
indexOf(requestObject.environments, this.environments[pubObject.environment].name) === -1
) {
requestObject.environments.push(this.environments[pubObject.environment].name);
}
if (pubObject.locale && indexOf(requestObject.locales, pubObject.locale) === -1) {
requestObject.locales.push(pubObject.locale);
}
});
}
log.debug(
`Serialized publish data - environments: ${requestObject.environments.length}, locales: ${requestObject.locales.length}`,
this.config.context,
);
return requestObject;
}
}