diff --git a/src/commands/configure.spec.ts b/src/commands/configure.spec.ts index 2591c163..ffac620d 100644 --- a/src/commands/configure.spec.ts +++ b/src/commands/configure.spec.ts @@ -30,7 +30,7 @@ describe('configure command', function() { jest.spyOn(fs, 'mkdirSync').mockReturnValueOnce(undefined); jest.spyOn(fs, 'writeFileSync').mockReturnValueOnce(undefined); - handler({ ...yargArgs, ...configFixture }); + handler({ ...yargArgs, ...configFixture, config: CONFIG_FILENAME() }); expect(fs.existsSync).toHaveBeenCalledWith(expect.stringMatching(/\.amplience$/)); expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringMatching(/\.amplience$/), { recursive: true }); @@ -48,7 +48,7 @@ describe('configure command', function() { jest.spyOn(fs, 'mkdirSync'); jest.spyOn(fs, 'writeFileSync').mockReturnValueOnce(undefined); - handler({ ...yargArgs, ...configFixture }); + handler({ ...yargArgs, ...configFixture, config: CONFIG_FILENAME() }); expect(fs.existsSync).toHaveBeenCalledWith(expect.stringMatching(/\.amplience$/)); expect(fs.mkdirSync).not.toHaveBeenCalled(); @@ -58,6 +58,24 @@ describe('configure command', function() { ); }); + it('should write a config file and use the specified file', () => { + jest + .spyOn(fs, 'existsSync') + .mockReturnValueOnce(false) + .mockReturnValueOnce(false); + jest.spyOn(fs, 'mkdirSync').mockReturnValueOnce(undefined); + jest.spyOn(fs, 'writeFileSync').mockReturnValueOnce(undefined); + + handler({ ...yargArgs, ...configFixture, config: 'subdirectory/custom-config.json' }); + + expect(fs.existsSync).toHaveBeenCalledWith(expect.stringMatching(/subdirectory$/)); + expect(fs.mkdirSync).toHaveBeenCalledWith(expect.stringMatching(/subdirectory$/), { recursive: true }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringMatching(new RegExp('subdirectory/custom-config.json$')), + JSON.stringify(configFixture) + ); + }); + it('should report an error if its not possible to create the .amplience dir', () => { jest .spyOn(fs, 'existsSync') @@ -69,7 +87,7 @@ describe('configure command', function() { jest.spyOn(fs, 'writeFileSync').mockReturnValueOnce(undefined); expect(() => { - handler({ ...yargArgs, ...configFixture }); + handler({ ...yargArgs, ...configFixture, config: CONFIG_FILENAME() }); }).toThrowError(/^Unable to create dir ".*". Reason: .*/); expect(fs.existsSync).toHaveBeenCalledWith(expect.stringMatching(/\.amplience$/)); @@ -88,7 +106,7 @@ describe('configure command', function() { }); expect(() => { - handler({ ...yargArgs, ...configFixture }); + handler({ ...yargArgs, ...configFixture, config: CONFIG_FILENAME() }); }).toThrowError(/^Unable to write config file ".*". Reason: .*/); expect(fs.existsSync).toHaveBeenCalledWith(expect.stringMatching(/\.amplience$/)); @@ -104,7 +122,7 @@ describe('configure command', function() { jest.spyOn(fs, 'readFileSync').mockReturnValueOnce(JSON.stringify(configFixture)); jest.spyOn(fs, 'writeFileSync'); - handler({ ...yargArgs, ...configFixture }); + handler({ ...yargArgs, ...configFixture, config: CONFIG_FILENAME() }); expect(fs.writeFileSync).not.toHaveBeenCalled(); }); diff --git a/src/commands/configure.ts b/src/commands/configure.ts index f8546ba8..b59749aa 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -17,6 +17,10 @@ export type ConfigurationParameters = { hubId: string; }; +type ConfigArgument = { + config: string; +}; + export const configureCommandOptions: CommandOptions = { clientId: { type: 'string', demandOption: true }, clientSecret: { type: 'string', demandOption: true }, @@ -43,14 +47,14 @@ const writeConfigFile = (configFile: string, parameters: ConfigurationParameters export const readConfigFile = (configFile: string): object => fs.existsSync(configFile) ? JSON.parse(fs.readFileSync(configFile, 'utf-8')) : {}; -export const handler = (argv: Arguments): void => { +export const handler = (argv: Arguments): void => { const { clientId, clientSecret, hubId } = argv; - const storedConfig = readConfigFile(CONFIG_FILENAME()); + const storedConfig = readConfigFile(argv.config); if (isEqual(storedConfig, { clientId, clientSecret, hubId })) { console.log('Config file up-to-date. Please use `--help` for command usage.'); return; } - writeConfigFile(CONFIG_FILENAME(), { clientId, clientSecret, hubId }); + writeConfigFile(argv.config, { clientId, clientSecret, hubId }); console.log('Config file updated.'); }; diff --git a/src/commands/content-item/__mocks__/dependant-content-helper.ts b/src/commands/content-item/__mocks__/dependant-content-helper.ts index 23b1979e..c1dd3277 100644 --- a/src/commands/content-item/__mocks__/dependant-content-helper.ts +++ b/src/commands/content-item/__mocks__/dependant-content-helper.ts @@ -3,7 +3,8 @@ import { ContentDependancy } from '../../../common/content-item/content-dependan function dependancy(id: string): ContentDependancy { return { _meta: { - schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-link' + schema: 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-link', + name: 'content-link' }, contentType: 'https://dev-solutions.s3.amazonaws.com/DynamicContentTypes/Accelerators/blog.json', id: id diff --git a/src/commands/content-item/__snapshots__/tree.spec.ts.snap b/src/commands/content-item/__snapshots__/tree.spec.ts.snap new file mode 100644 index 00000000..0357a9bc --- /dev/null +++ b/src/commands/content-item/__snapshots__/tree.spec.ts.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`content-item tree command handler tests should detect and print circular dependencies with a double line indicator 1`] = ` +"=== LEVEL 2 (1) === +item6 +└─ item5 + +=== LEVEL 1 (3) === +item3 + +item7 + +=== CIRCULAR (3) === +item1 ═════════════════╗ +├─ item2 ║ +│ └─ item4 ║ +│ └─ *** (item1) ══╝ +└─ (item3) + +Finished. Circular Dependencies printed: 1" +`; + +exports[`content-item tree command handler tests should detect intertwined circular dependencies with multiple lines with different position 1`] = ` +"=== CIRCULAR (6) === +item5 ══════════════╗ +└─ item6 ║ + └─ *** (item5) ══╝ + +item1 ══════════════════════╗ +└─ item2 ═════════════════╗ ║ + └─ item3 ║ ║ + ├─ *** (item2) ═════╝ ║ + └─ item4 ║ + ├─ *** (item1) ════╝ + └─ (item5) + +Finished. Circular Dependencies printed: 2" +`; + +exports[`content-item tree command handler tests should print a single content item by itself 1`] = ` +"=== LEVEL 1 (1) === +item1 + +Finished. Circular Dependencies printed: 0" +`; + +exports[`content-item tree command handler tests should print a tree of content items 1`] = ` +"=== LEVEL 4 (1) === +item1 +├─ item2 +│ ├─ item4 +│ └─ item6 +│ └─ item5 +└─ item3 + +=== LEVEL 3 (1) === +=== LEVEL 2 (1) === +=== LEVEL 1 (3) === +Finished. Circular Dependencies printed: 0" +`; + +exports[`content-item tree command handler tests should print an error when invalid json is found 1`] = ` +"=== LEVEL 1 (1) === +item1 + +Finished. Circular Dependencies printed: 0" +`; + +exports[`content-item tree command handler tests should print multiple disjoint trees of content items 1`] = ` +"=== LEVEL 3 (1) === +item1 +├─ item2 +│ └─ item4 +└─ item3 + +=== LEVEL 2 (2) === +item6 +└─ item5 + +=== LEVEL 1 (4) === +item7 + +Finished. Circular Dependencies printed: 0" +`; + +exports[`content-item tree command handler tests should print nothing if no content is present 1`] = `"Finished. Circular Dependencies printed: 0"`; diff --git a/src/commands/content-item/import.ts b/src/commands/content-item/import.ts index ba8dbdda..6e37d4da 100644 --- a/src/commands/content-item/import.ts +++ b/src/commands/content-item/import.ts @@ -26,6 +26,7 @@ import { ItemContentDependancies, ContentDependancyInfo } from '../../common/content-item/content-dependancy-tree'; +import { Body } from '../../common/content-item/body'; import { AmplienceSchemaValidator, defaultSchemaLookup } from '../../common/content-item/amplience-schema-validator'; import { createLog, getDefaultLogPath } from '../../common/log-helpers'; @@ -675,12 +676,23 @@ const prepareContentForImport = async ( return tree; }; -const rewriteDependancy = (dep: ContentDependancyInfo, mapping: ContentMapping): void => { - const id = mapping.getContentItem(dep.dependancy.id) || dep.dependancy.id; +const rewriteDependancy = (dep: ContentDependancyInfo, mapping: ContentMapping, allowNull: boolean): void => { + let id = mapping.getContentItem(dep.dependancy.id); + + if (id == null && !allowNull) { + id = dep.dependancy.id; + } + if (dep.dependancy._meta.schema === '_hierarchy') { dep.owner.content.body._meta.hierarchy.parentId = id; - } else { - dep.dependancy.id = id; + } else if (dep.parent) { + const parent = dep.parent as Body; + if (id == null) { + delete parent[dep.index]; + } else { + parent[dep.index] = dep.dependancy; + dep.dependancy.id = id; + } } }; @@ -706,7 +718,7 @@ const importTree = async ( // Replace any dependancies with the existing mapping. item.dependancies.forEach(dep => { - rewriteDependancy(dep, mapping); + rewriteDependancy(dep, mapping, false); }); const originalId = content.id; @@ -781,7 +793,7 @@ const importTree = async ( const content = item.owner.content; item.dependancies.forEach(dep => { - rewriteDependancy(dep, mapping); + rewriteDependancy(dep, mapping, pass === 0); }); const originalId = content.id; @@ -815,6 +827,7 @@ const importTree = async ( newDependants[i] = newItem; mapping.registerContentItem(originalId as string, newItem.id as string); + mapping.registerContentItem(newItem.id as string, newItem.id as string); } else { if (itemShouldPublish(content) && (newItem.version != oldVersion || argv.republish)) { publishable.push({ item: newItem, node: item }); diff --git a/src/commands/content-item/tree.spec.ts b/src/commands/content-item/tree.spec.ts new file mode 100644 index 00000000..3afccf61 --- /dev/null +++ b/src/commands/content-item/tree.spec.ts @@ -0,0 +1,309 @@ +// Copy tests are rather simple since they most of the work is done by import/export. +// Unique features are revert, throwing when parameters are wrong/missing, +// and forwarding input parameters to both import and export. + +import { builder, command, handler, firstSecondThird, fillWhitespace, LOG_FILENAME } from './tree'; +import Yargs from 'yargs/yargs'; + +import { writeFile } from 'fs'; +import { join } from 'path'; +import { promisify } from 'util'; + +import { ensureDirectoryExists } from '../../common/import/directory-utils'; +import rmdir from 'rimraf'; +import { getDefaultLogPath } from '../../common/log-helpers'; + +import { ItemTemplate } from '../../common/dc-management-sdk-js/mock-content'; +import { dependsOn } from '../../commands/content-item/__mocks__/dependant-content-helper'; +import { ContentItem, Status } from 'dc-management-sdk-js'; + +jest.mock('../../services/dynamic-content-client-factory'); +jest.mock('../../common/log-helpers'); + +const consoleLogSpy = jest.spyOn(console, 'log'); +const consoleErrorSpy = jest.spyOn(console, 'error'); + +function rimraf(dir: string): Promise { + return new Promise((resolve): void => { + rmdir(dir, resolve); + }); +} + +describe('content-item tree command', () => { + afterEach((): void => { + jest.resetAllMocks(); + }); + + it('should command should defined', function() { + expect(command).toEqual('tree '); + }); + + describe('builder tests', function() { + it('should configure yargs', function() { + const argv = Yargs(process.argv.slice(2)); + const spyPositional = jest.spyOn(argv, 'positional').mockReturnThis(); + + builder(argv); + + expect(spyPositional).toHaveBeenCalledWith('dir', { + type: 'string', + describe: 'Path to the content items to build a tree from. Should be in the same format as an export.' + }); + }); + }); + + describe('firstSecondThird tests', function() { + it('should return 0 for the first item in a list, above size 1', () => { + expect(firstSecondThird(0, 2)).toEqual(0); + expect(firstSecondThird(0, 3)).toEqual(0); + expect(firstSecondThird(0, 4)).toEqual(0); + }); + + it('should return 2 for the last item in a list', () => { + expect(firstSecondThird(0, 1)).toEqual(2); + expect(firstSecondThird(1, 2)).toEqual(2); + expect(firstSecondThird(2, 3)).toEqual(2); + expect(firstSecondThird(3, 4)).toEqual(2); + }); + + it('should return 1 for any middle item in a list, above size 2', () => { + expect(firstSecondThird(1, 3)).toEqual(1); + expect(firstSecondThird(1, 4)).toEqual(1); + expect(firstSecondThird(2, 4)).toEqual(1); + }); + }); + + describe('fillWhitespace tests', function() { + it('should fill space characters only after the original string with the given character up to the length', () => { + expect(fillWhitespace(' ', ' ', '-', 4)).toEqual(' '); + expect(fillWhitespace(' ', ' ', '-', 8)).toEqual(' ----'); + }); + + it('should inherit non-space characters from the current string', () => { + expect(fillWhitespace(' ', ' char', '-', 4)).toEqual(' char'); + expect(fillWhitespace(' ', ' char', '-', 8)).toEqual(' char'); + expect(fillWhitespace(' ', ' c a ', '-', 8)).toEqual(' c-a-'); + expect(fillWhitespace(' ', ' h r', '-', 8)).toEqual(' -h-r'); + expect(fillWhitespace(' ', ' ', '-', 8)).toEqual(' ----'); + }); + }); + + describe('handler tests', function() { + const yargArgs = { + $0: 'test', + _: ['test'], + json: true + }; + const config = { + clientId: 'client-id', + clientSecret: 'client-id', + hubId: 'hub-id' + }; + + beforeAll(async () => { + await rimraf('temp/tree/'); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + const itemFromTemplate = (template: ItemTemplate): ContentItem => { + const item = new ContentItem({ + label: template.label, + status: template.status || Status.ACTIVE, + id: template.id || template.label, + folderId: null, + version: template.version, + lastPublishedVersion: template.lastPublishedVersion, + locale: template.locale, + body: { + ...template.body, + _meta: { + schema: template.typeSchemaUri + } + }, + + // Not meant to be here, but used later for sorting by repository + repoId: template.repoId + }); + + return item; + }; + + const createContent = async (basePath: string, template: ItemTemplate): Promise => { + await promisify(writeFile)( + join(basePath, template.label + '.json'), + JSON.stringify(itemFromTemplate(template).toJSON()) + ); + }; + + it('should use getDefaultLogPath for LOG_FILENAME with process.platform as default', function() { + LOG_FILENAME(); + + expect(getDefaultLogPath).toHaveBeenCalledWith('item', 'tree', process.platform); + }); + + it('should print nothing if no content is present', async () => { + await ensureDirectoryExists('temp/tree/empty'); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/empty' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + + it('should print a single content item by itself', async () => { + const basePath = 'temp/tree/single/repo1'; + await ensureDirectoryExists(basePath); + + await promisify(writeFile)(join(basePath, 'dummyFile.txt'), 'ignored'); + + await createContent(basePath, { + label: 'item1', + id: 'id1', + repoId: 'repo1', + body: {}, + typeSchemaUri: 'http://type.com' + }); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/single' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + + it('should print a tree of content items', async () => { + const basePath = 'temp/tree/multiple/repo1'; + await ensureDirectoryExists(basePath); + + const shared = { typeSchemaUri: 'http://type.com', repoId: 'repo1' }; + + await createContent(basePath, { label: 'item1', id: 'id1', body: dependsOn(['id2', 'id3']), ...shared }); + await createContent(basePath, { label: 'item2', id: 'id2', body: dependsOn(['id4', 'id6']), ...shared }); + await createContent(basePath, { label: 'item3', id: 'id3', body: {}, ...shared }); + await createContent(basePath, { label: 'item4', id: 'id4', body: {}, ...shared }); + await createContent(basePath, { label: 'item5', id: 'id5', body: {}, ...shared }); + await createContent(basePath, { label: 'item6', id: 'id6', body: dependsOn(['id5']), ...shared }); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/multiple' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + + it('should print multiple disjoint trees of content items', async () => { + const basePath = 'temp/tree/disjoint/repo1'; + await ensureDirectoryExists(basePath); + + const shared = { typeSchemaUri: 'http://type.com', repoId: 'repo1' }; + + await createContent(basePath, { label: 'item1', id: 'id1', body: dependsOn(['id2', 'id3']), ...shared }); + await createContent(basePath, { label: 'item2', id: 'id2', body: dependsOn(['id4']), ...shared }); + await createContent(basePath, { label: 'item3', id: 'id3', body: {}, ...shared }); + await createContent(basePath, { label: 'item4', id: 'id4', body: {}, ...shared }); + + await createContent(basePath, { label: 'item5', id: 'id5', body: {}, ...shared }); + await createContent(basePath, { label: 'item6', id: 'id6', body: dependsOn(['id5']), ...shared }); + + await createContent(basePath, { label: 'item7', id: 'id7', body: {}, ...shared }); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/disjoint' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + + it('should detect and print circular dependencies with a double line indicator', async () => { + const basePath = 'temp/tree/disjoint/repo1'; + await ensureDirectoryExists(basePath); + + const shared = { typeSchemaUri: 'http://type.com', repoId: 'repo1' }; + + await createContent(basePath, { label: 'item1', id: 'id1', body: dependsOn(['id2', 'id3']), ...shared }); + await createContent(basePath, { label: 'item2', id: 'id2', body: dependsOn(['id4']), ...shared }); + await createContent(basePath, { label: 'item3', id: 'id3', body: {}, ...shared }); + await createContent(basePath, { label: 'item4', id: 'id4', body: dependsOn(['id1']), ...shared }); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/disjoint' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + + it('should detect intertwined circular dependencies with multiple lines with different position', async () => { + const basePath = 'temp/tree/intertwine/repo1'; + await ensureDirectoryExists(basePath); + + const shared = { typeSchemaUri: 'http://type.com', repoId: 'repo1' }; + + await createContent(basePath, { label: 'item1', id: 'id1', body: dependsOn(['id2']), ...shared }); + await createContent(basePath, { label: 'item2', id: 'id2', body: dependsOn(['id3']), ...shared }); + await createContent(basePath, { label: 'item3', id: 'id3', body: dependsOn(['id2', 'id4']), ...shared }); + await createContent(basePath, { label: 'item4', id: 'id4', body: dependsOn(['id1', 'id5']), ...shared }); + + await createContent(basePath, { label: 'item5', id: 'id5', body: dependsOn(['id6']), ...shared }); + await createContent(basePath, { label: 'item6', id: 'id6', body: dependsOn(['id5']), ...shared }); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/intertwine' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + }); + + it('should print an error when invalid json is found', async () => { + const basePath = 'temp/tree/invalud/repo1'; + await ensureDirectoryExists(basePath); + + await createContent(basePath, { + label: 'item1', + id: 'id1', + repoId: 'repo1', + body: {}, + typeSchemaUri: 'http://type.com' + }); + await promisify(writeFile)(join(basePath, 'badfile.json'), 'not json'); + + const argv = { + ...yargArgs, + ...config, + dir: 'temp/tree/invalud' + }; + + await handler(argv); + + expect(consoleLogSpy.mock.calls.map(args => args[0]).join('\n')).toMatchSnapshot(); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/commands/content-item/tree.ts b/src/commands/content-item/tree.ts new file mode 100644 index 00000000..79380bc9 --- /dev/null +++ b/src/commands/content-item/tree.ts @@ -0,0 +1,236 @@ +import { getDefaultLogPath } from '../../common/log-helpers'; +import { Argv, Arguments } from 'yargs'; +import { join, extname, resolve } from 'path'; +import { ConfigurationParameters } from '../configure'; +import { lstat, readdir, readFile } from 'fs'; +import { promisify } from 'util'; + +import { ContentItem, ContentRepository } from 'dc-management-sdk-js'; +import { + ContentDependancyTree, + ItemContentDependancies, + RepositoryContentItem +} from '../../common/content-item/content-dependancy-tree'; +import { ContentMapping } from '../../common/content-item/content-mapping'; + +export const command = 'tree '; + +export const desc = 'Print a content dependency tree from content in the given folder.'; + +export const LOG_FILENAME = (platform: string = process.platform): string => + getDefaultLogPath('item', 'tree', platform); + +export const builder = (yargs: Argv): void => { + yargs.positional('dir', { + type: 'string', + describe: 'Path to the content items to build a tree from. Should be in the same format as an export.' + }); +}; + +interface TreeOptions { + dir: string; +} + +export const traverseRecursive = async (path: string, action: (path: string) => Promise): Promise => { + const dir = await promisify(readdir)(path); + + dir.sort(); + + for (let i = 0; i < dir.length; i++) { + let contained = dir[i]; + contained = join(path, contained); + const stat = await promisify(lstat)(contained); + if (stat.isDirectory()) { + await traverseRecursive(contained, action); + } else { + await action(contained); + } + } +}; + +export const prepareContentForTree = async (repo: { + basePath: string; + repo: ContentRepository; +}): Promise => { + const contentItems: RepositoryContentItem[] = []; + const schemaNames = new Set(); + + await traverseRecursive(resolve(repo.basePath), async path => { + // Is this valid content? Must have extension .json to be considered, for a start. + if (extname(path) !== '.json') { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let contentJSON: any; + try { + const contentText = await promisify(readFile)(path, { encoding: 'utf8' }); + contentJSON = JSON.parse(contentText); + } catch (e) { + console.error(`Couldn't read content item at '${path}': ${e.toString()}`); + return; + } + + schemaNames.add(contentJSON.body._meta.schema); + + contentItems.push({ repo: repo.repo, content: new ContentItem(contentJSON) }); + }); + + return new ContentDependancyTree(contentItems, new ContentMapping()); +}; + +type LineIndexFrom = number; +type LineIndexTo = number; +type CircularLink = [LineIndexFrom, LineIndexTo]; +interface ParentReference { + item: ItemContentDependancies; + line: number; +} + +export const firstSecondThird = (index: number, total: number): number => { + return index == total - 1 ? 2 : index === 0 ? 0 : 1; +}; + +const fstPipes = ['├', '├', '└']; +const circularPipes = ['╗', '║', '╝']; +const circularLine = '═'; + +export class TreeBuilder { + lines: string[] = []; + circularLinks: CircularLink[] = []; + + constructor(public evaluated: Set) {} + + addDependency(item: ItemContentDependancies, evalThis: ParentReference[], fst: number, prefix: string): boolean { + const depth = evalThis.length - 1; + const pipe = depth < 0 ? '' : fstPipes[fst] + '─ '; + + const circularMatch = evalThis.find(parent => parent.item == item); + if (circularMatch) { + this.lines.push(`${prefix}${pipe}*** (${item.owner.content.label})`); + this.circularLinks.push([circularMatch.line, this.lines.length - 1]); + return false; + } else if (this.evaluated.has(item)) { + if (depth > -1) { + this.lines.push(`${prefix}${pipe}(${item.owner.content.label})`); + } + return false; + } else { + this.lines.push(`${prefix}${pipe}${item.owner.content.label}`); + } + + evalThis.push({ item, line: this.lines.length - 1 }); + this.evaluated.add(item); + + const filteredItems = item.dependancies.filter(dep => dep.resolved); + filteredItems.forEach((dep, index) => { + const subFst = firstSecondThird(index, filteredItems.length); + const subPrefix = depth == -1 ? '' : fst === 2 ? ' ' : '│ '; + this.addDependency(dep.resolved as ItemContentDependancies, [...evalThis], subFst, prefix + subPrefix); + }); + return true; + } +} + +export const fillWhitespace = (original: string, current: string, char: string, targetLength: number): string => { + let position = original.length; + let repeats = targetLength - original.length; + + // Replace existing whitespace characters + while (position < current.length && repeats > 0) { + if (current[position] != char && current[position] == ' ') { + current = current.slice(0, position) + char + current.slice(position + 1); + } + + position++; + repeats--; + } + + if (repeats > 0) { + current += char.repeat(repeats); + } + + return current; +}; + +export const printTree = (item: ItemContentDependancies, evaluated: Set): boolean => { + const builder = new TreeBuilder(evaluated); + + const result = builder.addDependency(item, [], 0, ''); + + if (!result) return false; + + const circularLinks = builder.circularLinks; + const lines = builder.lines.map(line => line + ' '); + const modifiedLines = [...lines]; + + // Render circular references. + // These are drawn as pipes on the right hand side, from a start line to an end line. + + const maxWidth = Math.max(...lines.map(x => x.length)); + + for (let i = 0; i < circularLinks.length; i++) { + const link = circularLinks[i]; + let linkDist = maxWidth + 2; + + // Find overlapping circular links. Push the link out further if a previously drawn line is there. + for (let j = 0; j < i; j++) { + const link2 = circularLinks[j]; + if (link[0] <= link2[1] && link[1] >= link2[0]) { + linkDist += 2; + } + } + + // Write the circular dependency lines into the tree. + + for (let ln = link[0]; ln <= link[1]; ln++) { + const end = ln == link[0] || ln == link[1]; + const original = lines[ln]; + let current = modifiedLines[ln]; + + current = fillWhitespace(original, current, end ? circularLine : ' ', linkDist); + current += circularPipes[firstSecondThird(ln - link[0], link[1] - link[0] + 1)]; + + modifiedLines[ln] = current; + } + } + + modifiedLines.forEach(line => console.log(line)); + console.log(''); + return true; +}; + +export const handler = async (argv: Arguments): Promise => { + const dir = argv.dir; + + const tree = await prepareContentForTree({ basePath: dir, repo: new ContentRepository() }); + + // Print the items in the tree. + // Keep a set of all items that have already been printed. + // Starting at the highest level, print all dependencies on the tree. + + const evaluated = new Set(); + + for (let i = tree.levels.length - 1; i >= 0; i--) { + const level = tree.levels[i]; + console.log(`=== LEVEL ${i + 1} (${level.items.length}) ===`); + + level.items.forEach(item => { + printTree(item, evaluated); + }); + } + + let topLevelPrints = 0; + + if (tree.circularLinks.length > 0) { + console.log(`=== CIRCULAR (${tree.circularLinks.length}) ===`); + + tree.circularLinks.forEach(item => { + if (printTree(item, evaluated)) { + topLevelPrints++; + } + }); + } + + console.log(`Finished. Circular Dependencies printed: ${topLevelPrints}`); +}; diff --git a/src/common/content-item/content-dependancy-tree.ts b/src/common/content-item/content-dependancy-tree.ts index 62ee583b..4ddb825a 100644 --- a/src/common/content-item/content-dependancy-tree.ts +++ b/src/common/content-item/content-dependancy-tree.ts @@ -13,7 +13,7 @@ export interface RepositoryContentItem { } export interface ContentDependancy { - _meta: { schema: DependancyContentTypeSchema }; + _meta: { schema: DependancyContentTypeSchema; name: string }; contentType: string; id: string | undefined; } @@ -22,6 +22,9 @@ export interface ContentDependancyInfo { resolved?: ItemContentDependancies; dependancy: ContentDependancy; owner: RepositoryContentItem; + + parent?: RecursiveSearchStep; + index: string | number; } export interface ItemContentDependancies { @@ -39,6 +42,12 @@ export const referenceTypes = [ 'http://bigcontent.io/cms/schema/v1/core#/definitions/content-reference' ]; +enum CircularDependencyStage { + Standalone = 0, + Intertwined, + Parent +} + type RecursiveSearchStep = Body | ContentDependancy | Array; export class ContentDependancyTree { @@ -96,8 +105,40 @@ export class ContentDependancyTree { // Remaining items in the info array are connected to circular dependancies, so must be resolved via rewriting. + // Create dependency layers for circular dependencies + + const circularStages: ItemContentDependancies[][] = []; + while (unresolvedCount > 0) { + const stage: ItemContentDependancies[] = []; + + // To be in this stage, the circular dependency must contain no other circular dependencies (before self-loop). + // The circular dependencies that appear before self loop are + const lastUnresolvedCount = unresolvedCount; + const circularLevels = info.map(item => this.topLevelCircular(item, info)); + + const chosenLevel = Math.min(...circularLevels) as CircularDependencyStage; + + for (let i = 0; i < info.length; i++) { + const item = info[i]; + if (circularLevels[i] === chosenLevel) { + stage.push(item); + circularLevels.splice(i, 1); + info.splice(i--, 1); + } + } + + unresolvedCount = info.length; + if (unresolvedCount === lastUnresolvedCount) { + break; + } + + circularStages.push(stage); + } + this.levels = stages; - this.circularLinks = info; + this.circularLinks = []; + circularStages.forEach(stage => this.circularLinks.push(...stage)); + this.all = allInfo; this.byId = new Map(allInfo.map(info => [info.owner.content.id as string, info])); this.requiredSchema = Array.from(requiredSchema); @@ -106,11 +147,13 @@ export class ContentDependancyTree { private searchObjectForContentDependancies( item: RepositoryContentItem, body: RecursiveSearchStep, - result: ContentDependancyInfo[] + result: ContentDependancyInfo[], + parent: RecursiveSearchStep | undefined, + index: string | number ): void { if (Array.isArray(body)) { - body.forEach(contained => { - this.searchObjectForContentDependancies(item, contained, result); + body.forEach((contained, index) => { + this.searchObjectForContentDependancies(item, contained, result, body, index); }); } else if (body != null) { const allPropertyNames = Object.getOwnPropertyNames(body); @@ -121,14 +164,14 @@ export class ContentDependancyTree { typeof body.contentType === 'string' && typeof body.id === 'string' ) { - result.push({ dependancy: body as ContentDependancy, owner: item }); + result.push({ dependancy: body as ContentDependancy, owner: item, parent, index }); return; } allPropertyNames.forEach(propName => { const prop = (body as Body)[propName]; if (typeof prop === 'object') { - this.searchObjectForContentDependancies(item, prop, result); + this.searchObjectForContentDependancies(item, prop, result, body, propName); } }); } @@ -161,22 +204,99 @@ export class ContentDependancyTree { } } + private topLevelCircular( + top: ItemContentDependancies, + unresolved: ItemContentDependancies[] + ): CircularDependencyStage { + let selfLoop = false; + let intertwinedLoop = false; + let isParent = false; + const seenBefore = new Set(); + + const traverse = ( + top: ItemContentDependancies, + item: ItemContentDependancies | undefined, + depth: number, + unresolved: ItemContentDependancies[], + seenBefore: Set, + intertwined: boolean + ): boolean => { + let hasCircular = false; + + if (item == null) { + return false; + } else if (top === item && depth > 0) { + selfLoop = true; + return false; + } else if (top !== item && unresolved.indexOf(item) !== -1) { + // Contains a circular dependency. + + if (!intertwined) { + // Does it loop back to the parent? + const storedSelfLoop = selfLoop; + const childIntertwined = traverse(item, item, 0, [top], new Set(), true); + selfLoop = storedSelfLoop; + + if (childIntertwined) { + intertwinedLoop = true; + } else { + // We're the parent of a non-intertwined circular loop. + isParent = true; + } + } + + hasCircular = true; + } + + if (seenBefore.has(item)) { + return false; + } + + seenBefore.add(item); + + item.dependancies.forEach(dep => { + hasCircular = traverse(top, dep.resolved, depth + 1, unresolved, seenBefore, intertwined) || hasCircular; + }); + + return hasCircular; + }; + + const hasCircular = traverse(top, top, 0, unresolved, seenBefore, false); + + if (hasCircular) { + if (intertwinedLoop) { + if (selfLoop && !isParent) { + return CircularDependencyStage.Intertwined; + } else { + return CircularDependencyStage.Parent; + } + } else { + return CircularDependencyStage.Parent; + } + } else { + return CircularDependencyStage.Standalone; + } + } + private identifyContentDependancies(items: RepositoryContentItem[]): ItemContentDependancies[] { return items.map(item => { const result: ContentDependancyInfo[] = []; - this.searchObjectForContentDependancies(item, item.content.body, result); + this.searchObjectForContentDependancies(item, item.content.body, result, undefined, 0); // Hierarchy parent is also a dependancy. if (item.content.body._meta.hierarchy && item.content.body._meta.hierarchy.parentId) { result.push({ dependancy: { _meta: { - schema: '_hierarchy' + schema: '_hierarchy', + name: '_hierarchy' }, id: item.content.body._meta.hierarchy.parentId, contentType: '' }, - owner: item + owner: item, + parent: undefined, + index: 0 }); } @@ -198,7 +318,13 @@ export class ContentDependancyTree { const target = idMap.get(dep.dependancy.id as string); dep.resolved = target; if (target) { - target.dependants.push({ owner: target.owner, resolved: item, dependancy: dep.dependancy }); + target.dependants.push({ + owner: target.owner, + resolved: item, + dependancy: dep.dependancy, + parent: dep.parent, + index: dep.index + }); resolve(target); } });