From 60714689552ae442ade29ebe27e29db635fa2441 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Thu, 4 Jan 2024 12:23:10 +0100 Subject: [PATCH] Add support for importing language variants --- src/commands/export.ts | 4 +- src/commands/import.ts | 10 +- .../entities/contentItems.ts | 7 +- .../entities/contentTypes.ts | 55 ++- .../entities/contentTypesSnippets.ts | 37 ++- .../entities/languageVariants.ts | 312 +++++++++++++++++- .../entities/utils/richText.ts | 34 +- .../entities/utils/typeElements.ts | 25 +- .../entities/workflows.ts | 62 +++- .../importExportEntities/entityDefinition.ts | 43 ++- src/utils/types.ts | 2 + 11 files changed, 523 insertions(+), 68 deletions(-) create mode 100644 src/utils/types.ts diff --git a/src/commands/export.ts b/src/commands/export.ts index 5ffbb543..b9c9ff2a 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -12,7 +12,7 @@ import { contentItemsExportEntity } from "./importExportEntities/entities/conten import { contentTypesEntity } from "./importExportEntities/entities/contentTypes.js"; import { contentTypesSnippetsEntity } from "./importExportEntities/entities/contentTypesSnippets.js"; import { languagesEntity } from "./importExportEntities/entities/languages.js"; -import { languageVariantsExportEntity } from "./importExportEntities/entities/languageVariants.js"; +import { languageVariantsEntity } from "./importExportEntities/entities/languageVariants.js"; import { previewUrlsExportEntity } from "./importExportEntities/entities/previewUrls.js"; import { rolesExportEntity } from "./importExportEntities/entities/roles.js"; import { spacesExportEntity } from "./importExportEntities/entities/spaces.js"; @@ -60,7 +60,7 @@ const entityDefinitions: ReadonlyArray> = [ contentTypesSnippetsEntity, contentTypesEntity, contentItemsExportEntity, - languageVariantsExportEntity, + languageVariantsEntity, assetFoldersEntity, assetsEntity, ]; diff --git a/src/commands/import.ts b/src/commands/import.ts index c641d988..99dc27a0 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -17,6 +17,7 @@ import { updateItemAndTypeReferencesInSnippetsImportEntity, } from "./importExportEntities/entities/contentTypesSnippets.js"; import { languagesEntity } from "./importExportEntities/entities/languages.js"; +import { languageVariantsEntity } from "./importExportEntities/entities/languageVariants.js"; import { taxonomiesEntity } from "./importExportEntities/entities/taxonomies.js"; import { workflowsEntity } from "./importExportEntities/entities/workflows.js"; import { EntityImportDefinition, ImportContext } from "./importExportEntities/entityDefinition.js"; @@ -59,6 +60,7 @@ const entityDefinitions: ReadonlyArray> = [ updateItemAndTypeReferencesInSnippetsImportEntity, updateItemAndTypeReferencesInTypesImportEntity, workflowsEntity, + languageVariantsEntity, ]; type ImportEntitiesParams = Readonly<{ @@ -105,9 +107,9 @@ const createInitialContext = (): ImportContext => ({ taxonomyTermIdsByOldIds: new Map(), assetFolderIdsByOldIds: new Map(), assetIdsByOldIds: new Map(), - contentTypeSnippetIdsWithElementsByOldIds: new Map(), - contentItemIdsByOldIds: new Map(), - contentTypeIdsWithElementsByOldIds: new Map(), + contentTypeSnippetContextWithElementsByOldIds: new Map(), + contentItemContextByOldIds: new Map(), + contentTypeContextWithElementsByOldIds: new Map(), workflowIdsByOldIds: new Map(), - worfklowStepsIdsByOldIds: new Map(), + worfklowStepsIdsWithTransitionsByOldIds: new Map(), }); diff --git a/src/commands/importExportEntities/entities/contentItems.ts b/src/commands/importExportEntities/entities/contentItems.ts index bef6ff44..bf0af13c 100644 --- a/src/commands/importExportEntities/entities/contentItems.ts +++ b/src/commands/importExportEntities/entities/contentItems.ts @@ -15,7 +15,10 @@ export const contentItemsExportEntity: EntityDefinition [fItem.id, pItem.id])), + contentItemContextByOldIds: new Map( + zip(fileItems, projectItems) + .map(([fItem, pItem]) => [fItem.id, { selfId: pItem.id, oldTypeId: fItem.type.id ?? "" }]), + ), }; }, }; @@ -28,7 +31,7 @@ const createImportItemFetcher = .addContentItem() .withData({ ...fileItem, - type: { id: context.contentTypeIdsWithElementsByOldIds.get(fileItem.type.id ?? "")?.selfId }, + type: { id: context.contentTypeContextWithElementsByOldIds.get(fileItem.type.id ?? "")?.selfId }, collection: { id: context.collectionIdsByOldIds.get(fileItem.collection.id ?? "") }, external_id: fileItem.external_id ?? fileItem.codename, }) diff --git a/src/commands/importExportEntities/entities/contentTypes.ts b/src/commands/importExportEntities/entities/contentTypes.ts index 328432fb..babbc4f7 100644 --- a/src/commands/importExportEntities/entities/contentTypes.ts +++ b/src/commands/importExportEntities/entities/contentTypes.ts @@ -1,4 +1,4 @@ -import { ContentTypeContracts, ManagementClient } from "@kontent-ai/management-sdk"; +import { ContentTypeContracts, ContentTypeElements, ManagementClient } from "@kontent-ai/management-sdk"; import { zip } from "../../../utils/array.js"; import { serially } from "../../../utils/requests.js"; @@ -18,13 +18,58 @@ export const contentTypesEntity: EntityDefinition { const elementIdEntries = zip(fileType.elements, projectType.elements) .map(([fileEl, projectEl]) => [fileEl.id ?? "", projectEl.id ?? ""] as const); - return [fileType.id, { selfId: projectType.id, elementIdsByOldIds: new Map(elementIdEntries) }]; + return [fileType.id, { + selfId: projectType.id, + elementIdsByOldIds: new Map(elementIdEntries), + elementTypeByOldIds: new Map( + fileType.elements.flatMap(el => { + if (el.type === "snippet") { + const typedEl = el as unknown as ContentTypeElements.ISnippetElement; + return [ + ...context.contentTypeSnippetContextWithElementsByOldIds.get(typedEl.snippet.id ?? "") + ?.elementTypeByOldIds ?? [], + ]; + } + + return [[el.id ?? "", el.type]]; + }), + ), + multiChoiceOptionIdsByOldIdsByOldElementId: new Map( + fileType.elements + .flatMap(el => { + switch (el.type) { + case "snippet": { + const typedEl = el as unknown as ContentTypeElements.ISnippetElement; + return [ + ...context.contentTypeSnippetContextWithElementsByOldIds.get(typedEl.snippet.id ?? "") + ?.multiChoiceOptionIdsByOldIdsByOldElementId ?? [], + ]; + } + case "multiple_choice": { + const typedEl = el as unknown as ContentTypeElements.IMultipleChoiceElement; + const typedProjectEl = projectType.elements.find(e => + e.id === el.id + ) as ContentTypeElements.IMultipleChoiceElement; + + return [[ + el.id ?? "", + new Map( + zip(typedEl.options, typedProjectEl.options).map(([fO, pO]) => [fO.id ?? "", pO.id ?? ""]), + ), + ]]; + } + default: + return []; + } + }), + ), + }]; }), ), }; @@ -82,7 +127,7 @@ const createUpdateTypeItemReferencesFetcher = .flatMap( createPatchItemAndTypeReferencesInTypeElement( params.context, - c => c.contentTypeIdsWithElementsByOldIds.get(type.id)?.elementIdsByOldIds, + c => c.contentTypeContextWithElementsByOldIds.get(type.id)?.elementIdsByOldIds, ), ); @@ -92,7 +137,7 @@ const createUpdateTypeItemReferencesFetcher = return params.client .modifyContentType() - .byTypeId(params.context.contentTypeIdsWithElementsByOldIds.get(type.id)?.selfId ?? "") + .byTypeId(params.context.contentTypeContextWithElementsByOldIds.get(type.id)?.selfId ?? "") .withData(patchOps) .toPromise(); }; diff --git a/src/commands/importExportEntities/entities/contentTypesSnippets.ts b/src/commands/importExportEntities/entities/contentTypesSnippets.ts index bc004b01..dc473ef9 100644 --- a/src/commands/importExportEntities/entities/contentTypesSnippets.ts +++ b/src/commands/importExportEntities/entities/contentTypesSnippets.ts @@ -1,4 +1,4 @@ -import { ContentTypeSnippetContracts, ManagementClient } from "@kontent-ai/management-sdk"; +import { ContentTypeElements, ContentTypeSnippetContracts, ManagementClient } from "@kontent-ai/management-sdk"; import { zip } from "../../../utils/array.js"; import { serially } from "../../../utils/requests.js"; @@ -21,15 +21,42 @@ export const contentTypesSnippetsEntity: EntityDefinition< return { ...context, - contentTypeSnippetIdsWithElementsByOldIds: new Map( + contentTypeSnippetContextWithElementsByOldIds: new Map( zip(fileSnippets, projectSnippets) .map(([fileSnippet, projectSnippet]) => { const elementIdEntries = zip(fileSnippet.elements, projectSnippet.elements) .map(([fileEl, projectEl]) => [fileEl.id ?? "", projectEl.id ?? ""] as const); - return [fileSnippet.id, { selfId: projectSnippet.id, elementIdsByOldIds: new Map(elementIdEntries) }]; + return [fileSnippet.id, { + selfId: projectSnippet.id, + elementIdsByOldIds: new Map(elementIdEntries), + elementTypeByOldIds: new Map(fileSnippet.elements.map(el => [el.id ?? "", el.type])), + multiChoiceOptionIdsByOldIdsByOldElementId: new Map( + fileSnippet.elements + .flatMap(el => { + switch (el.type) { + case "multiple_choice": { + const typedEl = el as ContentTypeElements.IMultipleChoiceElement; + const projectTypedEl = projectSnippet.elements + .find(e => e.id === el.id) as ContentTypeElements.IMultipleChoiceElement; + return [[ + el.id ?? "", + new Map( + zip(typedEl.options, projectTypedEl.options).map(([fO, pO]) => [fO.id ?? "", pO.id ?? ""]), + ), + ]]; + } + default: + return []; + } + }), + ), + }]; }), ), + elementTypesByOldElementIdsByOldSnippetIds: new Map( + fileSnippets.map(snippet => [snippet.id, new Map(snippet.elements.map(el => [el.id ?? "", el.type]))]), + ), }; }, }; @@ -81,7 +108,7 @@ const createUpdateSnippetItemAndTypeReferencesFetcher = .flatMap( createPatchItemAndTypeReferencesInTypeElement( params.context, - c => c.contentTypeSnippetIdsWithElementsByOldIds.get(snippet.id)?.elementIdsByOldIds, + c => c.contentTypeSnippetContextWithElementsByOldIds.get(snippet.id)?.elementIdsByOldIds, ), ); @@ -91,7 +118,7 @@ const createUpdateSnippetItemAndTypeReferencesFetcher = return params.client .modifyContentTypeSnippet() - .byTypeId(params.context.contentTypeSnippetIdsWithElementsByOldIds.get(snippet.id)?.selfId ?? "") + .byTypeId(params.context.contentTypeSnippetContextWithElementsByOldIds.get(snippet.id)?.selfId ?? "") .withData(patchOps) .toPromise(); }; diff --git a/src/commands/importExportEntities/entities/languageVariants.ts b/src/commands/importExportEntities/entities/languageVariants.ts index 9b63c941..39898969 100644 --- a/src/commands/importExportEntities/entities/languageVariants.ts +++ b/src/commands/importExportEntities/entities/languageVariants.ts @@ -1,10 +1,18 @@ -import { LanguageVariantModels } from "@kontent-ai/management-sdk"; +import { + ElementContracts, + LanguageVariantContracts, + LanguageVariantElements, + LanguageVariantElementsBuilder, + ManagementClient, +} from "@kontent-ai/management-sdk"; import { serially } from "../../../utils/requests.js"; -import { EntityDefinition } from "../entityDefinition.js"; +import { notNull } from "../../../utils/typeguards.js"; +import { EntityDefinition, ImportContext } from "../entityDefinition.js"; +import { replaceRichTextReferences } from "./utils/richText.js"; -export const languageVariantsExportEntity: EntityDefinition< - ReadonlyArray +export const languageVariantsEntity: EntityDefinition< + ReadonlyArray > = { name: "languageVariants", fetchEntities: async client => { @@ -15,18 +23,300 @@ export const languageVariantsExportEntity: EntityDefinition< .listLanguageVariantsByCollection() .byCollectionCodename(collection.codename) .toAllPromise() - .then(res => res.data.items) + .then(res => res.responses.flatMap(res => res.rawData.variants)) ); const variants = await serially(promises); return variants.flatMap(arr => arr); }, - serializeEntities: collections => JSON.stringify(collections), - importEntities: () => { - throw new Error("Not supported yet."); - }, - deserializeEntities: () => { - throw new Error("Not supported yet."); + serializeEntities: JSON.stringify, + deserializeEntities: JSON.parse, + importEntities: async (client, fileVariants, context) => { + await serially(fileVariants.map(createImportVariant(client, context))); }, }; + +const createImportVariant = + (client: ManagementClient, context: ImportContext) => + (fileVariant: LanguageVariantContracts.ILanguageVariantModelContract) => + async (): Promise => { + const typeContext = context.contentTypeContextWithElementsByOldIds + .get(context.contentItemContextByOldIds.get(fileVariant.item.id ?? "")?.oldTypeId ?? ""); + + if (!typeContext) { + throw new Error(`Cannot find type context for item "${fileVariant.item.id}".`); + } + + const newWfContext = findTargetWfStep(context, fileVariant); + + const projectVariant = await client + .upsertLanguageVariant() + .byItemId(context.contentItemContextByOldIds.get(fileVariant.item.id ?? "")?.selfId ?? "") + .byLanguageId(context.languageIdsByOldIds.get(fileVariant.language.id ?? "") ?? "") + .withData(builder => ({ + workflow: { + workflow_identifier: { + id: newWfContext.wfId, + }, + step_identifier: { + id: newWfContext.wfStepId, + }, + }, + elements: fileVariant.elements.map(createTransformElement({ + builder, + context, + elementIdByOldId: typeContext.elementIdsByOldIds, + elementTypeByOldId: typeContext.elementTypeByOldIds, + multiChoiceOptionIdsByOldIdsByOldElementId: typeContext.multiChoiceOptionIdsByOldIdsByOldElementId, + })) + .filter(notNull), + })) + .toPromise() + .then(res => res.rawData); + + switch (newWfContext.nextAction.action) { + case "none": + return true; + case "publish": + await publishVariant(client, projectVariant); + return true; + case "schedule": + await publishVariant(client, projectVariant, newWfContext.nextAction.to); + return true; + case "archive": + await publishVariant(client, projectVariant); + await client + .unpublishLanguageVariant() + .byItemId(projectVariant.item.id ?? "") + .byLanguageId(projectVariant.language.id ?? "") + .withoutData() + .toPromise(); + return true; + } + }; + +const publishVariant = async ( + client: ManagementClient, + variant: LanguageVariantContracts.ILanguageVariantModelContract, + scheduleTo?: Date, +) => { + const sharedRequest = client + .publishLanguageVariant() + .byItemId(variant.item.id ?? "") + .byLanguageId(variant.language.id ?? ""); + + return scheduleTo + ? await sharedRequest + .withData({ + scheduled_to: scheduleTo.toISOString(), + }) + .toPromise() + : await sharedRequest + .withoutData() + .toPromise(); +}; + +type TransformElementParams = Readonly<{ + context: ImportContext; + builder: LanguageVariantElementsBuilder; + elementTypeByOldId: ReadonlyMap; + elementIdByOldId: ReadonlyMap; + multiChoiceOptionIdsByOldIdsByOldElementId: ReadonlyMap>; +}>; + +const createTransformElement = (params: TransformElementParams) => +( + fileElement: ElementContracts.IContentItemElementContract, +): LanguageVariantElements.ILanguageVariantElementBase | null => { + const elementType = params.elementTypeByOldId.get(fileElement.element.id ?? ""); + const projectElementId = getProjectElementId(params, fileElement.element.id ?? ""); + if (!elementType || !projectElementId) { + return null; // Ignore elements that are not present in the type (This can happen for example when you remove an element from a type that already has variants) + } + + switch (elementType) { + case "asset": { + const typedElement = fileElement as LanguageVariantElements.IAssetInVariantElement; + return params.builder.assetElement({ + element: { id: projectElementId }, + value: typedElement.value + .map(ref => createReference(params.context.assetIdsByOldIds.get(ref.id ?? "") ?? null)), + }); + } + case "custom": { + const typedElement = fileElement as LanguageVariantElements.ICustomElementInVariantElement; + return params.builder.customElement({ + element: { id: projectElementId }, + value: typedElement.value, + searchable_value: typedElement.searchable_value, + }); + } + case "date_time": { + const typedElement = fileElement as LanguageVariantElements.IDateTimeInVariantElement; + return params.builder.dateTimeElement({ + element: { id: projectElementId }, + value: typedElement.value, + }); + } + case "modular_content": { + const typedElement = fileElement as LanguageVariantElements.ILinkedItemsInVariantElement; + return params.builder.linkedItemsElement({ + element: { id: projectElementId }, + value: typedElement.value + .map(ref => createReference(params.context.contentItemContextByOldIds.get(ref.id ?? "")?.selfId ?? null)), + }); + } + case "multiple_choice": { + const typedElement = fileElement as LanguageVariantElements.IMultipleChoiceInVariantElement; + const optionIdsByOldIds = params.multiChoiceOptionIdsByOldIdsByOldElementId.get(typedElement.element.id ?? ""); + + if (!optionIdsByOldIds) { + throw new Error( + `Multiple option element with id "${typedElement.element.id}" has no equivalent in the item's type.`, + ); + } + return params.builder.multipleChoiceElement({ + element: { id: projectElementId }, + value: typedElement.value.map(ref => ({ id: optionIdsByOldIds.get(ref.id ?? "") })), + }); + } + case "number": { + const typedElement = fileElement as LanguageVariantElements.INumberInVariantElement; + return params.builder.numberElement({ + element: { id: projectElementId }, + value: typedElement.value, + }); + } + case "rich_text": { + const typedElement = fileElement as LanguageVariantElements.IRichtextInVariantElement; + return params.builder.richTextElement({ + element: { id: projectElementId }, + value: typedElement.value + ? replaceRichTextReferences( + typedElement.value, + params.context, + new Set(typedElement.components?.map(c => c.id) ?? []), + ) + : typedElement.value, + components: typedElement.components?.map(c => { + const typeContext = params.context.contentTypeContextWithElementsByOldIds.get(c.type.id ?? ""); + if (!typeContext) { + throw new Error( + `Found a content component of type that was not found. ComponentId "${c.id}", Type id: "${c.type.id}"`, + ); + } + + return ({ + id: c.id, + type: { id: typeContext.selfId }, + elements: c.elements.map(createTransformElement({ + ...params, + elementIdByOldId: typeContext.elementIdsByOldIds, + elementTypeByOldId: typeContext.elementTypeByOldIds, + multiChoiceOptionIdsByOldIdsByOldElementId: typeContext.multiChoiceOptionIdsByOldIdsByOldElementId, + })) + .filter(notNull), + }); + }), + }); + } + case "subpages": { + const typedElement = fileElement as LanguageVariantElements.ILinkedItemsInVariantElement; + return params.builder.linkedItemsElement({ + element: { id: projectElementId }, + value: typedElement.value.map(ref => + createReference(params.context.contentItemContextByOldIds.get(ref.id ?? "")?.selfId ?? null) + ), + }); + } + case "taxonomy": { + const typedElement = fileElement as LanguageVariantElements.ITaxonomyInVariantElement; + return params.builder.taxonomyElement({ + element: { id: projectElementId }, + value: typedElement.value + .map(ref => createReference(params.context.taxonomyTermIdsByOldIds.get(ref.id ?? "") ?? null)), + }); + } + case "text": { + const typedElement = fileElement as LanguageVariantElements.ITextInVariantElement; + return params.builder.textElement({ + element: { id: projectElementId }, + value: typedElement.value, + }); + } + case "url_slug": { + const typedElement = fileElement as LanguageVariantElements.IUrlSlugInVariantElement; + return params.builder.urlSlugElement({ + element: { id: projectElementId }, + value: typedElement.value, + mode: typedElement.mode, + }); + } + default: + throw new Error(`Found an element "${JSON.stringify(fileElement)}" of an unknown type.`); + } +}; + +type FindWfStepResult = Readonly<{ + wfStepId: string; + wfId: string; + nextAction: + | Readonly<{ action: "none" }> + | Readonly<{ action: "publish" }> + | Readonly<{ action: "archive" }> + | Readonly<{ action: "schedule"; to: Date }>; +}>; + +const findTargetWfStep = ( + context: ImportContext, + oldWf: LanguageVariantContracts.ILanguageVariantModelContract, +): FindWfStepResult => { + const wfContext = context.workflowIdsByOldIds.get(oldWf.workflow.workflow_identifier.id ?? ""); + + if (!wfContext) { + throw new Error( + `Found a variant in an unknown workflow (workflow id: "${oldWf.workflow.workflow_identifier.id}").`, + ); + } + const translateStepId = createTranslateWfStepId(context); + + switch (oldWf.workflow.step_identifier.id) { + case wfContext.oldPublishedStepId: + return { + wfId: wfContext.selfId, + wfStepId: translateStepId(wfContext.anyStepIdLeadingToPublishedStep), + nextAction: { action: "publish" }, + }; + case wfContext.oldScheduledStepId: + return { + wfId: wfContext.selfId, + wfStepId: translateStepId(wfContext.anyStepIdLeadingToPublishedStep), + nextAction: { action: "publish" }, // There is no way to determing the date when the variant should be published using the current MAPI so we will publish it immediately instead for now + }; + case wfContext.oldArchivedStepId: + return { + wfId: wfContext.selfId, + wfStepId: translateStepId(wfContext.anyStepIdLeadingToPublishedStep), + nextAction: { action: "archive" }, + }; + default: { + return { + wfId: wfContext.selfId, + wfStepId: oldWf.workflow.step_identifier.id ?? "", + nextAction: { action: "none" }, + }; + } + } +}; + +const createTranslateWfStepId = (context: ImportContext) => (stepId: string): string => + context.worfklowStepsIdsWithTransitionsByOldIds.get(stepId)?.selfId ?? ""; + +const getProjectElementId = (params: TransformElementParams, fileId: string): string | null => + Array.from(params.context.contentTypeSnippetContextWithElementsByOldIds.values()) + .find(c => c.elementIdsByOldIds.has(fileId)) + ?.elementIdsByOldIds + .get(fileId) ?? params.elementIdByOldId.get(fileId) ?? null; + +const createReference = (id: string | null) => id ? { id } : { external_id: "referenceToAMissingEntity" }; diff --git a/src/commands/importExportEntities/entities/utils/richText.ts b/src/commands/importExportEntities/entities/utils/richText.ts index c7dccecf..53ea29b2 100644 --- a/src/commands/importExportEntities/entities/utils/richText.ts +++ b/src/commands/importExportEntities/entities/utils/richText.ts @@ -1,27 +1,41 @@ import { ImportContext } from "../../entityDefinition.js"; -export const replaceRichTextReferences = (richText: string, context: ImportContext): string => +export const replaceRichTextReferences = ( + richText: string, + context: ImportContext, + componentIds: ReadonlySet, +): string => richText - .replaceAll(assetRegex, (_, assetId /* from the regex capture group*/) => { - const newAssetId = context.assetIdsByOldIds.get(assetId); + .replaceAll(assetRegex, (_, oldAssetId /* from the regex capture group*/) => { + const newAssetId = context.assetIdsByOldIds.get(oldAssetId); if (!newAssetId) { - console.warn(`Found asset id "${assetId}" in rich text that doesn't exist.`); + console.warn(`Found asset id "${oldAssetId}" in rich text that doesn't exist.`); + return `${assetExternalIdAttributeName}="${oldAssetId}"`; } return `${assetAtributeName}="${newAssetId}"`; }) - .replaceAll(itemRegex, (_, itemId /* from the regex capture group*/) => { - const newItemId = context.contentItemIdsByOldIds.get(itemId); + .replaceAll(itemOrComponentRegex, (_, oldItemId /* from the regex capture group*/) => { + // Don't change component ids + if (componentIds.has(oldItemId)) { + return `${itemOrComponentAttributeName}="${oldItemId}"`; + } + + const newItemId = context.contentItemContextByOldIds.get(oldItemId); if (!newItemId) { - console.warn(`Found item id "${itemId}" in rich text that doesn't exist.`); + console.warn(`Found item id "${oldItemId}" in rich text that doesn't exist.`); + return `${itemExternalIdAttributeName}="${oldItemId}"`; } - return `${itemAttributeName}="${newItemId}"`; + return `${itemOrComponentAttributeName}="${newItemId}"`; }); const uuidRegex = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; const assetAtributeName = "data-asset-id"; -const itemAttributeName = "data-id"; +const itemOrComponentAttributeName = "data-id"; const assetRegex = new RegExp(`${assetAtributeName}="(${uuidRegex})"`, "gi"); -const itemRegex = new RegExp(`${itemAttributeName}="(${uuidRegex})"`, "gi"); +const itemOrComponentRegex = new RegExp(`${itemOrComponentAttributeName}="(${uuidRegex})"`, "gi"); + +const assetExternalIdAttributeName = "data-asset-external-id"; +const itemExternalIdAttributeName = "data-external-id"; diff --git a/src/commands/importExportEntities/entities/utils/typeElements.ts b/src/commands/importExportEntities/entities/utils/typeElements.ts index bfcb5123..8cbb40d2 100644 --- a/src/commands/importExportEntities/entities/utils/typeElements.ts +++ b/src/commands/importExportEntities/entities/utils/typeElements.ts @@ -148,7 +148,7 @@ export const createTransformTypeElement = external_id: typedElement.external_id ?? fallbackExternalId, content_group, snippet: { - id: params.context.contentTypeSnippetIdsWithElementsByOldIds.get(typedElement.snippet.id ?? "")?.selfId, + id: params.context.contentTypeSnippetContextWithElementsByOldIds.get(typedElement.snippet.id ?? "")?.selfId, }, }); } @@ -208,14 +208,15 @@ export const createTransformTypeElement = depends_on: { element: typedElement.depends_on.snippet ? { - id: - params.context.contentTypeSnippetIdsWithElementsByOldIds.get(typedElement.depends_on.snippet.id ?? "") - ?.elementIdsByOldIds.get(typedElement.depends_on.element.id ?? "") ?? "", + id: params.context.contentTypeSnippetContextWithElementsByOldIds.get( + typedElement.depends_on.snippet.id ?? "", + ) + ?.elementIdsByOldIds.get(typedElement.depends_on.element.id ?? "") ?? "", } : { external_id: params.elementExternalIdsByOldId.get(typedElement.depends_on.element.id ?? "") }, snippet: typedElement.depends_on.snippet?.id ? { - id: params.context.contentTypeSnippetIdsWithElementsByOldIds.get(typedElement.depends_on.snippet.id) + id: params.context.contentTypeSnippetContextWithElementsByOldIds.get(typedElement.depends_on.snippet.id) ?.selfId, } : undefined, @@ -254,7 +255,7 @@ export const createPatchItemAndTypeReferencesInTypeElement = case "guidelines": { const typedElement = fileElement as unknown as ContentTypeElements.IGuidelinesElement; - const newGuidelines = replaceRichTextReferences(typedElement.guidelines, context); + const newGuidelines = replaceRichTextReferences(typedElement.guidelines, context, new Set()); return newGuidelines === typedElement.guidelines ? [] : [ { @@ -272,7 +273,7 @@ export const createPatchItemAndTypeReferencesInTypeElement = op: "replace" as const, path: `/elements/id:${newElementId}/allowed_content_types`, value: typedElement.allowed_content_types.map(ref => ({ - id: context.contentTypeIdsWithElementsByOldIds.get(ref.id ?? "")?.selfId, + id: context.contentTypeContextWithElementsByOldIds.get(ref.id ?? "")?.selfId, })), }, typedElement.default && { @@ -281,7 +282,7 @@ export const createPatchItemAndTypeReferencesInTypeElement = value: { global: { value: typedElement.default.global.value.map(ref => ({ - id: context.contentItemIdsByOldIds.get(ref.id ?? ""), + id: context.contentItemContextByOldIds.get(ref.id ?? ""), })), }, }, @@ -296,14 +297,14 @@ export const createPatchItemAndTypeReferencesInTypeElement = op: "replace" as const, path: `/elements/id:${newElementId}/allowed_content_types`, value: typedElement.allowed_content_types.map(ref => ({ - id: context.contentTypeIdsWithElementsByOldIds.get(ref.id ?? "")?.selfId, + id: context.contentTypeContextWithElementsByOldIds.get(ref.id ?? "")?.selfId, })), }, typedElement.allowed_item_link_types && { op: "replace" as const, path: `/elements/id:${newElementId}/allowed_item_link_types`, value: typedElement.allowed_item_link_types.map(ref => ({ - id: context.contentTypeIdsWithElementsByOldIds.get(ref.id ?? "")?.selfId, + id: context.contentTypeContextWithElementsByOldIds.get(ref.id ?? "")?.selfId, })), }, ].filter(notNullOrUndefined); @@ -318,7 +319,7 @@ export const createPatchItemAndTypeReferencesInTypeElement = op: "replace" as const, path: `/elements/id:${newElementId}/allowed_content_types`, value: typedElement.allowed_content_types.map(ref => ({ - id: context.contentTypeIdsWithElementsByOldIds.get(ref.id ?? "")?.selfId, + id: context.contentTypeContextWithElementsByOldIds.get(ref.id ?? "")?.selfId, })), }, typedElement.default && { @@ -327,7 +328,7 @@ export const createPatchItemAndTypeReferencesInTypeElement = value: { global: { value: typedElement.default.global.value.map(ref => ({ - id: context.contentItemIdsByOldIds.get(ref.id ?? ""), + id: context.contentItemContextByOldIds.get(ref.id ?? ""), })), }, }, diff --git a/src/commands/importExportEntities/entities/workflows.ts b/src/commands/importExportEntities/entities/workflows.ts index c985fa5e..7b38bfb2 100644 --- a/src/commands/importExportEntities/entities/workflows.ts +++ b/src/commands/importExportEntities/entities/workflows.ts @@ -3,6 +3,7 @@ import { ManagementClient, WorkflowContracts, WorkflowModels } from "@kontent-ai import { emptyId } from "../../../constants/ids.js"; import { zip } from "../../../utils/array.js"; import { serially } from "../../../utils/requests.js"; +import { MapValues } from "../../../utils/types.js"; import { EntityDefinition, ImportContext } from "../entityDefinition.js"; const defaultWorkflowId = emptyId; @@ -24,12 +25,20 @@ export const workflowsEntity: EntityDefinition s.transitions_to.find(t => t.step.id === importDefaultWf.published_step.id))?.id ?? "", + }; return { ...context, - workflowIdsByOldIds: new Map([...newProjectWfs.workflows, [defaultWorkflowId, defaultWorkflowId]]), - worfklowStepsIdsByOldIds: new Map([...newProjectWfs.workflowSteps, ...newDefaultWfStepIdEntries]), + workflowIdsByOldIds: new Map([...newProjectWfs.workflows, [defaultWorkflowId, defaultWorkflowContext]]), + worfklowStepsIdsWithTransitionsByOldIds: new Map([...newProjectWfs.workflowSteps, ...newDefaultWfStepIdEntries]), }; }, }; @@ -38,7 +47,7 @@ const createWorkflowData = (importWorkflow: WorkflowContracts.IWorkflowContract, ...importWorkflow, scopes: importWorkflow.scopes.map(scope => ({ content_types: scope.content_types - .map(type => ({ id: context.contentTypeIdsWithElementsByOldIds.get(type.id ?? "")?.selfId })), + .map(type => ({ id: context.contentTypeContextWithElementsByOldIds.get(type.id ?? "")?.selfId })), collections: scope.collections.map(collection => ({ id: context.collectionIdsByOldIds.get(collection.id ?? "") })), })), steps: importWorkflow.steps.map(step => ({ @@ -93,9 +102,17 @@ const addWorkflows = async ( .toPromise() .then(res => res.rawData); - const stepsIds = zip(extractAllStepIds(importWorkflow), extractAllStepIds(response)); - - return { workflow: [importWorkflow.id, response.id] as const, workflowSteps: stepsIds }; + const workflowSteps = extractStepIdEntriesWithContext(importWorkflow, response); + const workflowContext: MapValues = { + selfId: response.id, + oldPublishedStepId: response.published_step.id, + oldArchivedStepId: response.archived_step.id, + oldScheduledStepId: response.scheduled_step.id, + anyStepIdLeadingToPublishedStep: + response.steps.find(s => s.transitions_to.find(t => t.step.id === response.published_step.id))?.id ?? "", + }; + + return { workflow: [importWorkflow.id, workflowContext] as const, workflowSteps }; }), ); @@ -107,13 +124,36 @@ const addWorkflows = async ( }; type ContextWorkflowEntries = Readonly<{ - workflows: ReadonlyArray; - workflowSteps: ReadonlyArray; + workflows: ReadonlyArray]>; + workflowSteps: ReadonlyArray]>; }>; -type AnyStep = Readonly<{ id: string; name: string; codename: string }>; +const extractStepIdEntriesWithContext = ( + importWorkflow: WorkflowContracts.IWorkflowContract, + projectWorkflow: WorkflowContracts.IWorkflowContract, +) => + zip(extractAllSteps(importWorkflow), extractAllStepIds(projectWorkflow)) + .map(([oldStep, newStepId]) => + [oldStep.id, { + selfId: newStepId, + oldTransitionIds: oldStep.transitions_to.map(t => t.step.id ?? ""), + }] as const + ); + +type AnyStep = Readonly< + { id: string; name: string; codename: string; transitions_to: WorkflowContracts.IWorkflowStepTransitionsToContract[] } +>; const extractAllSteps = (wf: WorkflowContracts.IWorkflowContract): ReadonlyArray => - (wf.steps as AnyStep[]).concat([wf.scheduled_step, wf.published_step, wf.archived_step]); + (wf.steps as AnyStep[]).concat([ + setOnlyTransition(wf.scheduled_step, wf.archived_step.id), + setOnlyTransition(wf.published_step, wf.archived_step.id), + { ...wf.archived_step, transitions_to: [] }, + ]); + +const setOnlyTransition = (step: Omit, transitionId: string): AnyStep => ({ + ...step, + transitions_to: [{ step: { id: transitionId } }], +}); const extractAllStepIds = (wf: WorkflowContracts.IWorkflowContract): ReadonlyArray => extractAllSteps(wf).map(s => s.id); diff --git a/src/commands/importExportEntities/entityDefinition.ts b/src/commands/importExportEntities/entityDefinition.ts index f445b66c..fb3e4c6a 100644 --- a/src/commands/importExportEntities/entityDefinition.ts +++ b/src/commands/importExportEntities/entityDefinition.ts @@ -33,14 +33,45 @@ export type ImportContext = Readonly<{ taxonomyTermIdsByOldIds: IdsMap; assetFolderIdsByOldIds: IdsMap; assetIdsByOldIds: IdsMap; - contentTypeSnippetIdsWithElementsByOldIds: ReadonlyMap< + contentTypeSnippetContextWithElementsByOldIds: ReadonlyMap< string, - Readonly<{ selfId: string; elementIdsByOldIds: IdsMap }> + Readonly<{ + selfId: string; + elementIdsByOldIds: IdsMap; + elementTypeByOldIds: ReadonlyMap; + multiChoiceOptionIdsByOldIdsByOldElementId: ReadonlyMap; + }> + >; + contentTypeContextWithElementsByOldIds: ReadonlyMap< + string, + Readonly<{ + selfId: string; + elementIdsByOldIds: IdsMap; + /** + * This does have snippet elements inlined. + */ + elementTypeByOldIds: ReadonlyMap; + /** + * This does have snippet elements inlined. + */ + multiChoiceOptionIdsByOldIdsByOldElementId: ReadonlyMap; + }> + >; + contentItemContextByOldIds: ReadonlyMap>; + workflowIdsByOldIds: ReadonlyMap< + string, + Readonly<{ + selfId: string; + oldPublishedStepId: string; + oldScheduledStepId: string; + oldArchivedStepId: string; + anyStepIdLeadingToPublishedStep: string; + }> + >; + worfklowStepsIdsWithTransitionsByOldIds: ReadonlyMap< + string, + Readonly<{ selfId: string; oldTransitionIds: ReadonlyArray }> >; - contentTypeIdsWithElementsByOldIds: ReadonlyMap>; - contentItemIdsByOldIds: IdsMap; - workflowIdsByOldIds: IdsMap; - worfklowStepsIdsByOldIds: IdsMap; }>; type IdsMap = ReadonlyMap; diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 00000000..d66b2c0c --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,2 @@ +export type MapValues> = Map extends ReadonlyMap ? Res + : never;