diff --git a/package.json b/package.json index 0b63cf4..1cbc6b1 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "wip-backup-manager", "version": "0.0.1", "description": "This utility enables backup & restore of Kentico Kontent projects", + "preferGlobal": true, "bin": { "kbm": "./_commonjs/src/cli/app.js" }, @@ -34,7 +35,8 @@ "Kentico Kontent", "Kontent backup manager", "Kontent restore project", - "Kontent backup project" + "Kontent backup project", + "Kontent import" ], "license": "MIT", "dependencies": { diff --git a/src/cli/app.ts b/src/cli/app.ts index 9a75249..c3c0146 100644 --- a/src/cli/app.ts +++ b/src/cli/app.ts @@ -4,9 +4,10 @@ import yargs = require('yargs'); import { CleanService } from '../clean'; import { ICliFileConfig } from '../core'; -import { ExportService } from '../export'; -import { ImportService } from '../import'; +import { ExportService, IExportAllResult } from '../export'; +import { IImportSource, ImportService } from '../import'; import { ZipService } from '../zip'; +import { ProjectContracts } from '@kentico/kontent-management'; const argv = yargs.argv; @@ -33,9 +34,27 @@ const backup = async (config: ICliFileConfig) => { enableLog: config.enableLog }); - const response = await exportService.exportAllAsync(); + const report = await exportService.exportProjectValidationAsync(); - await zipService.createZipAsync(response.data, response.metadata); + if (canExport(report, config)) { + const response = await exportService.exportAllAsync(); + await zipService.createZipAsync(response); + } else { + console.log(`Project contains following inconsistencies:`); + for (const issue of report.type_issues) { + console.log(`Type ${issue.type.codename} has issues: ${issue.issues.map(m => m.messages).join(',')}`); + } + for (const issue of report.variant_issues) { + console.log( + `Variant ${issue.item.codename} (${issue.language.codename}) has issues: ${issue.issues + .map(m => m.messages) + .join(',')}` + ); + } + + console.log(`To export data regardless of issues, set 'force' config parameter to true`); + console.log(`Export failed. See reasons above`); + } }; const clean = async (config: ICliFileConfig) => { @@ -58,8 +77,6 @@ const restore = async (config: ICliFileConfig) => { enableLog: config.enableLog }); - const data = await zipService.extractZipAsync(); - const importService = new ImportService({ onImport: item => { if (config.enableLog) { @@ -78,7 +95,27 @@ const restore = async (config: ICliFileConfig) => { } }); - await importService.importFromSourceAsync(data); + const data = await zipService.extractZipAsync(); + + if (canImport(data, config)) { + await importService.importFromSourceAsync(data); + } else { + console.log(`Project contains following inconsistencies:`); + for (const issue of data.validation.type_issues) { + console.log(`Type ${issue.type.codename} has issues: ${issue.issues.map(m => m.messages).join(',')}`); + } + for (const issue of data.validation.variant_issues) { + console.log( + `Variant ${issue.item.codename} (${issue.language.codename}) has issues: ${issue.issues + .map(m => m.messages) + .join(',')}` + ); + } + + console.log(`To import data regardless of issues, set 'force' config parameter to true, however keep in mind that without + fixing the issues, the import will likely fail.`); + console.log(`Import failed. See reasons above`); + } }; const validateConfig = (config: any) => { @@ -121,4 +158,29 @@ const process = async () => { } }; +const canExport = (projectReport: ProjectContracts.IProjectReportResponseContract, config: ICliFileConfig) => { + const projectHasIssues = projectReport.variant_issues.length > 0 || projectReport.type_issues.length > 0; + if (!projectHasIssues) { + return true; + } + + if (config.force === true) { + return true; + } + + return false; +}; + +const canImport = (importData: IImportSource, config: ICliFileConfig) => { + if (!importData.metadata.isInconsistentExport) { + return true; + } + + if (config.force === true) { + return true; + } + + return false; +}; + process(); diff --git a/src/cli/sample-config.json b/src/cli/sample-config.json index 3698e64..6e5b7fe 100644 --- a/src/cli/sample-config.json +++ b/src/cli/sample-config.json @@ -4,5 +4,6 @@ "zipFilename": "backup", "action": "backup", "enableLog": true, - "importLanguages": false + "importLanguages": false, + "force": false } diff --git a/src/core/core.models.ts b/src/core/core.models.ts index 08be439..7939f07 100644 --- a/src/core/core.models.ts +++ b/src/core/core.models.ts @@ -24,6 +24,7 @@ export interface ICliFileConfig { zipFilename: string; enableLog: boolean; importLanguages: boolean; + force: boolean; } export type CliAction = 'backup' | 'restore' | 'clean'; diff --git a/src/export/export.models.ts b/src/export/export.models.ts index edef681..12eb598 100644 --- a/src/export/export.models.ts +++ b/src/export/export.models.ts @@ -7,6 +7,7 @@ import { LanguageContracts, LanguageVariantContracts, TaxonomyContracts, + ProjectContracts, } from '@kentico/kontent-management'; import { IProcessedItem } from '../core'; @@ -31,9 +32,11 @@ export interface IExportData { export interface IExportMetadata { projectId: string; timestamp: Date; + isInconsistentExport: boolean; } export interface IExportAllResult { metadata: IExportMetadata; data: IExportData; + validation: ProjectContracts.IProjectReportResponseContract; } diff --git a/src/export/export.service.ts b/src/export/export.service.ts index e5f8ba3..a3386e6 100644 --- a/src/export/export.service.ts +++ b/src/export/export.service.ts @@ -9,6 +9,7 @@ import { AssetContracts, LanguageContracts, AssetFolderContracts, + ProjectContracts, } from '@kentico/kontent-management'; import { IExportAllResult, IExportConfig, IExportData } from './export.models'; @@ -26,6 +27,7 @@ export class ExportService { public async exportAllAsync(): Promise { const contentTypes = await this.exportContentTypesAsync(); + const projectValidation = await this.exportProjectValidationAsync(); const data: IExportData = { contentTypes, @@ -41,12 +43,22 @@ export class ExportService { return { metadata: { timestamp: new Date(), - projectId: this.config.projectId + projectId: this.config.projectId, + isInconsistentExport: projectValidation.type_issues.length > 0 || projectValidation.variant_issues.length > 0 }, + validation: projectValidation, data }; } + public async exportProjectValidationAsync(): Promise { + const response = await this.client.validateProjectContent() + .forProjectId(this.config.projectId) + .toPromise(); + + return response.rawData; + } + public async exportAssetsAsync(): Promise { const response = await this.client.listAssets().toAllPromise(); response.data.items.forEach(m => this.processItem(m.fileName, 'asset', m)); diff --git a/src/import/import.models.ts b/src/import/import.models.ts index f61ec92..e9c7202 100644 --- a/src/import/import.models.ts +++ b/src/import/import.models.ts @@ -7,6 +7,7 @@ import { LanguageContracts, LanguageVariantContracts, TaxonomyContracts, + ProjectContracts, } from '@kentico/kontent-management'; import { IProcessedItem, ItemType } from '../core'; @@ -61,6 +62,8 @@ export interface IImportSource { languages: LanguageContracts.ILanguageModelContract[]; assets: AssetContracts.IAssetModelContract[]; }; + metadata: IImportMetadata; + validation: ProjectContracts.IProjectReportResponseContract; assetFolders: AssetFolderContracts.IAssetFolderContract[]; binaryFiles: IBinaryFile[]; } @@ -76,3 +79,9 @@ export interface IFlattenedFolder { externalId?: string; id: string; } + +export interface IImportMetadata { + projectId: string; + timestamp: Date; + isInconsistentExport: boolean; +} \ No newline at end of file diff --git a/src/import/import.service.ts b/src/import/import.service.ts index c7a073a..acdefe8 100644 --- a/src/import/import.service.ts +++ b/src/import/import.service.ts @@ -97,6 +97,10 @@ export class ImportService { const importedTaxonomies = await this.importTaxonomiesAsync(sourceData.importData.taxonomies); importedItems.push(...importedTaxonomies); + // ### Dummy types & snippets + await this.importDummyContentTypeSnippetsAsync(sourceData.importData.contentTypeSnippets); + await this.importDummyContentTypesAsync(sourceData.importData.contentTypes); + // ### Content type snippets const importedContentTypeSnippets = await this.importContentTypeSnippetsAsync( sourceData.importData.contentTypeSnippets @@ -383,18 +387,18 @@ export class ImportService { >[] = []; for (const contentType of contentTypes) { - // first create dummy types to handle circular references between types & types that reference - // not yet processed ones - const createdContentType = await this.client - .addContentType() - .withData(builder => { - return { - elements: [], - name: contentType.name, - codename: contentType.codename, - content_groups: [] - }; - }) + await this.client + .modifyContentType() + .byTypeCodename(contentType.codename) + .withData( + contentType.elements.map(element => { + return { + op: 'addInto', + value: element, + path: '/elements' + }; + }) + ) .toPromise() .then(response => { importedItems.push({ @@ -403,26 +407,35 @@ export class ImportService { importId: response.data.id, originalId: contentType.id }); - this.processItem(response.data.name, 'dummyContentType', response.data); + this.processItem(response.data.name, 'contentType', response.data); }) .catch(error => this.handleImportError(error)); } - // once dummy content types are created, add elements + return importedItems; + } + + private async importDummyContentTypesAsync( + contentTypes: ContentTypeContracts.IContentTypeContract[] + ): Promise[]> { + const importedItems: IImportItemResult< + ContentTypeContracts.IContentTypeContract, + ContentTypeModels.ContentType + >[] = []; for (const contentType of contentTypes) { - await this.client - .modifyContentType() - .byTypeCodename(contentType.codename) - .withData( - contentType.elements.map(element => { - return { - op: 'addInto', - value: element, - path: '/elements' - }; - }) - ) + // first create dummy types to handle circular references between types & types that reference + // not yet processed ones + const createdContentType = await this.client + .addContentType() + .withData(builder => { + return { + elements: [], + name: contentType.name, + codename: contentType.codename, + content_groups: [] + }; + }) .toPromise() .then(response => { importedItems.push({ @@ -431,7 +444,7 @@ export class ImportService { importId: response.data.id, originalId: contentType.id }); - this.processItem(response.data.name, 'contentType', response.data); + this.processItem(response.data.name, 'dummyContentType', response.data); }) .catch(error => this.handleImportError(error)); } @@ -541,18 +554,18 @@ export class ImportService { >[] = []; for (const contentTypeSnippet of contentTypeSnippets) { - // first create dummy types to handle circular references between types & types that reference - // not yet processed ones - const createdContentTypeSnippet = await this.client - .addContentTypeSnippet() - .withData(builder => { - return { - elements: [], - name: contentTypeSnippet.name, - codename: contentTypeSnippet.codename, - content_groups: [] - }; - }) + await this.client + .modifyContentTypeSnippet() + .byTypeCodename(contentTypeSnippet.codename) + .withData( + contentTypeSnippet.elements.map(element => { + return { + op: 'addInto', + value: element, + path: '/elements' + }; + }) + ) .toPromise() .then(response => { importedItems.push({ @@ -561,26 +574,35 @@ export class ImportService { importId: response.data.id, originalId: contentTypeSnippet.id }); - this.processItem(response.data.name, 'dummyContentTypeSnippet', response.data); + this.processItem(response.data.name, 'contentTypeSnippet', response.data); }) .catch(error => this.handleImportError(error)); } - // once dummy content types are created, add elements + return importedItems; + } + + private async importDummyContentTypeSnippetsAsync( + contentTypeSnippets: ContentTypeSnippetContracts.IContentTypeSnippetContract[] + ): Promise[]> { + const importedItems: IImportItemResult< + ContentTypeContracts.IContentTypeContract, + ContentTypeModels.ContentType + >[] = []; for (const contentTypeSnippet of contentTypeSnippets) { - await this.client - .modifyContentTypeSnippet() - .byTypeCodename(contentTypeSnippet.codename) - .withData( - contentTypeSnippet.elements.map(element => { - return { - op: 'addInto', - value: element, - path: '/elements' - }; - }) - ) + // first create dummy types to handle circular references between types & types that reference + // not yet processed ones + const createdContentTypeSnippet = await this.client + .addContentTypeSnippet() + .withData(builder => { + return { + elements: [], + name: contentTypeSnippet.name, + codename: contentTypeSnippet.codename, + content_groups: [] + }; + }) .toPromise() .then(response => { importedItems.push({ @@ -589,7 +611,7 @@ export class ImportService { importId: response.data.id, originalId: contentTypeSnippet.id }); - this.processItem(response.data.name, 'contentTypeSnippet', response.data); + this.processItem(response.data.name, 'dummyContentTypeSnippet', response.data); }) .catch(error => this.handleImportError(error)); } diff --git a/src/zip/zip.service.ts b/src/zip/zip.service.ts index b5938da..4f6db20 100644 --- a/src/zip/zip.service.ts +++ b/src/zip/zip.service.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import { get } from 'https'; import JSZip = require('jszip'); -import { IExportData, IExportMetadata } from '../export'; +import { IExportData, IExportMetadata, IExportAllResult } from '../export'; import { IBinaryFile, IImportSource } from '../import'; import { IZipServiceConfig } from './zip.models'; @@ -20,6 +20,7 @@ export class ZipService { private readonly languages: string = 'languages.json'; private readonly filesName: string = 'files.json'; private readonly assetFoldersName: string = 'assetFolders.json'; + private readonly validationName: string = 'validation.json'; private readonly filenameWithExtension: string; @@ -55,7 +56,9 @@ export class ZipService { taxonomies: await this.readAndParseJsonFile(unzippedFile, this.taxonomiesName), }, assetFolders: await this.readAndParseJsonFile(unzippedFile, this.assetFoldersName), - binaryFiles: await this.extractBinaryFilesAsync(unzippedFile, assets) + binaryFiles: await this.extractBinaryFilesAsync(unzippedFile, assets), + validation: await this.readAndParseJsonFile(unzippedFile, this.validationName), + metadata: await this.readAndParseJsonFile(unzippedFile, this.metadataName), }; if (this.config.enableLog) { @@ -65,22 +68,23 @@ export class ZipService { return result; } - public async createZipAsync(exportData: IExportData, metadata: IExportMetadata): Promise { + public async createZipAsync(exportData: IExportAllResult): Promise { const zip = new JSZip(); if (this.config.enableLog) { console.log(`Parsing json`); } - zip.file(this.contentTypesName, JSON.stringify(exportData.contentTypes)); - zip.file(this.contentItemsName, JSON.stringify(exportData.contentItems)); - zip.file(this.taxonomiesName, JSON.stringify(exportData.taxonomies)); - zip.file(this.assetsName, JSON.stringify(exportData.assets)); - zip.file(this.languageVariantsName, JSON.stringify(exportData.languageVariants)); - zip.file(this.metadataName, JSON.stringify(metadata)); - zip.file(this.languages, JSON.stringify(exportData.languages)); - zip.file(this.contentTypeSnippetsName, JSON.stringify(exportData.contentTypeSnippets)); - zip.file(this.assetFoldersName, JSON.stringify(exportData.assetFolders)); + zip.file(this.contentTypesName, JSON.stringify(exportData.data.contentTypes)); + zip.file(this.validationName, JSON.stringify(exportData.validation)); + zip.file(this.contentItemsName, JSON.stringify(exportData.data.contentItems)); + zip.file(this.taxonomiesName, JSON.stringify(exportData.data.taxonomies)); + zip.file(this.assetsName, JSON.stringify(exportData.data.assets)); + zip.file(this.languageVariantsName, JSON.stringify(exportData.data.languageVariants)); + zip.file(this.metadataName, JSON.stringify(exportData.metadata)); + zip.file(this.languages, JSON.stringify(exportData.data.languages)); + zip.file(this.contentTypeSnippetsName, JSON.stringify(exportData.data.contentTypeSnippets)); + zip.file(this.assetFoldersName, JSON.stringify(exportData.data.assetFolders)); const assetsFolder = zip.folder(this.filesName); @@ -88,7 +92,7 @@ export class ZipService { console.log(`Adding assets to zip`); } - for (const asset of exportData.assets) { + for (const asset of exportData.data.assets) { const assetIdShortFolder = assetsFolder.folder(asset.id.substr(0, 3)); const assetIdFolder = assetIdShortFolder.folder(asset.id); const assetFilename = asset.file_name;