diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3f43cfe9..43351318 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,6 +14,8 @@ /tests/commands/deployment/ @celonis/astro @celonis/studio-platform /src/commands/action-flows/ @celonis/process-automation /tests/commands/action-flows/ @celonis/process-automation +/src/commands/bookmarks/ @celonis/studio-platform +/tests/commands/bookmarks/ @celonis/studio-platform /src/commands/analysis/ @celonis/process-analytics /src/commands/cpm4/ @celonis/cpm4 /src/commands/data-pipeline/ @Dusan-r @IvanGandacov @EktaCelonis @gorasoCelonis diff --git a/docs/command-graph.html b/docs/command-graph.html index ed95d759..68fd253f 100644 --- a/docs/command-graph.html +++ b/docs/command-graph.html @@ -155,9 +155,9 @@ { id: "area_analyze", label: "analyze", group: "area", path: "analyze", description: "Analyze action flows and return its dependencies", options: ["-h, --help"] }, { id: "area_export", label: "export", group: "area", path: "export", - description: "Export resource: action flow or data pool", options: ["-h, --help"] }, + description: "Export resource: action flow, data pool, or bookmarks", options: ["-h, --help"] }, { id: "area_import", label: "import", group: "area", path: "import", - description: "Import resource: action flow or data pool", options: ["-h, --help"] }, + description: "Import resource: action flow, data pool, or bookmarks", options: ["-h, --help"] }, { id: "area_pull", label: "pull", group: "area", path: "pull", description: "Pull resource: skill, bookmark, view bookmark, data pool or asset", options: ["-h, --help"] }, { id: "area_push", label: "push", group: "area", path: "push", @@ -211,6 +211,9 @@ { id: "export_data_pool", label: "data-pool", group: "command", path: "export data-pool", description: "Command to export a data pool", options: ["-p, --profile ", "--id ", "--outputToJsonFile", "-h, --help"] }, + { id: "export_bookmarks", label: "bookmarks", group: "command", path: "export bookmarks", + description: "Export bookmarks for a package", + options: ["-p, --profile ", "--packageKey ", "-f, --file ", "-h, --help"] }, // import { id: "import_action_flows", label: "action-flows", group: "command", path: "import action-flows", @@ -219,6 +222,9 @@ { id: "import_data_pools", label: "data-pools", group: "command", path: "import data-pools", description: "Command to batch import multiple data pools with their objects and dependencies", options: ["-p, --profile ", "-f, --jsonFile ", "--outputToJsonFile", "-h, --help"] }, + { id: "import_bookmarks", label: "bookmarks", group: "command", path: "import bookmarks", + description: "Import bookmarks into a package", + options: ["-p, --profile ", "--packageKey ", "-f, --file ", "-h, --help"] }, // pull { id: "pull_skill", label: "skill", group: "command", path: "pull skill", @@ -479,9 +485,9 @@ ["area_analyze","analyze_action_flows"], - ["area_export","export_action_flows"],["area_export","export_data_pool"], + ["area_export","export_action_flows"],["area_export","export_data_pool"],["area_export","export_bookmarks"], - ["area_import","import_action_flows"],["area_import","import_data_pools"], + ["area_import","import_action_flows"],["area_import","import_data_pools"],["area_import","import_bookmarks"], ["area_pull","pull_skill"],["area_pull","pull_bookmarks"],["area_pull","pull_data_pool"], ["area_pull","pull_asset"],["area_pull","pull_package"],["area_pull","pull_view_bookmarks"], diff --git a/docs/user-guide/bookmark-commands.md b/docs/user-guide/bookmark-commands.md new file mode 100644 index 00000000..651ef000 --- /dev/null +++ b/docs/user-guide/bookmark-commands.md @@ -0,0 +1,25 @@ +# Bookmark Commands + +## Export Bookmarks + +Export all bookmarks for a package. The exported file can then be imported into another team using the `import bookmarks` command. + +``` +content-cli export bookmarks -p --packageKey +``` + +By default, the export is saved to `bookmarks-.json` in the current directory. Use `-f` to specify a custom output path: + +``` +content-cli export bookmarks -p --packageKey -f my-bookmarks.json +``` + +## Import Bookmarks + +Import bookmarks into a package from a previously exported JSON file. + +``` +content-cli import bookmarks -p --packageKey -f bookmarks-.json +``` + +The import result is printed to the console showing the status of each entry (CREATED or SKIPPED). diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 5eb518f1..5fb464b7 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -11,3 +11,4 @@ Content CLI organizes its commands into groups by area. Each group covers a spec | [Asset Registry Commands](./asset-registry-commands.md) | Discover registered asset types and their service descriptors | | [Data Pool Commands](./data-pool-commands.md) | Export and import Data Pools with their dependencies | | [Action Flow Commands](./action-flow-commands.md) | Analyze and export/import Action Flows and their dependencies | +| [Bookmark Commands](./bookmark-commands.md) | Export and import bookmarks for packages | diff --git a/mkdocs.yaml b/mkdocs.yaml index 4648ae2f..0f5d234c 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -19,6 +19,7 @@ nav: - Asset Registry Commands: './user-guide/asset-registry-commands.md' - Data Pool Commands: './user-guide/data-pool-commands.md' - Action Flow Commands: './user-guide/action-flow-commands.md' + - Bookmark Commands: './user-guide/bookmark-commands.md' - Development: - Architecture: './internal-architecture.md' - How to Add a Command: './how-to-add-command.md' diff --git a/src/commands/bookmarks/bookmarks-api.ts b/src/commands/bookmarks/bookmarks-api.ts new file mode 100644 index 00000000..349be47b --- /dev/null +++ b/src/commands/bookmarks/bookmarks-api.ts @@ -0,0 +1,29 @@ +import { HttpClient } from "../../core/http/http-client"; +import { Context } from "../../core/command/cli-context"; +import { FatalError } from "../../core/utils/logger"; +import { BookmarksExport, BookmarksImportRequest, BookmarksImportResult } from "./bookmarks.interfaces"; + +export class BookmarksApi { + + private readonly httpClient: () => HttpClient; + + constructor(context: Context) { + this.httpClient = () => context.httpClient; + } + + public async exportBookmarks(packageKey: string): Promise { + try { + return await this.httpClient().get(`/package-manager/api/packages/${encodeURIComponent(packageKey)}/bookmarks/export`); + } catch (e) { + throw new FatalError(`Problem exporting bookmarks for package ${packageKey}: ${e}`); + } + } + + public async importBookmarks(packageKey: string, payload: BookmarksImportRequest): Promise { + try { + return await this.httpClient().post(`/package-manager/api/packages/${encodeURIComponent(packageKey)}/bookmarks/import`, payload); + } catch (e) { + throw new FatalError(`Problem importing bookmarks for package ${packageKey}: ${e}`); + } + } +} diff --git a/src/commands/bookmarks/bookmarks-command.service.ts b/src/commands/bookmarks/bookmarks-command.service.ts new file mode 100644 index 00000000..3c903f2e --- /dev/null +++ b/src/commands/bookmarks/bookmarks-command.service.ts @@ -0,0 +1,27 @@ +import { Context } from "../../core/command/cli-context"; +import { BookmarksApi } from "./bookmarks-api"; +import { fileService, FileService } from "../../core/utils/file-service"; +import { logger } from "../../core/utils/logger"; + +export class BookmarksCommandService { + + private readonly bookmarksApi: BookmarksApi; + + constructor(context: Context) { + this.bookmarksApi = new BookmarksApi(context); + } + + public async exportBookmarks(packageKey: string, file?: string): Promise { + const exportData = await this.bookmarksApi.exportBookmarks(packageKey); + + const fileName = file ?? `bookmarks-${packageKey}.json`; + fileService.writeToFileWithGivenName(JSON.stringify(exportData, null, 4), fileName); + logger.info(FileService.fileDownloadedMessage + fileName); + } + + public async importBookmarks(packageKey: string, file: string): Promise { + const payload = fileService.readFileToJson(file); + const result = await this.bookmarksApi.importBookmarks(packageKey, payload); + logger.info(`Bookmarks imported successfully: ${JSON.stringify(result, null, 4)}`); + } +} diff --git a/src/commands/bookmarks/bookmarks.interfaces.ts b/src/commands/bookmarks/bookmarks.interfaces.ts new file mode 100644 index 00000000..e487944c --- /dev/null +++ b/src/commands/bookmarks/bookmarks.interfaces.ts @@ -0,0 +1,40 @@ +export interface BookmarkEntry { + assetKey: string; + assetType: string; + bookmark: BookmarkDetails; + preference: BookmarkPreference; +} + +export interface BookmarkDetails { + name: string; + ownerId: string; + sharedByLink: boolean; + published: boolean; +} + +export interface BookmarkPreference { + configuration: string; + shareable: boolean; + mode: string; + userId: string; +} + +export interface BookmarksExport { + packageKey: string; + entries: BookmarkEntry[]; +} + +export interface BookmarksImportRequest { + entries: BookmarkEntry[]; +} + +export interface BookmarksImportResultEntry { + assetKey: string; + status: string; + reason: string | null; +} + +export interface BookmarksImportResult { + packageKey: string; + entries: BookmarksImportResultEntry[]; +} diff --git a/src/commands/bookmarks/module.ts b/src/commands/bookmarks/module.ts new file mode 100644 index 00000000..54a1beb3 --- /dev/null +++ b/src/commands/bookmarks/module.ts @@ -0,0 +1,33 @@ +import { Configurator, IModule } from "../../core/command/module-handler"; +import { Context } from "../../core/command/cli-context"; +import { Command, OptionValues } from "commander"; +import { BookmarksCommandService } from "./bookmarks-command.service"; + +class Module extends IModule { + + public register(context: Context, configurator: Configurator): void { + const exportCommand = configurator.command("export"); + exportCommand.command("bookmarks") + .description("Export bookmarks for a package") + .requiredOption("--packageKey ", "Key of the package to export bookmarks from") + .option("-f, --file ", "Output file path (defaults to bookmarks-.json)") + .action(this.exportBookmarks); + + const importCommand = configurator.command("import"); + importCommand.command("bookmarks") + .description("Import bookmarks into a package") + .requiredOption("--packageKey ", "Key of the package to import bookmarks into") + .requiredOption("-f, --file ", "Bookmarks JSON file to import") + .action(this.importBookmarks); + } + + private async exportBookmarks(context: Context, _command: Command, options: OptionValues): Promise { + await new BookmarksCommandService(context).exportBookmarks(options.packageKey, options.file); + } + + private async importBookmarks(context: Context, _command: Command, options: OptionValues): Promise { + await new BookmarksCommandService(context).importBookmarks(options.packageKey, options.file); + } +} + +export = Module; diff --git a/tests/commands/bookmarks/bookmarks.spec.ts b/tests/commands/bookmarks/bookmarks.spec.ts new file mode 100644 index 00000000..08107ec5 --- /dev/null +++ b/tests/commands/bookmarks/bookmarks.spec.ts @@ -0,0 +1,187 @@ +import { mockAxiosGet, mockAxiosGetError, mockAxiosPost, mockAxiosPostError, mockedPostRequestBodyByUrl } from "../../utls/http-requests-mock"; +import { BookmarksCommandService } from "../../../src/commands/bookmarks/bookmarks-command.service"; +import { loggingTestTransport } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; +import { FatalError } from "../../../src/core/utils/logger"; +import { testContext } from "../../utls/test-context"; +import { getJsonFromDownloadedFile, writeJsonTempFile } from "../../utls/fs-utils"; +import { Configurator } from "../../../src/core/command/module-handler"; +import { Command } from "commander"; + +describe("Export bookmarks", () => { + + const packageKey = "my-package"; + const mockExportResponse = { + packageKey: "my-package", + entries: [ + { + assetKey: "analysis-1", + assetType: "ANALYSIS", + bookmark: { + name: "My Bookmark", + ownerId: "user-123", + sharedByLink: false, + published: true, + }, + preference: { + configuration: "{\"filters\":[]}", + shareable: true, + mode: "PROCESS_ANALYTICS", + userId: "user-123", + }, + }, + ], + }; + + it("Should call export API and write JSON to default file", async () => { + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/packages/${packageKey}/bookmarks/export`, mockExportResponse); + + await new BookmarksCommandService(testContext).exportBookmarks(packageKey); + + expect(loggingTestTransport.logMessages).toHaveLength(1); + expect(loggingTestTransport.logMessages[0].message).toContain(FileService.fileDownloadedMessage); + expect(loggingTestTransport.logMessages[0].message).toContain(`bookmarks-${packageKey}.json`); + + expect(getJsonFromDownloadedFile()).toEqual(mockExportResponse); + }); + + it("Should call export API and write JSON to specified file", async () => { + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/packages/${packageKey}/bookmarks/export`, mockExportResponse); + + await new BookmarksCommandService(testContext).exportBookmarks(packageKey, "custom-output.json"); + + expect(loggingTestTransport.logMessages).toHaveLength(1); + expect(loggingTestTransport.logMessages[0].message).toContain("custom-output.json"); + + expect(getJsonFromDownloadedFile()).toEqual(mockExportResponse); + }); + + it("Should throw FatalError when export API fails", async () => { + mockAxiosGetError(`https://myTeam.celonis.cloud/package-manager/api/packages/${packageKey}/bookmarks/export`, 500, { message: "Internal Server Error" }); + + await expect(new BookmarksCommandService(testContext).exportBookmarks(packageKey)) + .rejects.toThrow(FatalError); + await expect(new BookmarksCommandService(testContext).exportBookmarks(packageKey)) + .rejects.toThrow(/Problem exporting bookmarks for package my-package/); + }); +}); + +describe("Import bookmarks", () => { + + const packageKey = "my-package"; + const mockImportPayload = { + entries: [ + { + assetKey: "analysis-1", + assetType: "ANALYSIS", + bookmark: { + name: "My Bookmark", + ownerId: "user-123", + sharedByLink: false, + published: true, + }, + preference: { + configuration: "{\"filters\":[]}", + shareable: true, + mode: "PROCESS_ANALYTICS", + userId: "user-123", + }, + }, + ], + }; + + const mockImportResult = { + packageKey: "my-package", + entries: [ + { + assetKey: "analysis-1", + status: "CREATED", + reason: null, + }, + ], + }; + + it("Should read file and call import API", async () => { + const importUrl = `https://myTeam.celonis.cloud/package-manager/api/packages/${packageKey}/bookmarks/import`; + writeJsonTempFile("bookmarks-import.json", mockImportPayload); + mockAxiosPost(importUrl, mockImportResult); + + await new BookmarksCommandService(testContext).importBookmarks(packageKey, "bookmarks-import.json"); + + expect(loggingTestTransport.logMessages).toHaveLength(1); + expect(loggingTestTransport.logMessages[0].message).toContain("Bookmarks imported successfully"); + + expect(JSON.parse(mockedPostRequestBodyByUrl.get(importUrl))).toEqual(mockImportPayload); + }); + + it("Should throw FatalError when import API fails", async () => { + const importUrl = `https://myTeam.celonis.cloud/package-manager/api/packages/${packageKey}/bookmarks/import`; + writeJsonTempFile("bookmarks-import-err.json", mockImportPayload); + mockAxiosPostError(importUrl, 500, { message: "Internal Server Error" }); + + await expect(new BookmarksCommandService(testContext).importBookmarks(packageKey, "bookmarks-import-err.json")) + .rejects.toThrow(FatalError); + await expect(new BookmarksCommandService(testContext).importBookmarks(packageKey, "bookmarks-import-err.json")) + .rejects.toThrow(/Problem importing bookmarks for package my-package/); + }); +}); + +describe("Bookmarks module registration", () => { + + it("Should register export and import bookmarks commands", () => { + const Module = require("../../../src/commands/bookmarks/module"); + const program = new Command(); + const configurator = new Configurator(program, testContext); + + const moduleInstance = new Module(); + moduleInstance.register(testContext, configurator); + + const exportCmd = program.commands.find(c => c.name() === "export"); + expect(exportCmd).toBeDefined(); + const exportBookmarksCmd = exportCmd.commands.find(c => c.name() === "bookmarks"); + expect(exportBookmarksCmd).toBeDefined(); + expect(exportBookmarksCmd.description()).toBe("Export bookmarks for a package"); + + const importCmd = program.commands.find(c => c.name() === "import"); + expect(importCmd).toBeDefined(); + const importBookmarksCmd = importCmd.commands.find(c => c.name() === "bookmarks"); + expect(importBookmarksCmd).toBeDefined(); + expect(importBookmarksCmd.description()).toBe("Import bookmarks into a package"); + }); + + it("Should execute export bookmarks action", async () => { + const exportUrl = `https://myTeam.celonis.cloud/package-manager/api/packages/test-pkg/bookmarks/export`; + mockAxiosGet(exportUrl, { packageKey: "test-pkg", entries: [] }); + + const Module = require("../../../src/commands/bookmarks/module"); + const program = new Command(); + const configurator = new Configurator(program, testContext); + + const moduleInstance = new Module(); + moduleInstance.register(testContext, configurator); + + await program.parseAsync(["export", "bookmarks", "--packageKey", "test-pkg"], { from: "user" }); + + expect(loggingTestTransport.logMessages).toHaveLength(1); + expect(loggingTestTransport.logMessages[0].message).toContain(FileService.fileDownloadedMessage); + }); + + it("Should execute import bookmarks action", async () => { + const importUrl = `https://myTeam.celonis.cloud/package-manager/api/packages/test-pkg/bookmarks/import`; + const payload = { entries: [] }; + writeJsonTempFile("module-test-import.json", payload); + mockAxiosPost(importUrl, { packageKey: "test-pkg", entries: [] }); + + const Module = require("../../../src/commands/bookmarks/module"); + const program = new Command(); + const configurator = new Configurator(program, testContext); + + const moduleInstance = new Module(); + moduleInstance.register(testContext, configurator); + + await program.parseAsync(["import", "bookmarks", "--packageKey", "test-pkg", "-f", "module-test-import.json"], { from: "user" }); + + expect(loggingTestTransport.logMessages).toHaveLength(1); + expect(loggingTestTransport.logMessages[0].message).toContain("Bookmarks imported successfully"); + }); +});