From 44c2a3deaa5bc3ace153f79f82fb3fd8d6364802 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Wed, 3 Apr 2024 10:54:40 +0200 Subject: [PATCH] Create diff combinators and diff handlers for content models - Diff handlers return an array of patch operations that need to be applied to the target to get into the same shape as source Implement diff function diffing whole content model Transform guidelines before using them in addInto operation - Handle references out of order in guidelines - references can also be mixed with other attributes - there can be just codename without external id attribute or the other way around --- package-lock.json | 8 +- package.json | 2 +- src/modules/sync/diff.ts | 105 +++- src/modules/sync/diff/combinators.ts | 198 +++++++ src/modules/sync/diff/contentType.ts | 87 +++ src/modules/sync/diff/contentTypeSnippet.ts | 73 +++ src/modules/sync/diff/guidelinesRichText.ts | 104 ++++ src/modules/sync/diff/modelElements.ts | 314 +++++++++++ src/modules/sync/diff/taxonomy.ts | 16 + src/modules/sync/diff/transformToAddModel.ts | 112 ++++ .../modelTransfomers/elementTransformers.ts | 4 +- src/modules/sync/types/diffModel.ts | 50 +- src/modules/sync/types/syncModel.ts | 4 +- src/modules/sync/utils/patchOperations.ts | 38 -- src/utils/function.ts | 6 + src/utils/object.ts | 4 + src/utils/types.ts | 7 +- .../integration/importExport/utils/compare.ts | 84 ++- tests/unit/sync/diff/combinators.test.ts | 397 ++++++++++++++ tests/unit/sync/diff/contentType.test.ts | 227 ++++++++ tests/unit/sync/diff/modelElements.test.ts | 503 ++++++++++++++++++ tests/unit/sync/diff/taxonomyGroup.test.ts | 99 ++++ tests/unit/sync/diff/utils.ts | 1 + tsconfig.tests.jsonc | 14 +- 24 files changed, 2329 insertions(+), 128 deletions(-) create mode 100644 src/modules/sync/diff/combinators.ts create mode 100644 src/modules/sync/diff/contentType.ts create mode 100644 src/modules/sync/diff/contentTypeSnippet.ts create mode 100644 src/modules/sync/diff/guidelinesRichText.ts create mode 100644 src/modules/sync/diff/modelElements.ts create mode 100644 src/modules/sync/diff/taxonomy.ts create mode 100644 src/modules/sync/diff/transformToAddModel.ts delete mode 100644 src/modules/sync/utils/patchOperations.ts create mode 100644 src/utils/function.ts create mode 100644 tests/unit/sync/diff/combinators.test.ts create mode 100644 tests/unit/sync/diff/contentType.test.ts create mode 100644 tests/unit/sync/diff/modelElements.test.ts create mode 100644 tests/unit/sync/diff/taxonomyGroup.test.ts create mode 100644 tests/unit/sync/diff/utils.ts diff --git a/package-lock.json b/package-lock.json index 096d6338..296df339 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.1", "license": "MIT", "dependencies": { - "@kontent-ai/management-sdk": "^6.0.0", + "@kontent-ai/management-sdk": "^6.1.0", "@kontent-ai/rich-text-resolver": "^1.1.0-beta", "archiver": "^6.0.1", "chalk": "^5.3.0", @@ -1653,9 +1653,9 @@ } }, "node_modules/@kontent-ai/management-sdk": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@kontent-ai/management-sdk/-/management-sdk-6.0.0.tgz", - "integrity": "sha512-7VdBDL0wlQPpqEDnk868FbHLFWI4o2iO2d+f9yodnEWAK7W+BjPWM/QyHz98Kts91Zy+u10/90kX2Abw3oHSQw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@kontent-ai/management-sdk/-/management-sdk-6.1.0.tgz", + "integrity": "sha512-nVNJie5YxJMAarkiZdRxqzdnvxsCu0Axm/cwplqZJl0iRx2TYq4YxO38PkF6Rk1rU0ff75I2Is32WTv+iR3WxQ==", "dependencies": { "@kontent-ai/core-sdk": "10.4.0", "mime": "3.0.0" diff --git a/package.json b/package.json index 890343c6..68dc9ebd 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ }, "homepage": "https://github.com/kontent-ai/data-ops#readme", "dependencies": { - "@kontent-ai/management-sdk": "^6.0.0", + "@kontent-ai/management-sdk": "^6.1.0", "@kontent-ai/rich-text-resolver": "^1.1.0-beta", "archiver": "^6.0.1", "chalk": "^5.3.0", diff --git a/src/modules/sync/diff.ts b/src/modules/sync/diff.ts index ab57b39a..d57842f8 100644 --- a/src/modules/sync/diff.ts +++ b/src/modules/sync/diff.ts @@ -1,11 +1,102 @@ -import { ManagementClient } from "@kontent-ai/management-sdk"; +import { Handler } from "./diff/combinators.js"; +import { makeContentTypeHandler, wholeContentTypesHandler } from "./diff/contentType.js"; +import { makeContentTypeSnippetHandler, wholeContentTypeSnippetsHandler } from "./diff/contentTypeSnippet.js"; +import { makeTaxonomyGroupHandler, wholeTaxonomyGroupsHandler } from "./diff/taxonomy.js"; +import { + transformSnippetToAddModel, + transformTaxonomyToAddModel, + transformTypeToAddModel, +} from "./diff/transformToAddModel.js"; +import { DiffModel, PatchOperation } from "./types/diffModel.js"; +import { FileContentModel } from "./types/fileContentModel.js"; -type DiffOptions = Readonly<{ - filname: string; +type DiffParams = Readonly<{ + targetItemsReferencedFromSourceByCodenames: ReadonlyMap>; + targetAssetsReferencedFromSourceByCodenames: ReadonlyMap>; + sourceEnvModel: FileContentModel; + targetEnvModel: FileContentModel; }>; -export const diff = (client: ManagementClient, config: DiffOptions) => { - // TODO - client as never; - config as never; +export const diff = (params: DiffParams): DiffModel => { + const diffType = makeContentTypeHandler({ + targetItemsByCodenames: params.targetItemsReferencedFromSourceByCodenames, + targetAssetsByCodenames: params.targetAssetsReferencedFromSourceByCodenames, + }); + + const typesDiffModel = createDiffModel( + wholeContentTypesHandler( + params.sourceEnvModel.contentTypes, + params.targetEnvModel.contentTypes, + ), + diffType, + ); + + const diffSnippet = makeContentTypeSnippetHandler({ + targetItemsByCodenames: params.targetItemsReferencedFromSourceByCodenames, + targetAssetsByCodenames: params.targetAssetsReferencedFromSourceByCodenames, + }); + + const snippetsDiffModel = createDiffModel( + wholeContentTypeSnippetsHandler( + params.sourceEnvModel.contentTypeSnippets, + params.targetEnvModel.contentTypeSnippets, + ), + diffSnippet, + ); + + const taxonomyDiffModel = createDiffModel( + wholeTaxonomyGroupsHandler(params.sourceEnvModel.taxonomyGroups, params.targetEnvModel.taxonomyGroups), + makeTaxonomyGroupHandler(), + ); + + return { + // All the arrays are mutable in the SDK (even though they shouldn't) and readonly in our models. Unfortunately, TS doesn't allow casting it without casting to unknown first. + taxonomyGroups: mapAdded(taxonomyDiffModel, transformTaxonomyToAddModel), + contentTypeSnippets: mapAdded(snippetsDiffModel, transformSnippetToAddModel(params)), + contentTypes: mapAdded(typesDiffModel, transformTypeToAddModel(params)), + }; }; + +const mapAdded = }>( + model: Model, + transformer: (added: Entity) => NewEntity, +): Omit & { readonly added: ReadonlyArray } => ({ + ...model, + added: model.added.map(transformer), +}); + +const createDiffModel = >( + ops: ReadonlyArray, + diffHandler: Handler, +) => + ops + .reduce>; deleted: Set }>>( + (prev, op) => { + switch (op.op) { + case "replace": { + const typedValue = op.value as Entity; + prev.updated.set(typedValue.codename, diffHandler(typedValue, op.oldValue as Entity)); + + return prev; + } + case "move": { + return prev; // we can't order top-level models + } + case "remove": { + const typedOldValue = op.oldValue as Entity; + prev.deleted.add(typedOldValue.codename); + + return prev; + } + case "addInto": { + const typedValue = op.value as Entity; + prev.added.push(typedValue); + + return prev; + } + default: + return prev; + } + }, + { added: [], updated: new Map(), deleted: new Set() }, + ); diff --git a/src/modules/sync/diff/combinators.ts b/src/modules/sync/diff/combinators.ts new file mode 100644 index 00000000..60ef191f --- /dev/null +++ b/src/modules/sync/diff/combinators.ts @@ -0,0 +1,198 @@ +import { zip } from "../../../utils/array.js"; +import { apply } from "../../../utils/function.js"; +import { PatchOperation } from "../types/diffModel.js"; + +export type Handler = (sourceValue: Entity, targetValue: Entity) => ReadonlyArray; +export type ContextfulHandler = Readonly< + { contextfulHandler: (context: Context) => Handler } +>; + +/** + * Create patch operations for changed properties inside the object. + * + * @param innerHandlers - Provide handlers for each property inside the object. + */ +export const makeObjectHandler = ( + innerHandlers: Omit< + { + readonly [k in keyof Entity]-?: + | Handler + | ContextfulHandler, Entity[k]>; + }, + "id" | "codename" | "external_id" + >, +): Handler => +(sourceValue, targetValue) => { + return (Object.entries(innerHandlers) as unknown as [ + keyof Entity & string, + | Handler + | ContextfulHandler, Entity[keyof Entity]>, + ][]) + .flatMap(([key, someHandler]) => { + const handler = typeof someHandler === "function" + ? someHandler + : someHandler.contextfulHandler({ source: sourceValue, target: targetValue }); + + return handler(sourceValue[key], targetValue[key]) + .map(prefixOperationPath(key)); + }); +}; + +/** + * Replaces the whole object if any of its properties are different. (===) is used for comparison. + * + * @param customComparers - use custom comparers for individual properties. The comparers should return true when the properties are the same and false otherwise. + * + * @param transformBeforeReplace - provide to transform the source object for the patch operation that will replace the target + */ +export const makeLeafObjectHandler = ( + customComparers: Omit< + { readonly [k in keyof Entity]?: (source: Entity[k], target: Entity[k]) => boolean }, + "id" | "codename" | "external_id" + >, + transformBeforeReplace: (v: Entity) => unknown = x => x, +): Handler => +(sourceValue, targetValue) => { + const shouldReplace = zip( + Object.keys(sourceValue) as (keyof typeof customComparers)[], + zip(Object.values(sourceValue), Object.values(targetValue)), + ) + .some(([key, [source, target]]) => + customComparers[key] ? !customComparers[key]?.(source, target) : source !== target + ); + + return shouldReplace + ? [{ op: "replace", value: transformBeforeReplace(sourceValue), oldValue: targetValue, path: "" }] + : []; +}; + +type LazyHandler = Readonly<{ lazyHandler: () => Handler }>; + +/** + * Creates patch operations for entities in an array. + * It matches the entities by codename and creates "move", "addInto", "remove" and "replace" operations. + * + * @param getCodename - function to get the codename from an entity inside the array + * + * @param createUpdateOps - update handler for entities inside the array (will only be called on entities with matching codenames) + * + * @param transformBeforeAdd - optional transformation of entities before they are added into the "addInto" patch operation + */ +export const makeArrayHandler = ( + getCodename: (el: Entity) => string, + createUpdateOps: Handler | LazyHandler, + transformBeforeAdd: (el: Entity) => Entity = x => x, +): Handler => +(sourceValue, targetValue) => { + const getCreateUpdateOps = () => + typeof createUpdateOps === "object" ? createUpdateOps.lazyHandler() : createUpdateOps; + + const sourceCodenamesSet = new Set(sourceValue.map(getCodename)); + const targetWithoutRemoved = targetValue.filter(v => sourceCodenamesSet.has(getCodename(v))); + const addAndUpdateOps = sourceValue + .flatMap((v, i) => { + const sourceCodename = getCodename(v); + const targetOnIndex = targetWithoutRemoved[i]; + if (targetOnIndex && sourceCodename === getCodename(targetOnIndex)) { + return getCreateUpdateOps()(v, targetOnIndex) + .map(prefixOperationPath(`codename:${getCodename(v)}`)); + } + const targetOnOtherIndex = targetWithoutRemoved.find(t => getCodename(t) === sourceCodename); + if (targetOnOtherIndex) { + const neighbourCodename = apply(getCodename, sourceValue[i - 1]) + || apply(getCodename, targetValue[0]) || null; + return [ + ...neighbourCodename === null ? [] : [ + { + op: "move" as const, + path: `/codename:${getCodename(targetOnOtherIndex)}`, + ...sourceValue[i - 1] + ? { after: { codename: neighbourCodename } } + : { before: { codename: neighbourCodename } }, + }, + ], + ...getCreateUpdateOps()(v, targetOnOtherIndex) + .map(prefixOperationPath(`codename:${getCodename(v)}`)), + ]; + } + + const neighbourCodename = apply(getCodename, sourceValue[i - 1]) + || apply(getCodename, targetValue[0]) || null; + return [ + { + op: "addInto" as const, + path: "", + value: transformBeforeAdd(v), + ...neighbourCodename ? { [sourceValue[i - 1] ? "after" : "before"]: { codename: neighbourCodename } } : {}, + }, + ]; + }); + + const removeOps = targetValue + .filter(v => !sourceCodenamesSet.has(getCodename(v))) + .map(v => ({ op: "remove" as const, path: `/codename:${getCodename(v)}`, oldValue: v })); + + return [...addAndUpdateOps, ...removeOps]; +}; + +/** + * Creates a handler for a union of different types discriminated by a single property + * + * @param discriminatorKey - defines the property that identifies a particular type in the union (e.g. "type" for content type elements) + * + * @param elementHandlers - define handlers for all the union handlers indexed by the apropriate value of the discriminator property + */ +export const makeUnionHandler = < + DiscriminatorKey extends string, + Union extends { [key in DiscriminatorKey]: string }, +>( + discriminatorKey: DiscriminatorKey, + elementHandlers: { + readonly [Key in Union[DiscriminatorKey]]: Handler; + }, +): Handler => +(source, target) => + source[discriminatorKey] !== target[discriminatorKey] + ? [{ op: "replace", path: "", value: source, oldValue: target }] + : elementHandlers[source[discriminatorKey]](source, target); + +const prefixOperationPath = (prefix: string) => (op: PatchOperation): PatchOperation => ({ + ...op, + path: "/" + prefix + op.path, +}); + +/** + * Replace the whole value when only one is undefined, do nothing when both are undefined and call the inner handler when neither are undefined. + */ +export const optionalHandler = + (handler: Handler): Handler => (sourceVal, targetVal) => { + if (sourceVal === targetVal && sourceVal === undefined) { + return []; + } + if (sourceVal === undefined || targetVal === undefined) { + return [{ + op: "replace", + path: "", + value: sourceVal, + oldValue: targetVal, + }]; + } + + return handler(sourceVal, targetVal); + }; + +/** + * For basic values only. Replace when they are different. + */ +export const baseHandler: Handler = (sourceVal, targetVal) => + sourceVal === targetVal ? [] : [{ + op: "replace", + path: "", + value: sourceVal, + oldValue: targetVal, + }]; + +/** + * Never creates a patch operation. Use for values that can never change (e.g. property "type" in elements). + */ +export const constantHandler: Handler = () => []; diff --git a/src/modules/sync/diff/contentType.ts b/src/modules/sync/diff/contentType.ts new file mode 100644 index 00000000..39da2f23 --- /dev/null +++ b/src/modules/sync/diff/contentType.ts @@ -0,0 +1,87 @@ +import { ContentTypeSyncModel } from "../types/fileContentModel.js"; +import { SyncGuidelinesElement } from "../types/syncModel.js"; +import { + baseHandler, + Handler, + makeArrayHandler, + makeLeafObjectHandler, + makeObjectHandler, + makeUnionHandler, + optionalHandler, +} from "./combinators.js"; +import { + makeAssetElementHandler, + makeCustomElementHandler, + makeDateTimeElementHandler, + makeGuidelinesElementHandler, + makeLinkedItemsElementHandler, + makeMultiChoiceElementHandler, + makeNumberElementHandler, + makeRichTextElementHandler, + makeSnippetElementHandler, + makeSubpagesElementHandler, + makeTaxonomyElementHandler, + makeTextElementHandler, + makeUrlSlugElementHandler, +} from "./modelElements.js"; +import { transformGuidelinesElementToAddModel } from "./transformToAddModel.js"; + +type HandleContentTypeParams = Readonly<{ + targetItemsByCodenames: ReadonlyMap>; + targetAssetsByCodenames: ReadonlyMap>; +}>; + +export const makeContentTypeHandler = ( + params: HandleContentTypeParams, +): Handler => + makeObjectHandler({ + name: baseHandler, + content_groups: optionalHandler(makeArrayHandler( + g => g.codename, + makeObjectHandler({ + name: baseHandler, + }), + )), + elements: { + contextfulHandler: ({ source, target }) => { + const ctx = { + ...params, + targetAssetCodenames: new Set(params.targetAssetsByCodenames.keys()), + targetItemCodenames: new Set(params.targetItemsByCodenames.keys()), + sourceTypeOrSnippet: source, + targetTypeOrSnippet: target, + }; + + return makeArrayHandler( + el => el.codename, + makeUnionHandler("type", { + number: makeNumberElementHandler(ctx), + text: makeTextElementHandler(ctx), + asset: makeAssetElementHandler(ctx), + guidelines: makeGuidelinesElementHandler(ctx), + custom: makeCustomElementHandler(ctx), + snippet: makeSnippetElementHandler(ctx), + subpages: makeSubpagesElementHandler(ctx), + taxonomy: makeTaxonomyElementHandler(ctx), + url_slug: makeUrlSlugElementHandler(ctx), + date_time: makeDateTimeElementHandler(ctx), + rich_text: makeRichTextElementHandler(ctx), + modular_content: makeLinkedItemsElementHandler(ctx), + multiple_choice: makeMultiChoiceElementHandler(ctx), + }), + el => + el.type === "guidelines" + ? transformGuidelinesElementToAddModel({ + targetItemsReferencedFromSourceByCodenames: params.targetItemsByCodenames, + targetAssetsReferencedFromSourceByCodenames: params.targetAssetsByCodenames, + }, el) as SyncGuidelinesElement + : el, + ); + }, + }, + }); + +export const wholeContentTypesHandler: Handler> = makeArrayHandler( + t => t.codename, + makeLeafObjectHandler({ name: () => false }), // always replace types with the same codename as this handler only handles whole types not their parts +); diff --git a/src/modules/sync/diff/contentTypeSnippet.ts b/src/modules/sync/diff/contentTypeSnippet.ts new file mode 100644 index 00000000..7b820409 --- /dev/null +++ b/src/modules/sync/diff/contentTypeSnippet.ts @@ -0,0 +1,73 @@ +import { ContentTypeSnippetsSyncModel } from "../types/fileContentModel.js"; +import { SyncGuidelinesElement } from "../types/syncModel.js"; +import { + baseHandler, + Handler, + makeArrayHandler, + makeLeafObjectHandler, + makeObjectHandler, + makeUnionHandler, +} from "./combinators.js"; +import { + makeAssetElementHandler, + makeCustomElementHandler, + makeDateTimeElementHandler, + makeGuidelinesElementHandler, + makeLinkedItemsElementHandler, + makeMultiChoiceElementHandler, + makeNumberElementHandler, + makeRichTextElementHandler, + makeTaxonomyElementHandler, + makeTextElementHandler, +} from "./modelElements.js"; +import { transformGuidelinesElementToAddModel } from "./transformToAddModel.js"; + +type HandleContentTypeSnippetParams = Readonly<{ + targetItemsByCodenames: ReadonlyMap>; + targetAssetsByCodenames: ReadonlyMap>; +}>; +export const makeContentTypeSnippetHandler = ( + params: HandleContentTypeSnippetParams, +): Handler => + makeObjectHandler({ + name: baseHandler, + elements: { + contextfulHandler: ({ source, target }) => { + const ctx = { + ...params, + targetAssetCodenames: new Set(params.targetAssetsByCodenames.keys()), + targetItemCodenames: new Set(params.targetItemsByCodenames.keys()), + sourceTypeOrSnippet: source, + targetTypeOrSnippet: target, + }; + + return makeArrayHandler( + el => el.codename, + makeUnionHandler("type", { + number: makeNumberElementHandler(ctx), + text: makeTextElementHandler(ctx), + asset: makeAssetElementHandler(ctx), + guidelines: makeGuidelinesElementHandler(ctx), + custom: makeCustomElementHandler(ctx), + taxonomy: makeTaxonomyElementHandler(ctx), + date_time: makeDateTimeElementHandler(ctx), + rich_text: makeRichTextElementHandler(ctx), + modular_content: makeLinkedItemsElementHandler(ctx), + multiple_choice: makeMultiChoiceElementHandler(ctx), + }), + el => + el.type === "guidelines" + ? transformGuidelinesElementToAddModel({ + targetItemsReferencedFromSourceByCodenames: params.targetItemsByCodenames, + targetAssetsReferencedFromSourceByCodenames: params.targetAssetsByCodenames, + }, el) as SyncGuidelinesElement + : el, + ); + }, + }, + }); + +export const wholeContentTypeSnippetsHandler: Handler> = makeArrayHandler( + s => s.codename, + makeLeafObjectHandler({ name: () => false }), // always replace snippets with the same codename as this handler only handles whole snippets not their parts +); diff --git a/src/modules/sync/diff/guidelinesRichText.ts b/src/modules/sync/diff/guidelinesRichText.ts new file mode 100644 index 00000000..8fc3030d --- /dev/null +++ b/src/modules/sync/diff/guidelinesRichText.ts @@ -0,0 +1,104 @@ +import { + assetAttributeName, + assetExternalIdAttributeName, + itemExternalIdLinkAttributeName, + itemLinkAttributeName, +} from "../../../constants/richText.js"; +import { customAssetCodenameAttributeName, customItemLinkCodenameAttributeName } from "../../constants/syncRichText.js"; + +export type OriginalReference = Readonly< + { codename: string; externalId?: string } | { codename?: string; externalId: string } +>; + +type Replacement = string | Readonly<{ internalId: string }> | Readonly<{ externalId: string }>; + +type ReplaceReferencesParams = Readonly<{ + referencesRegex: RegExp; + internalIdAttributeName: string; + codenameAttributeName: string; + externalIdAttributeName: string; +}>; + +const createGetReferences = (regex: RegExp) => (guidelines: string): ReadonlyArray => + [...guidelines.matchAll(regex)] + .map( + match => ({ + codename: match.groups?.[codenameGroupName] ?? match.groups?.[codename2GroupName], + externalId: match.groups?.[externalIdGroupName] ?? match.groups?.[externalId2GroupName], + } as OriginalReference), + ); + +const createReplaceReferences = + (params: ReplaceReferencesParams) => (guidelines: string, replacer: (reference: OriginalReference) => Replacement) => + guidelines + .replaceAll(params.referencesRegex, (match, ...[, , , , , , groups]) => { // In the arguments we must first skip the groups (4), offset and the whole string, then there is the groups object. See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_function_as_the_replacement for more details + const codename = groups[codenameGroupName] ?? groups[codename2GroupName]; + const externalId = groups[externalIdGroupName] ?? groups[externalId2GroupName]; + + const result = replacer({ codename, externalId }); + + const newValue = typeof result === "string" + ? result + : "internalId" in result + ? `${params.internalIdAttributeName}="${result.internalId}"` + : `${params.externalIdAttributeName}="${result.externalId}"`; + + return match + .replaceAll(new RegExp(`${params.codenameAttributeName}="[^"]*"`, "gi"), externalId ? "" : newValue) // only put here the new value when there is not external id attribute, otherwise the new value will be put in place of the external id + .replaceAll(new RegExp(`${params.externalIdAttributeName}="[^"]*"`, "gi"), newValue); + }); + +const makeAttrLookAhead = (attributeName: string) => `(?!${attributeName.slice(1)}=)`; // Look ahead whether we are already parsing the attribute (we already are on the first letter) + +const codenameGroupName = "codename"; +const codename2GroupName = "codename2"; +const externalIdGroupName = "externalId"; +const externalId2GroupName = "externalId2"; + +const itemCodenameAttributeLookAhead = makeAttrLookAhead(customItemLinkCodenameAttributeName); +const itemExternalIdAttributeLookAhead = makeAttrLookAhead(itemExternalIdLinkAttributeName); + +const anythingButItemAttributes = `(?:[^>]${itemCodenameAttributeLookAhead}${itemExternalIdAttributeLookAhead})*`; + +const oneOfItemAttributes = (codenameGroup: string, externalIdGroup: string) => + `(?:(?:${customItemLinkCodenameAttributeName}="(?<${codenameGroup}>[^"]*)")|(?:${itemExternalIdLinkAttributeName}="(?<${externalIdGroup}>[^"]*)"))`; + +const itemReferenceRegex = new RegExp( + `<${anythingButItemAttributes}${ + oneOfItemAttributes(codenameGroupName, externalIdGroupName) + }${anythingButItemAttributes}${oneOfItemAttributes(codename2GroupName, externalId2GroupName)}?[^>]*>`, + "gi", +); + +const assetCodenameAttributeLookAhead = makeAttrLookAhead(customAssetCodenameAttributeName); +const assetExternalIdAttributeLookAhead = makeAttrLookAhead(assetExternalIdAttributeName); + +const anythingButAssetAttributes = `(?:[^>]${assetCodenameAttributeLookAhead}${assetExternalIdAttributeLookAhead})*`; + +const oneOfAssetAttributes = (codenameGroup: string, externalIdGroup: string) => + `(?:(?:${customAssetCodenameAttributeName}="(?<${codenameGroup}>[^"]*)")|(?:${assetExternalIdAttributeName}="(?<${externalIdGroup}>[^"]*)"))`; + +const assetReferenceRegex = new RegExp( + `<${anythingButAssetAttributes}${ + oneOfAssetAttributes(codenameGroupName, externalIdGroupName) + }${anythingButAssetAttributes}${oneOfAssetAttributes(codename2GroupName, externalId2GroupName)}?[^>]*>`, + "gi", +); + +export const replaceItemReferences = createReplaceReferences({ + referencesRegex: itemReferenceRegex, + internalIdAttributeName: itemLinkAttributeName, + codenameAttributeName: customItemLinkCodenameAttributeName, + externalIdAttributeName: itemExternalIdLinkAttributeName, +}); + +export const replaceAssetReferences = createReplaceReferences({ + referencesRegex: assetReferenceRegex, + internalIdAttributeName: assetAttributeName, + codenameAttributeName: customAssetCodenameAttributeName, + externalIdAttributeName: assetExternalIdAttributeName, +}); + +export const getItemReferences = createGetReferences(itemReferenceRegex); + +export const getAssetReferences = createGetReferences(assetReferenceRegex); diff --git a/src/modules/sync/diff/modelElements.ts b/src/modules/sync/diff/modelElements.ts new file mode 100644 index 00000000..7dcdd2a3 --- /dev/null +++ b/src/modules/sync/diff/modelElements.ts @@ -0,0 +1,314 @@ +import { zip } from "../../../utils/array.js"; +import { apply } from "../../../utils/function.js"; +import { CodenameReference } from "../../../utils/types.js"; +import { ContentTypeSnippetsSyncModel, ContentTypeSyncModel } from "../types/fileContentModel.js"; +import { + SyncAssetElement, + SyncCustomElement, + SyncDateTimeElement, + SyncGuidelinesElement, + SyncLinkedItemsElement, + SyncMultipleChoiceElement, + SyncNumberElement, + SyncRichTextElement, + SyncSubpagesElement, + SyncTaxonomyElement, + SyncTextElement, + SyncTypeElement, + SyncTypeSnippetElement, + SyncUrlSlugElement, +} from "../types/syncModel.js"; +import { + baseHandler, + constantHandler, + Handler, + makeArrayHandler, + makeLeafObjectHandler, + makeObjectHandler, + optionalHandler, +} from "./combinators.js"; +import { + getAssetReferences, + getItemReferences, + OriginalReference, + replaceAssetReferences, + replaceItemReferences, +} from "./guidelinesRichText.js"; + +type HandleAssetElementContext = + & CommonPropsHandlersContext + & Readonly<{ + targetAssetCodenames: ReadonlySet; + }>; + +export const makeAssetElementHandler = ( + { targetAssetCodenames, ...restCtx }: HandleAssetElementContext, +): Handler => + makeObjectHandler({ + ...makeCommonPropsHandlers(restCtx), + asset_count_limit: optionalHandler(makeLeafObjectHandler({})), + image_width_limit: optionalHandler(makeLeafObjectHandler({})), + maximum_file_size: optionalHandler(baseHandler), + allowed_file_types: optionalHandler(baseHandler), + image_height_limit: optionalHandler(makeLeafObjectHandler({})), + default: makeDefaultReferencesHandler(targetAssetCodenames), + }); + +export const makeCustomElementHandler = ( + ctx: CommonPropsHandlersContext, +): Handler => + makeObjectHandler({ + ...makeCommonPropsHandlers(ctx), + source_url: baseHandler, + json_parameters: optionalHandler(baseHandler), + allowed_elements: optionalHandler(makeArrayHandler( + ref => ref.codename, + () => [], + )), + }); + +export const makeMultiChoiceElementHandler = ( + ctx: CommonPropsHandlersContext, +): Handler => + makeObjectHandler({ + ...makeCommonPropsHandlers(ctx), + mode: baseHandler, + options: makeArrayHandler( + o => o.codename, + makeObjectHandler({ + name: baseHandler, + }), + ), + default: makeDefaultReferencesHandler(), + }); + +export const makeRichTextElementHandler = ( + ctx: CommonPropsHandlersContext, +): Handler => + makeObjectHandler({ + ...makeCommonPropsHandlers(ctx), + allowed_blocks: optionalHandler(makeArrayHandler(b => b, () => [])), + image_width_limit: optionalHandler(makeLeafObjectHandler({})), + allowed_formatting: optionalHandler(makeArrayHandler(f => f, () => [])), + image_height_limit: optionalHandler(makeLeafObjectHandler({})), + maximum_image_size: optionalHandler(baseHandler), + allowed_image_types: optionalHandler(baseHandler), + allowed_text_blocks: optionalHandler(makeArrayHandler(b => b, () => [])), + maximum_text_length: optionalHandler(makeLeafObjectHandler({})), + allowed_table_blocks: optionalHandler(makeArrayHandler(b => b, () => [])), + allowed_content_types: optionalHandler(makeArrayHandler(ref => ref.codename, () => [])), + allowed_item_link_types: optionalHandler(makeArrayHandler(ref => ref.codename, () => [])), + allowed_table_formatting: optionalHandler(makeArrayHandler(f => f, () => [])), + allowed_table_text_blocks: optionalHandler(makeArrayHandler(b => b, () => [])), + }); + +export const makeTaxonomyElementHandler = ( + ctx: CommonPropsHandlersContext, +): Handler => + makeObjectHandler({ + ...makeCommonPropsHandlers(ctx), + taxonomy_group: (source, target) => + !source.codename || source.codename !== target.codename + ? [{ op: "replace", path: "", value: source, oldValue: target }] + : [], + term_count_limit: optionalHandler(makeLeafObjectHandler({})), + default: makeDefaultReferencesHandler(), + }); + +type HandleLinkedItemsElementContext = + & CommonPropsHandlersContext + & Readonly<{ + targetItemCodenames: ReadonlySet; + }>; + +export const makeLinkedItemsElementHandler = ( + ctx: HandleLinkedItemsElementContext, +): Handler => + makeObjectHandler({ + ...makeCommonPropsHandlers(ctx), + allowed_content_types: optionalHandler(makeArrayHandler(ref => ref.codename, () => [])), + item_count_limit: optionalHandler(makeLeafObjectHandler({})), + default: makeDefaultReferencesHandler(ctx.targetItemCodenames), + }); + +export const makeSubpagesElementHandler = ( + ctx: HandleLinkedItemsElementContext, +): Handler => + makeObjectHandler({ + ...makeCommonPropsHandlers(ctx), + allowed_content_types: optionalHandler(makeArrayHandler(ref => ref.codename, () => [])), + item_count_limit: optionalHandler(makeLeafObjectHandler({})), + default: makeDefaultReferencesHandler(ctx.targetItemCodenames), + }); + +export const makeTextElementHandler = (ctx: CommonPropsHandlersContext): Handler => + makeObjectHandler({ + ...makeCommonPropsHandlers(ctx), + maximum_text_length: optionalHandler(makeLeafObjectHandler({})), + validation_regex: optionalHandler(makeLeafObjectHandler({})), + default: optionalHandler(makeLeafObjectHandler({ + global: simpleDefaultValueComparator, + })), + }); + +export const makeDateTimeElementHandler = ( + ctx: CommonPropsHandlersContext, +): Handler => + makeObjectHandler({ + ...makeCommonPropsHandlers(ctx), + default: optionalHandler(makeLeafObjectHandler({ + global: simpleDefaultValueComparator, + })), + }); + +export const makeNumberElementHandler = ( + ctx: CommonPropsHandlersContext, +): Handler => + makeObjectHandler({ + ...makeCommonPropsHandlers(ctx), + default: optionalHandler(makeLeafObjectHandler({ + global: simpleDefaultValueComparator, + })), + }); + +export const makeSnippetElementHandler = ( + ctx: CommonPropsHandlersContext, +): Handler => + makeObjectHandler({ + type: constantHandler, + content_group: makeContentGroupHandler(ctx), + snippet: makeLeafObjectHandler({}), + }); + +export const makeUrlSlugElementHandler = ( + ctx: CommonPropsHandlersContext, +): Handler => + makeObjectHandler({ + ...makeCommonPropsHandlers(ctx), + validation_regex: optionalHandler(makeLeafObjectHandler({})), + depends_on: makeLeafObjectHandler({ + snippet: (source, target) => source?.codename === target?.codename, + element: (source, target) => source.codename === target.codename, + }), + }); + +type HandleGuidelinesElementContext = + & CommonPropsHandlersContext + & Readonly<{ + targetItemsByCodenames: ReadonlyMap>; + targetAssetsByCodenames: ReadonlyMap>; + }>; + +export const makeGuidelinesElementHandler = ( + ctx: HandleGuidelinesElementContext, +): Handler => + makeObjectHandler({ + type: constantHandler, + content_group: makeContentGroupHandler(ctx), + guidelines: (source, target) => { + const sourceRefs = [...getItemReferences(source), ...getAssetReferences(source)]; + const targetRefs = [...getItemReferences(target), ...getAssetReferences(target)]; + + // This makes the comparison pass for the most common case of one vs two attributes separated by one space + const replaceRef = (ref: OriginalReference) => ref.codename && ref.externalId ? "" : " "; + const sourceWithoutRefs = replaceItemReferences( + replaceAssetReferences(source, replaceRef), + replaceRef, + ); + const targetWithoutRefs = replaceItemReferences( + replaceAssetReferences(target, replaceRef), + replaceRef, + ); + + const areRefsSame = sourceRefs.length === targetRefs.length && zip(sourceRefs, targetRefs).every(([s, t]) => + (s.codename && s.codename === t.codename) || (s.externalId && s.externalId === t.externalId) + ); + + if (areRefsSame && sourceWithoutRefs === targetWithoutRefs) { + return []; + } + + const guidelinesWithTransformedAssetReferences = replaceAssetReferences(source, ref => { + const targetAsset = ctx.targetAssetsByCodenames.get(ref.codename ?? ""); + + return targetAsset ? { internalId: targetAsset.id } : { externalId: ref.externalId ?? "" }; + }); + const sourceToUse = replaceItemReferences( + guidelinesWithTransformedAssetReferences, + ref => { + const targetItem = ctx.targetItemsByCodenames.get(ref.codename ?? ""); + + return targetItem ? { internalId: targetItem.id } : { externalId: ref.externalId ?? "" }; + }, + ); + + return [{ op: "replace", path: "", value: sourceToUse, oldValue: target }]; + }, + }); + +type CommonPropsHandlersContext = Readonly<{ + sourceTypeOrSnippet: ContentTypeSnippetsSyncModel | ContentTypeSyncModel; + targetTypeOrSnippet: ContentTypeSnippetsSyncModel | ContentTypeSyncModel; +}>; + +type CommonPropsHandlers = Readonly<{ + name: Handler; + type: Handler; + guidelines: Handler; + is_required: Handler; + content_group: Handler; + is_non_localizable: Handler; +}>; + +const makeCommonPropsHandlers = (ctx: CommonPropsHandlersContext): CommonPropsHandlers => ({ + name: baseHandler, + type: constantHandler, + guidelines: optionalHandler(baseHandler), + is_required: optionalHandler(baseHandler), + content_group: makeContentGroupHandler(ctx), + is_non_localizable: optionalHandler(baseHandler), +}); + +const makeContentGroupHandler = ( + { sourceTypeOrSnippet }: CommonPropsHandlersContext, +): Handler => +(source, target) => { + if (!("content_groups" in sourceTypeOrSnippet)) { + return []; // elements in snippets can never have content_group defined + } + const defaultGroup = apply(g => ({ codename: g.codename }), sourceTypeOrSnippet.content_groups?.[0]) ?? undefined; + + return source?.codename === target?.codename + ? [] + : [{ + op: "replace", + path: "", + value: sourceTypeOrSnippet.content_groups?.some(g => g.codename === source?.codename) ? source : defaultGroup, + oldValue: target, + }]; +}; + +type DefaultValue = { readonly global: { readonly value: Value } }; + +type ReferencesDefault = DefaultValue>>; + +const makeDefaultReferencesHandler = ( + targetCodenames?: { has: (codename: string) => boolean }, +): Handler => + optionalHandler(makeLeafObjectHandler( + { + global: ({ value: source }, { value: target }) => + source.length === target.length && zip(source, target).every(([s, t]) => s.codename === t.codename), + }, + apply(tC => source => ({ + global: { + value: source.global.value + .map(ref => tC.has(ref.codename) ? ref : { external_id: ref.external_id }), + }, + }), targetCodenames) ?? undefined, + )); + +const simpleDefaultValueComparator = ( + source: DefaultValue["global"], + target: DefaultValue["global"], +) => source.value === target.value; diff --git a/src/modules/sync/diff/taxonomy.ts b/src/modules/sync/diff/taxonomy.ts new file mode 100644 index 00000000..421c5111 --- /dev/null +++ b/src/modules/sync/diff/taxonomy.ts @@ -0,0 +1,16 @@ +import { TaxonomySyncModel } from "../types/fileContentModel.js"; +import { baseHandler, Handler, makeArrayHandler, makeLeafObjectHandler, makeObjectHandler } from "./combinators.js"; + +export const makeTaxonomyGroupHandler = (): Handler => + makeObjectHandler({ + name: baseHandler, + terms: makeArrayHandler( + t => t.codename, + { lazyHandler: makeTaxonomyGroupHandler }, + ), + }); + +export const wholeTaxonomyGroupsHandler: Handler> = makeArrayHandler( + g => g.codename, + makeLeafObjectHandler({ name: () => false }), // always replace taxonomy groups with the same codename as this handler only handles whole taxonomy groups not their parts +); diff --git a/src/modules/sync/diff/transformToAddModel.ts b/src/modules/sync/diff/transformToAddModel.ts new file mode 100644 index 00000000..38e36254 --- /dev/null +++ b/src/modules/sync/diff/transformToAddModel.ts @@ -0,0 +1,112 @@ +import { + ContentTypeElements, + ContentTypeModels, + ContentTypeSnippetModels, + TaxonomyModels, +} from "@kontent-ai/management-sdk"; + +import { RequiredCodename } from "../../../utils/types.js"; +import { ContentTypeSnippetsSyncModel, ContentTypeSyncModel, TaxonomySyncModel } from "../types/fileContentModel.js"; +import { SyncGuidelinesElement } from "../types/syncModel.js"; +import { replaceAssetReferences, replaceItemReferences } from "./guidelinesRichText.js"; + +export const transformTaxonomyToAddModel = ( + taxonomy: TaxonomySyncModel, +): RequiredCodename => ({ + ...taxonomy, + terms: taxonomy.terms.map(transformTaxonomyToAddModel), +}); + +export const transformTypeToAddModel = + (params: TransformElementParams) => + (type: ContentTypeSyncModel): RequiredCodename => ({ + ...type, + content_groups: type.content_groups as MakeArrayMutable, + elements: type.elements.map(transformElementToAddModel(params)) as ContentTypeElements.Element[], + }); + +export const transformSnippetToAddModel = + (params: TransformElementParams) => + (snippet: ContentTypeSnippetsSyncModel): RequiredCodename => ({ + ...snippet, + elements: snippet.elements.map(transformElementToAddModel(params)) as ContentTypeElements.Element[], + }); + +type TransformElementParams = Readonly<{ + targetItemsReferencedFromSourceByCodenames: ReadonlyMap>; + targetAssetsReferencedFromSourceByCodenames: ReadonlyMap>; +}>; + +const transformElementToAddModel = (params: TransformElementParams) => +( + el: ContentTypeSyncModel["elements"][number], +): ContentTypeElements.Element | { type: "subpages"; default?: ContentTypeElements.ILinkedItemsElement["default"] } => { // to fix SDK types bug + switch (el.type) { + case "number": + case "snippet": + case "url_slug": + case "date_time": + case "text": + return el; + case "asset": + return { + ...el, + // the casting is needed to fix match the SDK mutable arrays, we can remove it once we change the SDK types + default: el.default ? { global: el.default.global as MakeArraysMutable } : undefined, + }; + case "custom": + return el as MakeArraysMutable; + case "subpages": + case "modular_content": + return { + ...el, + default: el.default ? { global: el.default.global as MakeArraysMutable } : undefined, + allowed_content_types: el.allowed_content_types as MakeArrayMutable, + }; + case "taxonomy": + return { + ...el, + default: el.default ? { global: el.default.global as MakeArraysMutable } : undefined, + }; + case "rich_text": + return el as MakeArraysMutable; + case "multiple_choice": + return { + ...el, + options: el.options as MakeArrayMutable, + default: el.default ? { global: el.default.global as MakeArraysMutable } : undefined, + }; + case "guidelines": + return transformGuidelinesElementToAddModel(params, el); + } +}; + +export const transformGuidelinesElementToAddModel = ( + params: TransformElementParams, + element: SyncGuidelinesElement, +): ContentTypeElements.IGuidelinesElement => { + const guidelinesWithTransformedAssetReferences = replaceAssetReferences(element.guidelines, ref => { + const targetAsset = params.targetAssetsReferencedFromSourceByCodenames.get(ref.codename ?? ""); + + return targetAsset ? { internalId: targetAsset.id } : { externalId: ref.externalId ?? "" }; + }); + return ({ + ...element, + guidelines: replaceItemReferences( + guidelinesWithTransformedAssetReferences, + ref => { + const targetItem = params.targetItemsReferencedFromSourceByCodenames.get(ref.codename ?? ""); + + return targetItem ? { internalId: targetItem.id } : { externalId: ref.externalId ?? "" }; + }, + ), + }); +}; + +type MakeArraysMutable = { + [Key in keyof T]: T[Key] extends ReadonlyArray | undefined ? MakeArrayMutable + : T[Key]; +}; + +type MakeArrayMutable | undefined> = T extends ReadonlyArray ? Elem[] + : T; diff --git a/src/modules/sync/modelTransfomers/elementTransformers.ts b/src/modules/sync/modelTransfomers/elementTransformers.ts index 9e924878..010b5915 100644 --- a/src/modules/sync/modelTransfomers/elementTransformers.ts +++ b/src/modules/sync/modelTransfomers/elementTransformers.ts @@ -84,7 +84,9 @@ const replaceRichTextReferences = ( return `${itemExternalIdAttributeName}="${oldItemId}"`; } - return `${customItemLinkCodenameAttributeName}="${item.codename}" ${itemExternalIdLinkAttributeName}="${item.id}"`; + return `${customItemLinkCodenameAttributeName}="${item.codename}" ${itemExternalIdLinkAttributeName}="${ + item.external_id ?? item.id + }"`; }); export const transformCustomElement = ( diff --git a/src/modules/sync/types/diffModel.ts b/src/modules/sync/types/diffModel.ts index 2131d1f1..c5bf2c26 100644 --- a/src/modules/sync/types/diffModel.ts +++ b/src/modules/sync/types/diffModel.ts @@ -4,23 +4,41 @@ import { RequiredCodename } from "../../../utils/types.js"; type Codename = string; -type DiffObject = Readonly<{ - added: ReadonlyArray; - updated: ReadonlyMap>; - deleted: ReadonlyArray; +type DiffObject = Readonly<{ + added: ReadonlyArray; + updated: ReadonlyMap>; + deleted: ReadonlySet; }>; export type DiffModel = Readonly<{ - taxonomyGroups: DiffObject< - RequiredCodename, - TaxonomyModels.IModifyTaxonomyData - >; - contentTypeSnippets: DiffObject< - RequiredCodename, - ContentTypeSnippetModels.IModifyContentTypeSnippetData - >; - contentTypes: DiffObject< - RequiredCodename, - ContentTypeModels.IModifyContentTypeData - >; + taxonomyGroups: DiffObject>; + contentTypeSnippets: DiffObject>; + contentTypes: DiffObject>; }>; + +export type PatchOperation = + | Readonly<{ + op: "addInto"; + path: string; + value: unknown; + before?: { codename: string }; + after?: { codename: string }; + }> + | Readonly<{ + op: "remove"; + path: string; + oldValue: unknown; + }> + | Readonly<{ + op: "replace"; + path: string; + value: unknown; + oldValue: unknown; + }> + | ( + & Readonly<{ + op: "move"; + path: string; + }> + & ({ readonly before: { readonly codename: string } } | { readonly after: { readonly codename: string } }) + ); diff --git a/src/modules/sync/types/syncModel.ts b/src/modules/sync/types/syncModel.ts index 67476760..d104daf2 100644 --- a/src/modules/sync/types/syncModel.ts +++ b/src/modules/sync/types/syncModel.ts @@ -48,7 +48,9 @@ export type SyncDateTimeElement = ReplaceReferences; export type SyncTypeSnippetElement = ReplaceReferences; export type SyncUrlSlugElement = ReplaceReferences; -export type SyncSubpagesElement = ReplaceReferences; +export type SyncSubpagesElement = + & ReplaceReferences + & Pick; // The property is missing in the SDK type type SyncSnippetCustomElement = SnippetElement; type SyncSnippetMultipleChoiceElement = SnippetElement; diff --git a/src/modules/sync/utils/patchOperations.ts b/src/modules/sync/utils/patchOperations.ts deleted file mode 100644 index 736be3ca..00000000 --- a/src/modules/sync/utils/patchOperations.ts +++ /dev/null @@ -1,38 +0,0 @@ -// TODO: This file is just a suggestion. Feel free to come up with your idea :) -// The definitions are here for the suggestion of the skeleton of the idea and might not be correct. - -import { ContentTypeModels, ContentTypeSnippetModels, TaxonomyModels } from "@kontent-ai/management-sdk"; - -type PatchOperationType = - | ContentTypeModels.IModifyContentTypeData - | ContentTypeSnippetModels.IModifyContentTypeSnippetData - | TaxonomyModels.IModifyTaxonomyData; - -type ComposableHandler = >>( - key: string, - innerHandlers: { readonly [k in keyof T]: Handler }, -) => Handler; - -type Handler = (value: T) => PatchOperationType | null; - -/** - * This function might create a one big handler function for nested objects - * consisting of multiple smaller handler function handling individual attributes - */ -const composeHandlers: ComposableHandler = () => { - return () => { - return null; - }; -}; - -composeHandlers as never; - -export const handleName = (name: string) => ({ - op: "replace", - path: "/name", - value: name, -}); - -export const contentTypePropertyHandlers = { - name: handleName, -}; diff --git a/src/utils/function.ts b/src/utils/function.ts new file mode 100644 index 00000000..eee30753 --- /dev/null +++ b/src/utils/function.ts @@ -0,0 +1,6 @@ +import { notNullOrUndefined } from "./typeguards.js"; + +export const apply = ( + fnc: (v: Input) => Output, + value: Input | null | undefined, +): Output | null | undefined => notNullOrUndefined(value) ? fnc(value) : value; diff --git a/src/utils/object.ts b/src/utils/object.ts index 5c5f838c..37486c15 100644 --- a/src/utils/object.ts +++ b/src/utils/object.ts @@ -18,3 +18,7 @@ export const removeNulls = (value: unknown): unknown => { return value; }; +type ObjectPerKey = Key extends any ? { [k in Key]: Value } : never; + +export const makeObjectWithKey = (key: Key, value: Value): ObjectPerKey => + ({ [key]: value }) as ObjectPerKey; diff --git a/src/utils/types.ts b/src/utils/types.ts index 72472370..f08fd7df 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -23,9 +23,10 @@ export type ReplaceReferences = Replace; export type RequiredCodename = Replace; -export type Replace = - & Omit - & Readonly; +export type Replace = T extends any ? + & Omit + & Readonly + : never; /** * Original Pick type extended to work on Union type objects diff --git a/tests/integration/importExport/utils/compare.ts b/tests/integration/importExport/utils/compare.ts index 6155a961..7c589a23 100644 --- a/tests/integration/importExport/utils/compare.ts +++ b/tests/integration/importExport/utils/compare.ts @@ -251,14 +251,12 @@ element => { }; switch (baseElement.type) { case "asset": { - const typedElement = baseElement as ContentTypeElements.IAssetElement; - const result: ContentTypeElements.IAssetElement = { - ...typedElement, - default: typedElement.default + ...baseElement, + default: baseElement.default ? { global: { - value: typedElement.default.global.value.map(ref => ({ + value: baseElement.default.global.value.map(ref => ({ id: data.assets.find(a => a.id === ref.id)?.codename ?? "non-existing-asset", })), }, @@ -268,11 +266,9 @@ element => { return result; } case "custom": { - const typedElement = baseElement as ContentTypeElements.ICustomElement; - const result: ContentTypeElements.ICustomElement = { - ...typedElement, - allowed_elements: typedElement.allowed_elements + ...baseElement, + allowed_elements: baseElement.allowed_elements ?.map(ref => ({ id: otherElements.find(el => el.id === ref.id)?.codename ?? "non-existing-element" })), }; @@ -283,12 +279,10 @@ element => { case "text": return baseElement; case "guidelines": { - const typedElement = baseElement as unknown as ContentTypeElements.IGuidelinesElement; // incorrect SDK types - const result: ContentTypeElements.IGuidelinesElement = { - ...typedElement, + ...baseElement, guidelines: replaceRichTextReferences({ - richText: typedElement.guidelines, + richText: baseElement.guidelines, replaceItemId: (itemId, asInternalId, asExternalId) => { const item = data.items.find(i => i.id === itemId); return item @@ -316,36 +310,32 @@ element => { return result as unknown as ElementContracts.IContentTypeElementContract; // incorrect SDK types } case "modular_content": { - const typedElement = baseElement as ContentTypeElements.ILinkedItemsElement; - const result: ContentTypeElements.ILinkedItemsElement = { - ...typedElement, - default: typedElement.default + ...baseElement, + default: baseElement.default ? { global: { - value: typedElement.default.global.value.map(ref => ({ + value: baseElement.default.global.value.map(ref => ({ id: data.items.find(i => i.id === ref.id)?.codename ?? "non-existing-item", })), }, } : undefined, - allowed_content_types: typedElement.allowed_content_types + allowed_content_types: baseElement.allowed_content_types ?.map(ref => ({ id: data.types.find(t => t.id === ref.id)?.codename ?? "non-existing-type" })), }; return result; } case "multiple_choice": { - const typedElement = baseElement as ContentTypeElements.IMultipleChoiceElement; - const result: ContentTypeElements.IMultipleChoiceElement = { - ...typedElement, - options: typedElement.options.map(o => ({ ...o, id: o.codename ?? "non-existing-option", external_id: "-" })), - default: typedElement.default + ...baseElement, + options: baseElement.options.map(o => ({ ...o, id: o.codename ?? "non-existing-option", external_id: "-" })), + default: baseElement.default ? { global: { - value: typedElement.default.global.value.map(ref => ({ - id: typedElement.options.find(o => o.id === ref.id)?.codename ?? "non-existing-option", + value: baseElement.default.global.value.map(ref => ({ + id: baseElement.options.find(o => o.id === ref.id)?.codename ?? "non-existing-option", })), }, } @@ -355,24 +345,20 @@ element => { return result; } case "rich_text": { - const typedElement = baseElement as ContentTypeElements.IRichTextElement; - const result: ContentTypeElements.IRichTextElement = { - ...typedElement, - allowed_content_types: typedElement.allowed_content_types + ...baseElement, + allowed_content_types: baseElement.allowed_content_types ?.map(ref => ({ id: data.types.find(t => t.id === ref.id)?.codename ?? "non-existing-type" })), - allowed_item_link_types: typedElement.allowed_item_link_types + allowed_item_link_types: baseElement.allowed_item_link_types ?.map(ref => ({ id: data.types.find(t => t.id === ref.id)?.codename ?? "non-existing-type" })), }; return result; } case "snippet": { - const typedElement = baseElement as unknown as ContentTypeElements.ISnippetElement; - const result: ContentTypeElements.ISnippetElement = { - ...typedElement, - snippet: { id: data.snippets.find(s => s.id === typedElement.snippet.id)?.codename ?? "non-existing-snippet" }, + ...baseElement, + snippet: { id: data.snippets.find(s => s.id === baseElement.snippet.id)?.codename ?? "non-existing-snippet" }, }; return result as unknown as ElementContracts.IContentTypeElementContract; @@ -400,19 +386,17 @@ element => { return result; } case "taxonomy": { - const typedElement = baseElement as ContentTypeElements.ITaxonomyElement; - const result: ContentTypeElements.ITaxonomyElement = { - ...typedElement, + ...baseElement, taxonomy_group: { - id: data.taxonomies.find(g => g.id === typedElement.taxonomy_group.id)?.codename ?? "non-existing-group", + id: data.taxonomies.find(g => g.id === baseElement.taxonomy_group.id)?.codename ?? "non-existing-group", }, - default: typedElement.default + default: baseElement.default ? { global: { - value: typedElement.default.global.value.map(ref => ({ + value: baseElement.default.global.value.map(ref => ({ id: - getAllTerms(data.taxonomies.find(g => g.id === typedElement.taxonomy_group.id)).find(t => + getAllTerms(data.taxonomies.find(g => g.id === baseElement.taxonomy_group.id)).find(t => t.id === ref.id )?.codename ?? "non-existing-term", })), @@ -424,23 +408,21 @@ element => { return result as ElementContracts.IContentTypeElementContract; } case "url_slug": { - const typedElement = baseElement as ContentTypeElements.IUrlSlugElement; - const result: ContentTypeElements.IUrlSlugElement = { - ...typedElement, + ...baseElement, depends_on: { element: { - id: typedElement.depends_on.snippet + id: baseElement.depends_on.snippet ? data.snippets - .find(s => s.id === typedElement.depends_on.snippet?.id) - ?.elements.find(el => el.id === typedElement.depends_on.element.id)?.codename ?? "non-existing-element" + .find(s => s.id === baseElement.depends_on.snippet?.id) + ?.elements.find(el => el.id === baseElement.depends_on.element.id)?.codename ?? "non-existing-element" : otherElements - .find(el => el.id === typedElement.depends_on.element.id)?.codename ?? "non-existing-element", + .find(el => el.id === baseElement.depends_on.element.id)?.codename ?? "non-existing-element", }, - snippet: typedElement.depends_on.snippet + snippet: baseElement.depends_on.snippet ? { id: data.snippets - .find(s => s.id === typedElement.depends_on.snippet?.id)?.codename ?? "non-existing-snippet", + .find(s => s.id === baseElement.depends_on.snippet?.id)?.codename ?? "non-existing-snippet", } : undefined, }, diff --git a/tests/unit/sync/diff/combinators.test.ts b/tests/unit/sync/diff/combinators.test.ts new file mode 100644 index 00000000..b29b3bbe --- /dev/null +++ b/tests/unit/sync/diff/combinators.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it } from "@jest/globals"; + +import { + baseHandler, + constantHandler, + Handler, + makeArrayHandler, + makeLeafObjectHandler, + makeObjectHandler, + makeUnionHandler, + optionalHandler, +} from "../../../../src/modules/sync/diff/combinators"; + +describe("makeObjectHandler", () => { + it("concatenates results of all property handlers and prepends property names to paths", () => { + type TestedType = Readonly<{ a: number; b: boolean; c: string }>; + const source: TestedType = { + a: 69, + b: true, + c: "abc", + }; + const target: TestedType = { + a: 33, + b: false, + c: "hm", + }; + + const result = makeObjectHandler({ + a: (s, t) => [{ op: "replace", path: "/test", value: s, oldValue: t }], + b: (s) => [{ op: "addInto", path: "/test/a", value: s }], + c: (s, t) => [{ op: "remove", path: `/test/${s}/${t}`, oldValue: t }], + })(source, target); + + expect(result).toStrictEqual([ + { op: "replace", path: "/a/test", value: 69, oldValue: 33 }, + { op: "addInto", path: "/b/test/a", value: true }, + { op: "remove", path: "/c/test/abc/hm", oldValue: "hm" }, + ]); + }); + + it("provides context of the whole object to contextful handlers", () => { + type TestedType = Readonly<{ a: string; b: number }>; + const source: TestedType = { + a: "abc", + b: 69, + }; + const target: TestedType = { + a: "xyz", + b: 11, + }; + + const result = makeObjectHandler({ + a: { + contextfulHandler: + ({ source, target }) => (s, t) => [{ op: "replace", path: `/${s}/${t}`, value: source, oldValue: target }], + }, + b: () => [], + })(source, target); + + expect(result).toStrictEqual([ + { + op: "replace", + path: "/a/abc/xyz", + value: { a: "abc", b: 69 }, + oldValue: { a: "xyz", b: 11 }, + }, + ]); + }); +}); + +describe("makeLeafObjectHandler", () => { + it("Replaces the whole object when one of the properties changed", () => { + type TestedType = Readonly<{ a: string; b: number }>; + const source: TestedType = { + a: "abc", + b: 42, + }; + const target: TestedType = { + a: "ccc", + b: 42, + }; + + const result = makeLeafObjectHandler({})(source, target); + + expect(result).toStrictEqual([ + { op: "replace", path: "", value: source, oldValue: target }, + ]); + }); + + it("Leverages provided comparers to compare properties", () => { + type TestedType = Readonly<{ a: string; b: number }>; + const source: TestedType = { + a: "abc", + b: 42, + }; + const target: TestedType = source; + + const result = makeLeafObjectHandler({ + a: () => false, + })(source, target); + + expect(result).toStrictEqual([ + { op: "replace", path: "", value: source, oldValue: target }, + ]); + }); + + it("Doesn't create any operations for the same objects", () => { + type TestedType = Readonly<{ a: number }>; + const source: TestedType = { a: 42 }; + const target = source; + + const result = makeLeafObjectHandler({})(source, target); + + expect(result).toStrictEqual([]); + }); + + it("Applies provided transform function on source before passing it to the resulting operation", () => { + type TestedType = Readonly<{ a: string }>; + const source: TestedType = { a: "aaa" }; + const target: TestedType = { a: "bbb" }; + + const result = makeLeafObjectHandler({}, s => ({ ...s, b: 42 }))(source, target); + + expect(result).toStrictEqual([ + { op: "replace", path: "", value: { a: "aaa", b: 42 }, oldValue: target }, + ]); + }); +}); + +describe("makeArrayHandler", () => { + it("Creates move operations to sort target based on source elements' codenames", () => { + type TestedElement = Readonly<{ a: string }>; + const source: ReadonlyArray = [{ a: "first" }, { a: "second" }, { a: "third" }]; + const target: ReadonlyArray = [{ a: "third" }, { a: "first" }, { a: "second" }]; + + const result = makeArrayHandler(el => el.a, () => [])(source, target); + + expect(result).toStrictEqual([ + { + op: "move", + path: "/codename:first", + before: { codename: "third" }, + }, + { + op: "move", + path: "/codename:second", + after: { codename: "first" }, + }, + { + op: "move", + path: "/codename:third", + after: { codename: "second" }, + }, + ]); + }); + + it("Creates update operations along with move operations when needed", () => { + type TestedElement = Readonly<{ a: string; b: number }>; + const source: ReadonlyArray = [{ a: "first", b: 42 }, { a: "second", b: 69 }]; + const target: ReadonlyArray = [{ a: "second", b: 32 }, { a: "first", b: 42 }]; + + const result = makeArrayHandler( + el => el.a, + (s, t) => s.b !== t.b ? [{ op: "replace", path: "/test", value: s, oldValue: t }] : [], + )(source, target); + + expect(result).toStrictEqual([ + { + op: "move", + path: "/codename:first", + before: { codename: "second" }, + }, + { + op: "move", + path: "/codename:second", + after: { codename: "first" }, + }, + { + op: "replace", + path: "/codename:second/test", + value: { a: "second", b: 69 }, + oldValue: { a: "second", b: 32 }, + }, + ]); + }); + + it("Creates remove operations for removed elements", () => { + type TestedElement = Readonly<{ a: string }>; + const source: ReadonlyArray = [{ a: "first" }, { a: "second" }]; + const target: ReadonlyArray = [{ a: "second" }, { a: "toDelete" }, { a: "first" }]; + + const result = makeArrayHandler( + el => el.a, + () => [], + )(source, target); + + expect(result).toStrictEqual([ + { + op: "move", + path: "/codename:first", + before: { codename: "second" }, + }, + { + op: "move", + path: "/codename:second", + after: { codename: "first" }, + }, + { + op: "remove", + path: "/codename:toDelete", + oldValue: { a: "toDelete" }, + }, + ]); + }); + + it("Creates add operations with proper position argument", () => { + type TestedElement = Readonly<{ a: string }>; + const source: ReadonlyArray = [ + { a: "newFirst" }, + { a: "first" }, + { a: "newSecond" }, + { a: "second" }, + ]; + const target: ReadonlyArray = [{ a: "first" }, { a: "second" }]; + + const result = makeArrayHandler( + el => el.a, + () => [], + )(source, target); + + expect(result).toStrictEqual([ + { + op: "addInto", + path: "", + value: { a: "newFirst" }, + before: { codename: "first" }, + }, + { + op: "move", + path: "/codename:first", + after: { codename: "newFirst" }, + }, + { + op: "addInto", + path: "", + value: { a: "newSecond" }, + after: { codename: "first" }, + }, + { + op: "move", + path: "/codename:second", + after: { codename: "newSecond" }, + }, + ]); + }); + + it("Transforms values for addInto operations when the transformer is provided", () => { + type TestedElement = Readonly<{ a: string; b: number }>; + const source: ReadonlyArray = [{ a: "1", b: 1 }, { a: "2", b: 2 }]; + const target: ReadonlyArray = []; + + const result = makeArrayHandler(el => el.a, () => [], el => ({ ...el, b: el.b * 2 }))( + source, + target, + ); + + expect(result).toStrictEqual([ + { + op: "addInto", + path: "", + value: { a: "1", b: 2 }, + }, + { + op: "addInto", + path: "", + value: { a: "2", b: 4 }, + after: { codename: "1" }, + }, + ]); + }); +}); + +describe("optionalHandler", () => { + const timesTwoHandler: Handler = (s, t) => + s === t ? [] : [{ op: "replace", path: "", value: s * 2, oldValue: t * 2 }]; + + it("Returns empty array when source and target are undefined", () => { + type TestedType = number | undefined; + const source: TestedType = undefined; + const target: TestedType = undefined; + + const result = optionalHandler(timesTwoHandler)(source, target); + + expect(result).toStrictEqual([]); + }); + + it("Replaces target when only source is undefined", () => { + type TestedType = number | undefined; + const source: TestedType = undefined; + const target: TestedType = 42; + + const result = optionalHandler(timesTwoHandler)(source, target); + + expect(result).toStrictEqual([{ op: "replace", path: "", value: undefined, oldValue: 42 }]); + }); + + it("Replaces target when only target is undefined", () => { + type TestedType = number | undefined; + const source: TestedType = 42; + const target: TestedType = undefined; + + const result = optionalHandler(timesTwoHandler)(source, target); + + expect(result).toStrictEqual([{ op: "replace", path: "", value: 42, oldValue: undefined }]); + }); + + it("Returns inner handler result when both are defined", () => { + type TestedType = number | undefined; + const source: TestedType = 42; + const target: TestedType = 86; + + const result = optionalHandler(timesTwoHandler)(source, target); + + expect(result).toStrictEqual([{ op: "replace", path: "", value: 42 * 2, oldValue: 86 * 2 }]); + }); +}); + +describe("baseHandler", () => { + it("Replaces target when source and target are different", () => { + const source: number = 42; + const target: number = 98; + + const result = baseHandler(source, target); + + expect(result).toStrictEqual([{ op: "replace", path: "", value: 42, oldValue: 98 }]); + }); + + it("Returns an empty array when source and target are the same", () => { + const result = baseHandler(42, 42); + + expect(result).toStrictEqual([]); + }); +}); + +describe("constantHandler", () => { + it("Returns an empty array for any values", () => { + const entries: [unknown, unknown][] = [[4, 4], [4, 2], ["r", "g"], [3, "j"], [{ a: 8 }, { b: "f" }], [[], [9]]]; + + const result = entries.flatMap(([s, t]) => constantHandler(s, t)); + + expect(result).toStrictEqual([]); + }); +}); + +describe("makeUnionHandler", () => { + it("Replaces the whole object when the discriminator is different in source and target", () => { + type TestedType = { a: "num"; b: number } | { a: "str"; b: string }; + const source: TestedType = { a: "num", b: 42 }; + const target: TestedType = { a: "str", b: "42" }; + + const result = makeUnionHandler<"a", TestedType>("a", { num: () => [], str: () => [] })( + source, + target, + ); + + expect(result).toStrictEqual([ + { + op: "replace", + path: "", + value: source, + oldValue: target, + }, + ]); + }); + + it("Uses handler based on discriminator", () => { + type TestedType = { a: "num"; b: number } | { a: "str"; b: string }; + const source: TestedType = { a: "num", b: 42 }; + const target: TestedType = { a: "num", b: 88 }; + + const result = makeUnionHandler<"a", TestedType>("a", { + num: () => [{ op: "remove", path: "here", oldValue: "test" }], + str: () => [{ op: "addInto", path: "notHere", value: "notThis" }], + })( + source, + target, + ); + + expect(result).toStrictEqual([ + { + op: "remove", + path: "here", + oldValue: "test", + }, + ]); + }); +}); diff --git a/tests/unit/sync/diff/contentType.test.ts b/tests/unit/sync/diff/contentType.test.ts new file mode 100644 index 00000000..ae8d5532 --- /dev/null +++ b/tests/unit/sync/diff/contentType.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "@jest/globals"; + +import { makeContentTypeHandler } from "../../../../src/modules/sync/diff/contentType"; +import { ContentTypeSyncModel } from "../../../../src/modules/sync/types/fileContentModel"; +import { removeSpaces } from "./utils"; + +describe("makeContentTypeHandler", () => { + it("creates operations for all changed properties", () => { + const source: ContentTypeSyncModel = { + name: "new name", + codename: "type", + content_groups: [ + { + name: "group 1", + codename: "group1", + }, + { + name: "group 2 with new name", + codename: "group2", + }, + { + name: "group to be added", + codename: "groupToBeAdded", + }, + { + name: "group 3", + codename: "group3", + }, + ], + elements: [ + { + type: "number", + codename: "element_1", + name: "number", + }, + { + type: "text", + codename: "element_2", + name: "text", + }, + { + type: "date_time", + codename: "element_3", + name: "date", + }, + ], + }; + const target: ContentTypeSyncModel = { + name: "old name", + codename: "type", + content_groups: [ + { + name: "group 2", + codename: "group2", + }, + { + name: "group to be deleted", + codename: "groupToBeDeleted", + }, + { + name: "group 3", + codename: "group3", + }, + { + name: "group 1", + codename: "group1", + }, + ], + elements: [ + { + type: "number", + codename: "element_1", + name: "num", + }, + { + type: "date_time", + codename: "element_3", + name: "date", + }, + { + type: "text", + codename: "toDelete", + name: "to delete", + }, + { + type: "text", + codename: "element_2", + name: "txt", + }, + ], + }; + + const result = makeContentTypeHandler({ + targetItemsByCodenames: new Map(), + targetAssetsByCodenames: new Map(), + })(source, target); + + expect(result).toStrictEqual([ + { + op: "replace", + path: "/name", + value: "new name", + oldValue: "old name", + }, + { + op: "move", + path: "/content_groups/codename:group1", + before: { codename: "group2" }, + }, + { + op: "move", + path: "/content_groups/codename:group2", + after: { codename: "group1" }, + }, + { + op: "replace", + path: "/content_groups/codename:group2/name", + value: "group 2 with new name", + oldValue: "group 2", + }, + { + op: "addInto", + path: "/content_groups", + value: { + name: "group to be added", + codename: "groupToBeAdded", + }, + after: { codename: "group2" }, + }, + { + op: "move", + path: "/content_groups/codename:group3", + after: { codename: "groupToBeAdded" }, + }, + { + op: "remove", + path: "/content_groups/codename:groupToBeDeleted", + oldValue: { + name: "group to be deleted", + codename: "groupToBeDeleted", + }, + }, + { + op: "replace", + path: "/elements/codename:element_1/name", + value: "number", + oldValue: "num", + }, + { + op: "move", + path: "/elements/codename:element_2", + after: { codename: "element_1" }, + }, + { + op: "replace", + path: "/elements/codename:element_2/name", + value: "text", + oldValue: "txt", + }, + { + op: "move", + path: "/elements/codename:element_3", + after: { codename: "element_2" }, + }, + { + op: "remove", + path: "/elements/codename:toDelete", + oldValue: { + type: "text", + codename: "toDelete", + name: "to delete", + }, + }, + ]); + }); + + it("Correctly replaces references when adding guidelines element", () => { + const source: ContentTypeSyncModel = { + name: "type", + codename: "type", + elements: [ + { + type: "guidelines", + codename: "guidelines", + guidelines: + `

item linkxyz asset link

non-existing asset link

`, + }, + ], + }; + const target: ContentTypeSyncModel = { + name: "type", + codename: "type", + elements: [], + }; + + const result = makeContentTypeHandler({ + targetItemsByCodenames: new Map([["item", { id: "itemId", codename: "item" }]]), + targetAssetsByCodenames: new Map([ + ["asset1", { id: "asset1Id", codename: "asset1" }], + ["asset2", { id: "asset2Id", codename: "asset2" }], + ]), + })(source, target); + + const resultWithFilteredSpaces = result + .map(op => ({ + ...op, + value: op.op === "addInto" && typeof op.value === "object" && op.value !== null && "guidelines" in op.value + && typeof op.value.guidelines === "string" + ? { ...op.value ?? {}, guidelines: removeSpaces(op.value.guidelines) } + : {}, + })); + + expect(resultWithFilteredSpaces).toStrictEqual([ + { + op: "addInto", + path: "/elements", + value: { + type: "guidelines", + codename: "guidelines", + guidelines: removeSpaces( + `

item linkxyz asset link

non-existing asset link

`, + ), + }, + }, + ]); + }); +}); diff --git a/tests/unit/sync/diff/modelElements.test.ts b/tests/unit/sync/diff/modelElements.test.ts new file mode 100644 index 00000000..6f395176 --- /dev/null +++ b/tests/unit/sync/diff/modelElements.test.ts @@ -0,0 +1,503 @@ +import { describe, expect, it } from "@jest/globals"; + +import { makeAssetElementHandler, makeGuidelinesElementHandler } from "../../../../src/modules/sync/diff/modelElements"; +import { PatchOperation } from "../../../../src/modules/sync/types/diffModel"; +import { + ContentTypeSnippetsSyncModel, + ContentTypeSyncModel, +} from "../../../../src/modules/sync/types/fileContentModel"; +import { SyncAssetElement, SyncGuidelinesElement } from "../../../../src/modules/sync/types/syncModel"; +import { removeSpaces } from "./utils"; + +const basicType: ContentTypeSnippetsSyncModel = { + name: "type", + codename: "type", + elements: [], +}; + +describe("makeAssetElementHandler", () => { + it("creates operations for all changed properties", () => { + const type: ContentTypeSyncModel = { + ...basicType, + content_groups: [ + { + name: "group 1", + codename: "group1", + }, + { + name: "group 2", + codename: "group2", + }, + ], + }; + const source: SyncAssetElement = { + name: "new name", + codename: "asset", + type: "asset", + guidelines: "new guidelines", + is_required: true, + content_group: { codename: "group1" }, + is_non_localizable: true, + asset_count_limit: undefined, + image_width_limit: { + value: 33, + condition: "exactly", + }, + maximum_file_size: 45, + allowed_file_types: "adjustable", + image_height_limit: { + value: 34, + condition: "at_most", + }, + }; + const target: SyncAssetElement = { + name: "old name", + codename: "asset", + type: "asset", + guidelines: undefined, + is_required: false, + content_group: { codename: "group2" }, + is_non_localizable: undefined, + asset_count_limit: { + value: 44, + condition: "at_least", + }, + image_width_limit: undefined, + maximum_file_size: 31, + allowed_file_types: "any", + image_height_limit: { + value: 69, + condition: "exactly", + }, + }; + + const result = makeAssetElementHandler({ + sourceTypeOrSnippet: type, + targetTypeOrSnippet: type, + targetAssetCodenames: new Set(), + })( + source, + target, + ); + + expect(result).toStrictEqual([ + { + op: "replace", + path: "/name", + value: "new name", + oldValue: "old name", + }, + { + op: "replace", + path: "/guidelines", + value: "new guidelines", + oldValue: undefined, + }, + { + op: "replace", + path: "/is_required", + value: true, + oldValue: false, + }, + { + op: "replace", + path: "/content_group", + value: { codename: "group1" }, + oldValue: { codename: "group2" }, + }, + { + op: "replace", + path: "/is_non_localizable", + value: true, + oldValue: undefined, + }, + { + op: "replace", + path: "/asset_count_limit", + value: undefined, + oldValue: { + value: 44, + condition: "at_least", + }, + }, + { + op: "replace", + path: "/image_width_limit", + value: { + value: 33, + condition: "exactly", + }, + oldValue: undefined, + }, + { + op: "replace", + path: "/maximum_file_size", + value: 45, + oldValue: 31, + }, + { + op: "replace", + path: "/allowed_file_types", + value: "adjustable", + oldValue: "any", + }, + { + op: "replace", + path: "/image_height_limit", + value: { + value: 34, + condition: "at_most", + }, + oldValue: { + value: 69, + condition: "exactly", + }, + }, + ]); + }); + + it("creates operation to patch content_group when the target group doesn't exist", () => { + const sourceType: ContentTypeSyncModel = { + ...basicType, + content_groups: [{ name: "group 1", codename: "group1" }], + }; + const source: SyncAssetElement = { + type: "asset", + name: "asset", + codename: "asset", + content_group: { codename: "group1" }, + }; + const target: SyncAssetElement = { + type: "asset", + name: "asset", + codename: "asset", + content_group: { codename: "non-existent-group" }, + }; + + const result = makeAssetElementHandler({ + sourceTypeOrSnippet: sourceType, + targetTypeOrSnippet: basicType, + targetAssetCodenames: new Set(), + })( + source, + target, + ); + + expect(result).toStrictEqual([ + { + op: "replace", + path: "/content_group", + value: { codename: "group1" }, + oldValue: { codename: "non-existent-group" }, + }, + ]); + }); +}); + +describe("makeGuidelinesElementHandler", () => { + const sourceType: ContentTypeSnippetsSyncModel = { codename: "snip", name: "Snip", elements: [] }; + const targetType: ContentTypeSnippetsSyncModel = { codename: "snip", name: "Snip", elements: [] }; + const element: SyncGuidelinesElement = { + codename: "el", + guidelines: "", + type: "guidelines", + }; + + describe("asset references", () => { + [[true, true], [true, false], [false, true], [false, false]].forEach(([doesSourceExist, doesTargetExist]) => { + it(`Doesn't create operations for the same guidelines with an asset ${doesSourceExist ? "" : "not "}existing in source and ${doesTargetExist ? "" : "not "}existing in target`, () => { + const sourceCodenameTag = doesSourceExist ? "data-asset-codename=\"asset1\" " : ""; + const targetCodenameTag = doesTargetExist ? "data-asset-codename=\"asset1\" " : ""; + const source = + `

abc

xyz

`; + const target = + `

abc

xyz

`; + const knownAssets = new Map( + doesTargetExist ? [["asset1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "asset1" }]] : [], + ); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: knownAssets, + targetItemsByCodenames: new Map(), + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(result).toStrictEqual([]); + }); + }); + + it("Creates replace and uses assetId for asset existing in target", () => { + const source = + `

abc

xyz

`; + const target = + `

abc

xyz

`; + const knownAssets = new Map([ + ["asset1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "asset1" }], + ["asset2", { id: "391d6188-cc36-47d6-b4a9-09e4da4a54c1", codename: "asset2" }], + ]); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: knownAssets, + targetItemsByCodenames: new Map(), + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(removeSpacesInValues(result)).toStrictEqual(removeSpacesInValues([{ + op: "replace", + path: "/guidelines", + oldValue: target, + value: + `

abc

xyz

`, + }])); + }); + + it("Creates replace and uses assetExternalId for asset not existing in target", () => { + const source = + `

abc

xyz

`; + const target = + `

abc

xyz

`; + const knownAssets = new Map([["asset1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "asset1" }]]); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: knownAssets, + targetItemsByCodenames: new Map(), + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(removeSpacesInValues(result)).toStrictEqual(removeSpacesInValues([{ + op: "replace", + path: "/guidelines", + oldValue: target, + value: + `

abc

xyz

`, + }])); + }); + + it("Creates replace and uses assetId for asset not existing in source", () => { + const source = + `

abc

xyz

`; + const target = + `

abc

xyz

`; + const knownAssets = new Map([["asset1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "asset1" }]]); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: knownAssets, + targetItemsByCodenames: new Map(), + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(removeSpacesInValues(result)).toStrictEqual(removeSpacesInValues([{ + op: "replace", + path: "/guidelines", + oldValue: target, + value: + `

abc

xyz

`, + }])); + }); + }); + + describe("asset link references", () => { + [[true, true], [true, false], [false, true], [false, false]].forEach(([doesSourceExist, doesTargetExist]) => { + it(`Doesn't create operations for the same guidelines with link to an asset ${doesSourceExist ? "" : "not "}existing in source and ${doesTargetExist ? "" : "not "}existing in target`, () => { + const sourceCodenameTag = doesSourceExist ? "data-asset-codename=\"asset1\" " : ""; + const targetCodenameTag = doesTargetExist ? "data-asset-codename=\"asset1\" " : ""; + const source = `

abc asset linkxyz

`; + const target = `

abc asset linkxyz

`; + const knownAssets = new Map( + doesTargetExist ? [["asset1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "asset1" }]] : [], + ); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: knownAssets, + targetItemsByCodenames: new Map(), + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(result).toStrictEqual([]); + }); + }); + + it("Creates replace and uses assetId for asset link existing in target", () => { + const source = `

abcasset linkxyz

`; + const target = `

abcasset linkxyz

`; + const knownAssets = new Map([ + ["asset1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "asset1" }], + ["asset2", { id: "391d6188-cc36-47d6-b4a9-09e4da4a54c1", codename: "asset2" }], + ]); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: knownAssets, + targetItemsByCodenames: new Map(), + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(removeSpacesInValues(result)).toStrictEqual(removeSpacesInValues([{ + op: "replace", + path: "/guidelines", + oldValue: target, + value: `

abcasset linkxyz

`, + }])); + }); + + it("Creates replace and uses assetExternalId for asset link not existing in target", () => { + const source = `

abcasset linkxyz

`; + const target = `

abcasset linkxyz

`; + const knownAssets = new Map([["asset1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "asset1" }]]); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: knownAssets, + targetItemsByCodenames: new Map(), + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(removeSpacesInValues(result)).toStrictEqual(removeSpacesInValues([{ + op: "replace", + path: "/guidelines", + oldValue: target, + value: `

abcasset linkxyz

`, + }])); + }); + + it("Creates replace and uses assetId for asset link not existing in source", () => { + const source = `

abcasset linkxyz

`; + const target = `

abcasset linkxyz

`; + const knownAssets = new Map([["asset1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "asset1" }]]); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: knownAssets, + targetItemsByCodenames: new Map(), + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(removeSpacesInValues(result)).toStrictEqual(removeSpacesInValues([{ + op: "replace", + path: "/guidelines", + oldValue: target, + value: `

abcasset linkxyz

`, + }])); + }); + }); + + describe("item link references", () => { + [[true, true], [true, false], [false, true], [false, false]].forEach(([doesSourceExist, doesTargetExist]) => { + it(`Doesn't create operations for the same guidelines with link to an item ${doesSourceExist ? "" : "not "}existing in source and ${doesTargetExist ? "" : "not "}existing in target`, () => { + const sourceCodenameTag = doesSourceExist ? "data-item-codename=\"item1\" " : ""; + const targetCodenameTag = doesTargetExist ? "data-item-codename=\"item1\" " : ""; + const source = `

abc item linkxyz

`; + const target = `

abc item linkxyz

`; + const knownItems = new Map( + doesTargetExist ? [["item1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "item1" }]] : [], + ); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: new Map(), + targetItemsByCodenames: knownItems, + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(result).toStrictEqual([]); + }); + }); + + it("Creates replace and uses itemId for item link existing in target", () => { + const source = `

abcitem linkxyz

`; + const target = `

abcitem linkxyz

`; + const knownItems = new Map([ + ["item1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "item1" }], + ["item2", { id: "391d6188-cc36-47d6-b4a9-09e4da4a54c1", codename: "item2" }], + ]); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: new Map(), + targetItemsByCodenames: knownItems, + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(removeSpacesInValues(result)).toStrictEqual(removeSpacesInValues([{ + op: "replace", + path: "/guidelines", + oldValue: target, + value: `

abcitem linkxyz

`, + }])); + }); + + it("Creates replace and uses itemExternalId for item link not existing in target", () => { + const source = `

abcitem linkxyz

`; + const target = `

abcitem linkxyz

`; + const knownItems = new Map([["item1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "item1" }]]); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: new Map(), + targetItemsByCodenames: knownItems, + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(removeSpacesInValues(result)).toStrictEqual(removeSpacesInValues([{ + op: "replace", + path: "/guidelines", + oldValue: target, + value: `

abcitem linkxyz

`, + }])); + }); + + it("Creates replace and uses itemId for item link not existing in source", () => { + const source = `

abcitem linkxyz

`; + const target = `

abcitem linkxyz

`; + const knownItems = new Map([["item1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "item1" }]]); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: new Map(), + targetItemsByCodenames: knownItems, + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(removeSpacesInValues(result)).toStrictEqual(removeSpacesInValues([{ + op: "replace", + path: "/guidelines", + oldValue: target, + value: `

abcitem linkxyz

`, + }])); + }); + + it("Creates replace even with additional attributes and different order", () => { + const source = `

abcitem linkxyz

`; + const target = + `

abcitem linkxyz

`; + const knownItems = new Map([["item1", { id: "e89cb81a-fdea-46b5-86bb-0a0a46480146", codename: "item1" }]]); + + const result = makeGuidelinesElementHandler({ + targetAssetsByCodenames: new Map(), + targetItemsByCodenames: knownItems, + targetTypeOrSnippet: targetType, + sourceTypeOrSnippet: sourceType, + })({ ...element, guidelines: source }, { ...element, guidelines: target }); + + expect(removeSpacesInValues(result)).toStrictEqual(removeSpacesInValues([{ + op: "replace", + path: "/guidelines", + oldValue: target, + value: + `

abcitem linkxyz

`, + }])); + }); + }); +}); + +const removeSpacesInValues = (ops: ReadonlyArray): ReadonlyArray => + ops.map(op => + "value" in op && typeof op.value === "string" + ? { ...op, value: removeSpaces(op.value) } + : op + ); diff --git a/tests/unit/sync/diff/taxonomyGroup.test.ts b/tests/unit/sync/diff/taxonomyGroup.test.ts new file mode 100644 index 00000000..326dc85d --- /dev/null +++ b/tests/unit/sync/diff/taxonomyGroup.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "@jest/globals"; + +import { makeTaxonomyGroupHandler } from "../../../../src/modules/sync/diff/taxonomy"; +import { TaxonomySyncModel } from "../../../../src/modules/sync/types/fileContentModel"; + +describe("makeTaxonomyGroupHandler", () => { + it("correctly create patch operations for taxonomy groups with nested terms", () => { + const source: TaxonomySyncModel = { + name: "New group name", + codename: "taxonomy_group", + terms: [ + { + name: "New term name", + codename: "term1", + terms: [], + }, + { + name: "Term2", + codename: "term2", + terms: [ + { + name: "New term", + codename: "newTerm", + terms: [], + }, + ], + }, + ], + }; + const target: TaxonomySyncModel = { + name: "Old group name", + codename: "taxonomy_group", + terms: [ + { + name: "Term2", + codename: "term2", + terms: [ + { + name: "Term to delete", + codename: "termToDelete", + terms: [], + }, + ], + }, + { + name: "Old term name", + codename: "term1", + terms: [], + }, + ], + }; + + const result = makeTaxonomyGroupHandler()(source, target); + + expect(result).toStrictEqual([ + { + op: "replace", + path: "/name", + value: "New group name", + oldValue: "Old group name", + }, + { + op: "move", + path: "/terms/codename:term1", + before: { codename: "term2" }, + }, + { + op: "replace", + path: "/terms/codename:term1/name", + value: "New term name", + oldValue: "Old term name", + }, + { + op: "move", + path: "/terms/codename:term2", + after: { codename: "term1" }, + }, + { + op: "addInto", + path: "/terms/codename:term2/terms", + value: { + name: "New term", + codename: "newTerm", + terms: [], + }, + before: { codename: "termToDelete" }, + }, + { + op: "remove", + path: "/terms/codename:term2/terms/codename:termToDelete", + oldValue: { + name: "Term to delete", + codename: "termToDelete", + terms: [], + }, + }, + ]); + }); +}); diff --git a/tests/unit/sync/diff/utils.ts b/tests/unit/sync/diff/utils.ts new file mode 100644 index 00000000..cdf40ea1 --- /dev/null +++ b/tests/unit/sync/diff/utils.ts @@ -0,0 +1 @@ +export const removeSpaces = (str: string) => str.replaceAll(" ", ""); diff --git a/tsconfig.tests.jsonc b/tsconfig.tests.jsonc index 6bfd9bbb..26cec4d6 100644 --- a/tsconfig.tests.jsonc +++ b/tsconfig.tests.jsonc @@ -2,18 +2,17 @@ "compilerOptions": { /* Language and Environment */ "target": "es2022", - "lib": ["esnext"], - + "lib": [ + "esnext" + ], /* Modules */ "module": "nodenext", "moduleResolution": "nodenext", "resolveJsonModule": true, "allowImportingTsExtensions": true, "esModuleInterop": true, - /* Emit */ "noEmit": true, - /* Type Checking */ "strict": true, "noUnusedLocals": true, @@ -22,8 +21,11 @@ "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "allowUnreachableCode": false, - "skipLibCheck": true }, - "include": ["tests/integration/**/*", "tests/unit/**/*", "src/**/*"] + "include": [ + "tests/integration/**/*", + "tests/unit/**/*", + "src/**/*" + ] }