From dbb4aa860c95d69bf767ccd38048a28124327bc2 Mon Sep 17 00:00:00 2001 From: Jiri Lojda Date: Thu, 22 Aug 2024 10:42:57 +0200 Subject: [PATCH] Add asset folders to model sync --- src/modules/sync/constants/filename.ts | 1 + src/modules/sync/diff.ts | 7 ++ src/modules/sync/diff/assetFolder.ts | 12 +++ src/modules/sync/generateSyncModel.ts | 9 ++ .../sync/modelTransfomers/assetFolder.ts | 11 +++ src/modules/sync/printDiff.ts | 15 +++- src/modules/sync/sync.ts | 3 + src/modules/sync/sync/assetFolders.ts | 52 +++++++++++ src/modules/sync/types/diffModel.ts | 1 + src/modules/sync/types/fileContentModel.ts | 2 + src/modules/sync/types/syncModel.ts | 6 ++ src/modules/sync/utils/fetchers.ts | 6 ++ src/modules/sync/utils/getContentModel.ts | 7 ++ .../syncModel/assetFolderTransformer.test.ts | 89 +++++++++++++++++++ .../syncModel/contentTypeTransformer.test.ts | 1 + tests/unit/syncModel/diff/assetFolder.test.ts | 58 ++++++++++++ .../unit/syncModel/snippetTransformer.test.ts | 1 + .../syncModel/webSpotlightTransformer.test.ts | 1 + 18 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 src/modules/sync/diff/assetFolder.ts create mode 100644 src/modules/sync/modelTransfomers/assetFolder.ts create mode 100644 src/modules/sync/sync/assetFolders.ts create mode 100644 tests/unit/syncModel/assetFolderTransformer.test.ts create mode 100644 tests/unit/syncModel/diff/assetFolder.test.ts diff --git a/src/modules/sync/constants/filename.ts b/src/modules/sync/constants/filename.ts index eeba8a03..1451e85d 100644 --- a/src/modules/sync/constants/filename.ts +++ b/src/modules/sync/constants/filename.ts @@ -2,3 +2,4 @@ export const contentTypesFileName = "contentTypes.json"; export const contentTypeSnippetsFileName = "contentTypeSnippets.json"; export const taxonomiesFileName = "taxonomyGroups.json"; export const webSpotlightFileName = "webSpotlight.json"; +export const assetFoldersFileName = "assetFolders.json"; diff --git a/src/modules/sync/diff.ts b/src/modules/sync/diff.ts index a6a362d2..22912ace 100644 --- a/src/modules/sync/diff.ts +++ b/src/modules/sync/diff.ts @@ -1,3 +1,4 @@ +import { assetFoldersHandler } from "./diff/assetFolder.js"; import { Handler } from "./diff/combinators.js"; import { makeContentTypeHandler, wholeContentTypesHandler } from "./diff/contentType.js"; import { makeContentTypeSnippetHandler, wholeContentTypeSnippetsHandler } from "./diff/contentTypeSnippet.js"; @@ -58,12 +59,18 @@ export const diff = (params: DiffParams): DiffModel => { params.targetEnvModel.webSpotlight, ); + const assetFoldersDiffModel = assetFoldersHandler( + params.sourceEnvModel.assetFolders, + params.targetEnvModel.assetFolders, + ); + 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)), webSpotlight: webSpotlightDiffModel, + assetFolders: assetFoldersDiffModel, }; }; diff --git a/src/modules/sync/diff/assetFolder.ts b/src/modules/sync/diff/assetFolder.ts new file mode 100644 index 00000000..f1dc6d8c --- /dev/null +++ b/src/modules/sync/diff/assetFolder.ts @@ -0,0 +1,12 @@ +import { AssetFolderSyncModel } from "../types/syncModel.js"; +import { baseHandler, Handler, makeArrayHandler, makeObjectHandler } from "./combinators.js"; + +export const assetFolderHandler: Handler = makeObjectHandler({ + name: baseHandler, + folders: { contextfulHandler: () => makeArrayHandler(f => f.codename, assetFolderHandler) }, +}); + +export const assetFoldersHandler: Handler> = makeArrayHandler( + f => f.codename, + assetFolderHandler, +); diff --git a/src/modules/sync/generateSyncModel.ts b/src/modules/sync/generateSyncModel.ts index d108f84f..43a33e53 100644 --- a/src/modules/sync/generateSyncModel.ts +++ b/src/modules/sync/generateSyncModel.ts @@ -1,5 +1,6 @@ import { AssetContracts, + AssetFolderContracts, ContentItemContracts, ManagementClient, TaxonomyContracts, @@ -18,6 +19,7 @@ import { taxonomiesFileName, webSpotlightFileName, } from "./constants/filename.js"; +import { transformAssetFolderModel } from "./modelTransfomers/assetFolder.js"; import { transformContentTypeModel } from "./modelTransfomers/contentTypes.js"; import { transformContentTypeSnippetsModel } from "./modelTransfomers/contentTypeSnippets.js"; import { transformTaxonomyGroupsModel } from "./modelTransfomers/taxonomyGroups.js"; @@ -26,6 +28,7 @@ import { ContentTypeSnippetsWithUnionElements, ContentTypeWithUnionElements } fr import { FileContentModel } from "./types/fileContentModel.js"; import { getRequiredIds } from "./utils/contentTypeHelpers.js"; import { + fetchAssetFolders, fetchContentTypes, fetchContentTypeSnippets, fetchRequiredAssets, @@ -39,6 +42,7 @@ export type EnvironmentModel = { contentTypeSnippets: ReadonlyArray; contentTypes: ReadonlyArray; webSpotlight: WebSpotlightContracts.IWebSpotlightStatus; + assetFolders: ReadonlyArray; assets: ReadonlyArray; items: ReadonlyArray; }; @@ -52,6 +56,8 @@ export const fetchModel = async (client: ManagementClient): Promise; itemIds: Set }>( (previous, type) => { const ids = getRequiredIds(type.elements); @@ -74,6 +80,7 @@ export const fetchModel = async (client: ManagementClient): Promise ({ + ...omit(environmentModel, ["id", "external_id"]), + folders: environmentModel.folders.map(transformAssetFolderModel), +}); diff --git a/src/modules/sync/printDiff.ts b/src/modules/sync/printDiff.ts index 40c9e2b6..3f13eb89 100644 --- a/src/modules/sync/printDiff.ts +++ b/src/modules/sync/printDiff.ts @@ -13,9 +13,17 @@ import { resolveOutputPath, } from "./utils/fileUtils.js"; import { DiffData, resolveHtmlTemplate } from "./utils/htmlRenderers.js"; +import { PatchOperation } from "./types/patchOperation.js"; export const printDiff = (diffModel: DiffModel, logOptions: LogOptions) => { - logInfo(logOptions, "standard", chalk.blue.bold("TAXONOMY GROUPS:")); + logInfo(logOptions, "standard", chalk.blue.bold("ASSET FOLDERS:")); + if (diffModel.assetFolders.length) { + diffModel.assetFolders.forEach(op => printPatchOperation(op, logOptions)); + } else { + logInfo(logOptions, "standard", "No asset folders to update."); + } + + logInfo(logOptions, "standard", chalk.blue.bold("\nTAXONOMY GROUPS:")); printDiffEntity(diffModel.taxonomyGroups, "taxonomy groups", logOptions); logInfo(logOptions, "standard", chalk.blue.bold("\nCONTENT TYPE SNIPPETS:")); @@ -62,7 +70,7 @@ const printDiffEntity = ( Array.from(diffObject.updated.entries()).sort().forEach(([codename, value]) => { if (value.length) { logInfo(logOptions, "standard", `Entity codename: ${chalk.blue(codename)}`); - value.forEach(v => logInfo(logOptions, "standard", `${chalk.yellow(JSON.stringify(v, null, 2))}\n`)); + value.forEach(v => printPatchOperation(v, logOptions)); } }); } else { @@ -102,3 +110,6 @@ export const createAdvancedDiffFile = (diffData: DiffData) => { openOutputFile(resolvedPath, logOptions); } }; + +const printPatchOperation = (operation: PatchOperation, logOptions: LogOptions) => + logInfo(logOptions, "standard", `${chalk.yellow(JSON.stringify(operation, null, 2))}\n`); diff --git a/src/modules/sync/sync.ts b/src/modules/sync/sync.ts index c671d81b..ec7f3f1c 100644 --- a/src/modules/sync/sync.ts +++ b/src/modules/sync/sync.ts @@ -2,6 +2,7 @@ import { ManagementClient } from "@kontent-ai/management-sdk"; import { logInfo, LogOptions } from "../../log.js"; import { serially } from "../../utils/requests.js"; +import { syncAssetFolders } from "./sync/assetFolders.js"; import { addElementsIntoSnippetsWithoutReferences, addSnippetsReferences, @@ -17,6 +18,8 @@ import { DiffModel } from "./types/diffModel.js"; export const sync = async (client: ManagementClient, diff: DiffModel, logOptions: LogOptions) => { // there order of these operations is very important + await syncAssetFolders(client, diff.assetFolders, logOptions); + await syncTaxonomies(client, diff.taxonomyGroups, logOptions); logInfo(logOptions, "standard", "Adding content type snippets"); diff --git a/src/modules/sync/sync/assetFolders.ts b/src/modules/sync/sync/assetFolders.ts new file mode 100644 index 00000000..19bbb43e --- /dev/null +++ b/src/modules/sync/sync/assetFolders.ts @@ -0,0 +1,52 @@ +import { AssetFolderModels, ManagementClient } from "@kontent-ai/management-sdk"; +import { match } from "ts-pattern"; + +import { logInfo, LogOptions } from "../../../log.js"; +import { throwError } from "../../../utils/error.js"; +import { apply } from "../../../utils/function.js"; +import { omit } from "../../../utils/object.js"; +import { DiffModel } from "../types/diffModel.js"; +import { getTargetCodename, PatchOperation } from "../types/patchOperation.js"; + +export const syncAssetFolders = async ( + client: ManagementClient, + operations: DiffModel["assetFolders"], + logOptions: LogOptions, +) => { + logInfo(logOptions, "standard", "Updating asset folders"); + + if (!operations.length) { + return; + } + + await client + .modifyAssetFolders() + .withData(operations.map(convertOperation)) + .toPromise(); +}; + +const convertOperation = (operation: PatchOperation): AssetFolderModels.IModifyAssetFolderData => { + const targetCodename = apply(codename => ({ codename }), getTargetCodename(operation)); + + return match(omit(operation, ["path"])) + .returnType() + .with({ op: "addInto" }, op => ({ + ...op, + value: op.value as AssetFolderModels.IAssetFolderValue, + reference: targetCodename ?? undefined, + })) + .with( + { op: "move" }, + op => throwError(`Move operation is not supported for asset folders. Found operation: ${JSON.stringify(op)}`), + ) + .with({ op: "replace" }, op => ({ + ...omit(op, ["oldValue"]), + op: "rename", + reference: targetCodename ?? throwError(`Missing target codename in ${JSON.stringify(operation)}`), + value: typeof op.value === "string" ? op.value : throwError("Invalid value type"), + })) + .otherwise(op => ({ + ...omit(op, ["oldValue"]), + reference: targetCodename ?? throwError(`Missing target codename in ${JSON.stringify(operation)}`), + })); +}; diff --git a/src/modules/sync/types/diffModel.ts b/src/modules/sync/types/diffModel.ts index 02ce3bc9..77fd491b 100644 --- a/src/modules/sync/types/diffModel.ts +++ b/src/modules/sync/types/diffModel.ts @@ -23,4 +23,5 @@ export type DiffModel = Readonly<{ contentTypeSnippets: DiffObject>; contentTypes: DiffObject>; webSpotlight: WebSpotlightDiffModel; + assetFolders: ReadonlyArray; }>; diff --git a/src/modules/sync/types/fileContentModel.ts b/src/modules/sync/types/fileContentModel.ts index f1a03fd4..a2da4ff0 100644 --- a/src/modules/sync/types/fileContentModel.ts +++ b/src/modules/sync/types/fileContentModel.ts @@ -1,4 +1,5 @@ import { + AssetFolderSyncModel, ContentTypeSnippetsSyncModel, ContentTypeSyncModel, TaxonomySyncModel, @@ -10,4 +11,5 @@ export type FileContentModel = Readonly<{ contentTypeSnippets: ReadonlyArray; contentTypes: ReadonlyArray; webSpotlight: WebSpotlightSyncModel; + assetFolders: ReadonlyArray; }>; diff --git a/src/modules/sync/types/syncModel.ts b/src/modules/sync/types/syncModel.ts index e94b2808..5e544756 100644 --- a/src/modules/sync/types/syncModel.ts +++ b/src/modules/sync/types/syncModel.ts @@ -1,4 +1,5 @@ import { + AssetFolderContracts, ContentTypeContracts, ContentTypeElements, ContentTypeSnippetContracts, @@ -123,6 +124,11 @@ export type WebSpotlightSyncModel = Replace< { root_type: Readonly<{ codename: string }> | null } >; +export type AssetFolderSyncModel = Replace< + Omit, + Readonly<{ folders: ReadonlyArray }> +>; + export const isSyncCustomElement = (entity: unknown): entity is SyncCustomElement => typeof entity === "object" && entity !== null && "type" in entity && entity.type === "custom"; diff --git a/src/modules/sync/utils/fetchers.ts b/src/modules/sync/utils/fetchers.ts index 14ce2539..5468d005 100644 --- a/src/modules/sync/utils/fetchers.ts +++ b/src/modules/sync/utils/fetchers.ts @@ -91,3 +91,9 @@ export const fetchWebSpotlight = (client: ManagementClient) => .checkWebSpotlightStatus() .toPromise() .then(res => res.rawData); + +export const fetchAssetFolders = (client: ManagementClient) => + client + .listAssetFolders() + .toPromise() + .then(res => res.rawData.folders); diff --git a/src/modules/sync/utils/getContentModel.ts b/src/modules/sync/utils/getContentModel.ts index fffbbb15..32aa524a 100644 --- a/src/modules/sync/utils/getContentModel.ts +++ b/src/modules/sync/utils/getContentModel.ts @@ -3,6 +3,7 @@ import * as fs from "fs/promises"; import { LogOptions } from "../../../log.js"; import { + assetFoldersFileName, contentTypesFileName, contentTypeSnippetsFileName, taxonomiesFileName, @@ -11,6 +12,7 @@ import { import { fetchModel, transformSyncModel } from "../generateSyncModel.js"; import { FileContentModel } from "../types/fileContentModel.js"; import { + AssetFolderSyncModel, ContentTypeSnippetsSyncModel, ContentTypeSyncModel, TaxonomySyncModel, @@ -36,11 +38,16 @@ export const readContentModelFromFolder = async (folderName: string): Promise "[]"), + ) as ReadonlyArray; + return { contentTypes, contentTypeSnippets: snippets, taxonomyGroups: taxonomyGroups, webSpotlight, + assetFolders, }; }; diff --git a/tests/unit/syncModel/assetFolderTransformer.test.ts b/tests/unit/syncModel/assetFolderTransformer.test.ts new file mode 100644 index 00000000..ecc79a51 --- /dev/null +++ b/tests/unit/syncModel/assetFolderTransformer.test.ts @@ -0,0 +1,89 @@ +import { AssetFolderContracts } from "@kontent-ai/management-sdk"; +import { describe, expect, it } from "vitest"; + +import { transformAssetFolderModel } from "../../../src/modules/sync/modelTransfomers/assetFolder.js"; +import { AssetFolderSyncModel } from "../../../src/modules/sync/types/syncModel.js"; + +describe("transformAssetFolderModel", () => { + it("correctly transforms asset folder model to sync model", () => { + const input = { + id: "01160b00-9d58-446d-bd13-5d9ceeb55d3c", + name: "folderName", + codename: "folderCodename", + external_id: "externalId", + folders: [ + { + id: "folderId", + name: "nestedFolder", + codename: "nestedFolderCodename", + external_id: "nestedExternalId", + folders: [], + }, + ], + } as const satisfies AssetFolderContracts.IAssetFolderContract; + + const expectedOutput: AssetFolderSyncModel = { + name: "folderName", + codename: "folderCodename", + folders: [ + { + name: "nestedFolder", + codename: "nestedFolderCodename", + folders: [], + }, + ], + }; + + const result = transformAssetFolderModel(input); + + expect(result).toStrictEqual(expectedOutput); + }); + + it("correctly transforms asset folder model to sync model with nested folders", () => { + const input = { + id: "01160b00-9d58-446d-bd13-5d9ceeb55d3c", + name: "folderName", + codename: "folderCodename", + external_id: "externalId", + folders: [ + { + id: "folderId", + name: "nestedFolder", + codename: "nestedFolderCodename", + external_id: "nestedExternalId", + folders: [ + { + id: "nestedFolderId", + name: "nestedNestedFolder", + codename: "nestedNestedFolderCodename", + external_id: "nestedNestedExternalId", + folders: [], + }, + ], + }, + ], + } as const satisfies AssetFolderContracts.IAssetFolderContract; + + const expectedOutput: AssetFolderSyncModel = { + name: "folderName", + codename: "folderCodename", + folders: [ + { + name: "nestedFolder", + codename: "nestedFolderCodename", + folders: [ + { + name: "nestedNestedFolder", + codename: "nestedNestedFolderCodename", + folders: [], + }, + ], + }, + ], + }; + + const result = transformAssetFolderModel(input); + + expect(result).toStrictEqual(expectedOutput); + }); +}); diff --git a/tests/unit/syncModel/contentTypeTransformer.test.ts b/tests/unit/syncModel/contentTypeTransformer.test.ts index 69f02b86..81e937d4 100644 --- a/tests/unit/syncModel/contentTypeTransformer.test.ts +++ b/tests/unit/syncModel/contentTypeTransformer.test.ts @@ -37,6 +37,7 @@ const createEnvironmentModel = (contentTypes: ReadonlyArray = [], + codename: string = "", +): AssetFolderSyncModel => ({ + name, + codename: codename || name, + folders: [...subFolders], +}); + +describe("assetFolderHandler", () => { + it("creates addInto operation for nested folders", () => { + const source = createFolder("root", [createFolder("folder1", [createFolder("folder2")])]); + const target = createFolder("root", [createFolder("folder1")]); + + const operations = assetFolderHandler(source, target); + + expect(operations).toStrictEqual([ + { op: "addInto", path: "/folders/codename:folder1/folders", value: createFolder("folder2") }, + ]); + }); + + it("creates remove operation for nested folders", () => { + const source = createFolder("root", [createFolder("folder1")]); + const target = createFolder("root", [createFolder("folder1", [createFolder("folder2")])]); + + const operations = assetFolderHandler(source, target); + + expect(operations).toStrictEqual([ + { + op: "remove", + path: "/folders/codename:folder1/folders/codename:folder2", + oldValue: createFolder("folder2"), + }, + ]); + }); + + it("creates replace operation for rename in nested folders", () => { + const source = createFolder("root", [createFolder("folder 1", [], "fCodename")]); + const target = createFolder("root", [createFolder("folder 2", [], "fCodename")]); + + const operations = assetFolderHandler(source, target); + + expect(operations).toStrictEqual([ + { + op: "replace", + path: "/folders/codename:fCodename/name", + value: "folder 1", + oldValue: "folder 2", + }, + ]); + }); +}); diff --git a/tests/unit/syncModel/snippetTransformer.test.ts b/tests/unit/syncModel/snippetTransformer.test.ts index b891b1db..c7546982 100644 --- a/tests/unit/syncModel/snippetTransformer.test.ts +++ b/tests/unit/syncModel/snippetTransformer.test.ts @@ -39,6 +39,7 @@ describe("content type snippet transfomers", () => { contentTypes: [], contentTypeSnippets: snippets, taxonomyGroups: [], + assetFolders: [], webSpotlight: { enabled: false, root_type: null, diff --git a/tests/unit/syncModel/webSpotlightTransformer.test.ts b/tests/unit/syncModel/webSpotlightTransformer.test.ts index c7f48ea2..ba7064aa 100644 --- a/tests/unit/syncModel/webSpotlightTransformer.test.ts +++ b/tests/unit/syncModel/webSpotlightTransformer.test.ts @@ -17,6 +17,7 @@ const createEnvironmentModel = (isWebSpotlightEnabled: boolean, rootTypeId: stri contentTypes, contentTypeSnippets: [], taxonomyGroups: [], + assetFolders: [], webSpotlight: { enabled: isWebSpotlightEnabled, root_type: rootTypeId ? { id: rootTypeId } : null,