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/**/*" + ] }