/* Copyright IBM Corp. 2017 */ import { toWordsOrdinal } from 'number-to-words'; import { join } from 'path'; import { concat, defer, empty, from, merge, Observable, of, range, Subject } from 'rxjs'; import { catchError, filter, map, mergeMap, toArray } from 'rxjs/operators'; import { isString } from 'util'; import { v4 } from 'uuid'; import { getDefaultLayout } from '../utils/layout.mapping.utils'; import { CMD_CREATE } from './../constants'; import { LayoutNameMapper, Options } from './../options'; import { canonicalizeJSON } from './../utils/json'; import { addRevisionId } from './../utils/revision.utils'; import { rxCopyFile, rxMkdirp, rxStats, writeFile, writeFileIfChanged } from './../utils/rx.walk'; import { camelCase, kebabCase } from './../utils/transliteration'; import { Matcher, WchTools, wchToolsGetMatcher } from './../utils/wchtools'; import { AbstractCommand } from './abstract.command'; import { Command } from './command'; const TYPE_SUFFIX = 'Type'; const LAYOUT_SUFFIX = 'Layout'; const MAPPING_SUFFIX = 'Mapping'; declare type TypeMatcher = (aType: any) => boolean; interface WchToolsInfo { tools: WchTools; layoutsByName: { [name: string]: any }; mappingByType: { [name: string]: any }; typeByName: { [name: string]: any }; } function _niceStringify(aData: any): string { return JSON.stringify(aData, null, 2); } function _createWchToolsInfo(aTools: WchTools): WchToolsInfo { const result: WchToolsInfo = { tools: aTools, layoutsByName: {}, mappingByType: {}, typeByName: {} }; const layoutsByName = result.layoutsByName; const mappingByType = result.mappingByType; const typeByName = result.typeByName; // tslint:disable-next-line:forin for (const id in aTools.types) { // register const type = aTools.types[id]; typeByName[type.name] = type; } // tslint:disable-next-line:forin for (const id in aTools.layouts) { // register const layout = aTools.layouts[id]; layoutsByName[layout.name] = layout; } // tslint:disable-next-line:forin for (const id in aTools.layoutMappings) { // register const mapping = aTools.layoutMappings[id]; mappingByType[mapping.type.id] = mapping; } return result; } function _createDefaultLayoutNameMapper(aOptions: Options, aTools: WchToolsInfo): LayoutNameMapper { return (aNumber: number, aTotal: number, aTypeName: string) => { // if we only have one type if (aTotal === 1) { // check if we already know a layout for the type const type = aTools.typeByName[aTypeName]; if (type) { const typeId = type.id; const existingMapping = aTools.mappingByType[typeId]; if (existingMapping) { // get the default layout from the mapping const defaultLayout = getDefaultLayout(existingMapping, aTools.tools); if (defaultLayout && isString(defaultLayout.name)) { // returns that layout name return defaultLayout.name; } } } } // add the ordinal number const prefix = (aTotal > 1) ? toWordsOrdinal(aNumber + 1) : ''; // construct layout name from type name let typeName = camelCase(prefix + ' ' + aTypeName); if (typeName.endsWith(TYPE_SUFFIX)) { typeName = typeName.substr(0, typeName.length - TYPE_SUFFIX.length); } // augment return typeName + LAYOUT_SUFFIX; }; } function _createLayoutMappingName(aLayoutName: string): string { // construct layout name from type name return aLayoutName + MAPPING_SUFFIX; } function _getUniqueFileNameCount(aCount: number, aPath: string, aID: string): Observable { // try if the default name would exist const path = join(aPath, `${aID}-${aCount}.json`); return rxStats(path).pipe( mergeMap(stat => stat.isFile() ? _getUniqueFileNameCount(aCount + 1, aPath, aID) : of(path)), catchError(() => of(path)) ); } function _getUniqueFileName(aPath: string, aID: string): Observable { // try if the default name would exist const path = join(aPath, `${aID}.json`); return rxStats(path).pipe( mergeMap(stat => stat.isFile() ? _getUniqueFileNameCount(0, aPath, aID) : of(path)), catchError(() => of(path)) ); } function _createNewLayoutForType(aName: string, aType: any, aTools: WchToolsInfo): Observable { // construct a new ID // const id = v4(); let id = kebabCase(aName); // check if the ID is already known if (aTools.tools.layouts[id]) { id = v4(); } // selector const selector = kebabCase(aName); // create the path const dir = join(aTools.tools.root, 'layouts'); const path = rxMkdirp(dir); // source assets const srcThumb = join(__dirname, '..', '..', '..', 'assets', 'layout.thumbnail.jpg'); // thumb path const relThumPath = '/dxauth/layoutThumbs/'; const thumbPath = rxMkdirp(join(aTools.tools.root, 'assets', relThumPath)); // construct the file const layout = addRevisionId({ 'id': id, 'name': aName, 'classification': 'layout', 'prerender': false, 'template': selector, 'templateType': 'angular', 'thumbnail': { 'path': `${relThumPath}${id}.jpg` } }); // register this new layout aTools.layoutsByName[aName] = layout; aTools.tools.layouts[id] = layout; // copy the thumbnail const thumb = thumbPath.pipe( map(p => join(p, `${id}.jpg`)), mergeMap(dst => rxCopyFile(srcThumb, dst)) ); // handle const json = path.pipe( mergeMap(p => _getUniqueFileName(p, id)), mergeMap(name => writeFile(name, _niceStringify(canonicalizeJSON(layout)))) ); // combine return merge(json, thumb); } function _createLayoutMapping(aType: any, aTotal: number, aNameMapper: LayoutNameMapper, aTools: WchToolsInfo): Observable { // build the array of layouts return range(0, aTotal).pipe( map(id => aNameMapper(id, aTotal, aType.name)), map(name => aTools.layoutsByName[name]), toArray(), mergeMap(allLayouts => { // check if we have a known mapping let knownMapping = aTools.mappingByType[aType.id]; if (!knownMapping) { // name of the mapping const canonicalLayoutName = aNameMapper(0, 1, aType.name); const name = _createLayoutMappingName(canonicalLayoutName); // new ID const id = kebabCase(name); // construct a mapping knownMapping = addRevisionId({ 'id': id, 'name': name, 'classification': 'layout-mapping', 'type': { 'id': aType.id, 'name': aType.name }, mappings: [] }); // the new filename aTools.tools.idToFile[id] = join(aTools.tools.root, 'layout-mappings', id + '.json'); } // filename const fileName = aTools.tools.idToFile[knownMapping.id]; // mappings file const mappings = knownMapping.mappings; // make sure we have a record const defaultRecord = mappings[0] || (mappings[0] = {}); // check for the default const firstLayout = allLayouts[0]; const defaultLayout = defaultRecord.defaultLayout || (defaultRecord.defaultLayout = { 'id': firstLayout.id, 'name': firstLayout.name }); // make sure to register the layout in the list of children const layouts = defaultRecord.layouts || (defaultRecord.layouts = []); // iterate over all layouts allLayouts.forEach(aLayout => { // do we know our layout if (!layouts.find((l: any) => l.id === aLayout.id)) { // insert our layout layouts.push({ 'id': aLayout.id, 'name': aLayout.name }); } }); // serialize, so we can check if we need to update the file const data = _niceStringify(canonicalizeJSON(knownMapping)); // first read, so we do not overide an unmodified file return rxMkdirp(join(aTools.tools.root, 'layout-mappings')).pipe( mergeMap(dir => writeFileIfChanged(fileName, data))); })); } function _createLayout(aType: any, aTools: WchToolsInfo, aNumber: number, aTotal: number, aNameMapper: LayoutNameMapper): Observable { // construct a layout name const layoutName = aNameMapper(aNumber, aTotal, aType.name); // the generated layout let layout: Observable; // check if we know about the layout let knownLayout = aTools.layoutsByName[layoutName]; if (knownLayout) { // no layout to create layout = empty(); } else { // the layout does not exist layout = _createNewLayoutForType(layoutName, aType, aTools); knownLayout = aTools.layoutsByName[layoutName]; } // go return layout; } function _createLayouts(aTools: WchTools, aTypePredicate: TypeMatcher, aTotal: number, aNameMapper: LayoutNameMapper | undefined, aOptions: Options, aLogger: Subject): Observable { // some helper const tools = _createWchToolsInfo(aTools); // construct a mapper const nameMapper = aNameMapper || _createDefaultLayoutNameMapper(aOptions, tools); // log this aLogger.next(`Layouts: ${_niceStringify(Object.keys(tools.layoutsByName))}`); aLogger.next(`Mappings: ${_niceStringify(Object.keys(tools.mappingByType))}`); aLogger.next(`Types: ${_niceStringify(Object.keys(aTools.types))}`); // create a layout for each type return from(Object.keys(aTools.types)).pipe( map(key => aTools.types[key]), filter(aTypePredicate), mergeMap(type => concat( range(0, aTotal).pipe(mergeMap(idx => _createLayout(type, tools, idx, aTotal, nameMapper))), _createLayoutMapping(type, aTotal, nameMapper, tools) ))); } function _allTypes(aType: any): boolean { return true; } function _matches(aName: string, aMatcher: Matcher, aLogger: Subject): boolean { // check const result = aMatcher(aName); aLogger.next(`Matching [${aName}] results in [${result}].`); return result; } function _byMatcher(aMatcher: Matcher, aLogger: Subject): TypeMatcher { // execute the check return (aType: any) => aType && aType.name && _matches(aType.name, aMatcher, aLogger); } /** * Generates layout and layout mappings * * create layouts --data DATA_FOLDER [--count] */ export class CreateLayoutsCommand extends AbstractCommand implements Command { static readonly TYPE = 'layouts'; static readonly COMMAND = CMD_CREATE; constructor(private options: Options) { super(options); } process(): Observable { // defer return defer(() => { // logger const logger = this.getLogger(); // matcher const matcher: Matcher = wchToolsGetMatcher(this.options); const typeMatcher: TypeMatcher = _byMatcher(matcher, logger); // access the name mapper const layoutNameMapper: LayoutNameMapper | undefined = this.options.layoutNameMapper; // number of layouts to create const total = Math.max(1, this.options.count || 1); // the tools const rxWchTools = this.getWchTools(); // generated layouts const rxLayouts = rxWchTools.pipe( mergeMap(aTools => _createLayouts(aTools, typeMatcher, total, layoutNameMapper, this.options, logger)) ); // ok return rxLayouts; }); } }