Source: widget/help/Data.js

(function() {
    /**
     * @typedef {Object} Help4.widget.help.Data.Params
     * @property {Help4.widget.help.Widget} widget - the owner widget
     * @property {Help4.widget.help.CatalogueBackend} catalogueBackend - the corresponding backend connector
     */

    /**
     * @typedef {Object} Help4.widget.help.ProjectTile
     * @property {boolean} alignWithText
     * @property {string[]} autoProgress
     * @property {boolean} autoSkipStep
     * @property {Help4.typedef.BubbleAnimationType} bubbleAnimationType
     * @property {Help4.control2.PositionLeftTop} bubbleOffset
     * @property {Help4.typedef.BubbleOrientation} bubbleOrientation
     * @property {string} bubbleSize
     * @property {Help4.typedef.BubbleType} bubbleType
     * @property {boolean} callout
     * @property {Array<*>} conditions
     * @property {string} content
     * @property {string} elementType
     * @property {boolean} hidden
     * @property {?string} hotspotAnchor
     * @property {string} hotspotAnimationType
     * @property {boolean} hotspotCentered
     * @property {Help4.typedef.HotspotIconPosition} hotspotIconPos
     * @property {Help4.typedef.HotspotIconType} hotspotIconType
     * @property {string} hotspotId
     * @property {Help4.control2.PositionLeftTop} hotspotManualOffset
     * @property {boolean} hotspotOffset
     * @property {Help4.control2.SizeWidthHeight} hotspotRectDelta
     * @property {string} hotspotSize
     * @property {boolean} hotspotSpotlight
     * @property {number} hotspotSpotlightBlur
     * @property {number} hotspotSpotlightOffset
     * @property {number} hotspotSpotlightOpacity
     * @property {string} hotspotStyle
     * @property {string} hotspotTrianglePos
     * @property {string} id
     * @property {boolean} instantHelp
     * @property {string} [language]
     * @property {Help4.control2.SizeWidthHeight} lightboxSize
     * @property {string} lightboxSizing
     * @property {boolean} linkLightbox
     * @property {string} linkTo
     * @property {string} loio
     * @property {string} pageUrl
     * @property {boolean} showArrow
     * @property {boolean} showAsButton
     * @property {boolean} showTitleBar
     * @property {boolean} [showDetach]
     * @property {boolean} [fullCover]
     * @property {boolean} splash
     * @property {Help4.typedef.SplashOption} splashOption
     * @property {string} summaryText
     * @property {Help4.typedef.TileIcons} tileIcon
     * @property {number} tileOrder
     * @property {string} title
     * @property {string} type
     * @property {Help4.widget.help.CatalogueKeys} [_catalogueKey]
     * @property {Help4.widget.help.CatalogueTypes} _catalogueType
     * @property {Array<Object>} _ctx
     * @property {Help4.widget.help.DataTypes} _dataType
     * @property {Help4.widget.help.project.SEN.LessonJsMacro} _mac
     * @property {boolean} [_hiddenAnnouncement]
     * @property {string} _projectId
     * @property {?string} _special
     * @property {boolean} _standalone
     * @property {boolean} _reference
     * @property {boolean} [_isUR]
     * @property {Object} [_translation]
     * @property {string} [_translation.content]
     * @property {string} [_translation.title]
     */

    /**
     * @typedef {Object} Help4.widget.help.Project
     * @property {string} alias
     * @property {Object} conditions
     * @property {Help4.widget.help.ContextTypes} contextType
     * @property {boolean} [hidden] - authors only
     * @property {string} id
     * @property {string} language
     * @property {string} loio
     * @property {Help4.widget.help.PUBLISHED_STATUS} [playbackTag] - authors only
     * @property {string} product
     * @property {Help4.widget.help.PUBLISHED_STATUS} [published] - authors only
     * @property {string} screen
     * @property {string} system
     * @property {Help4.widget.help.ProjectTile[]} tiles
     * @property {string} title
     * @property {string} version
     * @property {Help4.widget.help.CatalogueTypes} _catalogueType - SEN, SEN2, UACP, ...; internally added
     * @property {Help4.widget.help.DataTypes} _dataType - SEN, UACP, ...; internally added
     * @property {boolean} _standalone
     * @property {'ext'|'ro'|'rw'} [_ext] - EXT mode
     * @property {string} [_rw] - EXT: ID of the EXT project from RW
     */

    /**
     * @typedef {Object} Help4.widget.help.Projects
     * @property {?Help4.widget.help.Project} pub - public project for end-users
     * @property {?Help4.widget.help.Project} [head] - non-public project for authors
     */

    /**
     * @typedef {Object} Help4.widget.help.HelpSelection
     * @property {Help4.widget.help.Project[]} pub
     * @property {Help4.widget.help.Project[]} head
     */

    /**
     * @typedef {Object} Help4.widget.help.TileDescriptor
     * @property {string} [tileId]
     * @property {string} projectId
     * @property {Help4.widget.help.CatalogueTypes} catalogueType
     * @property {Help4.widget.help.CatalogueKeys} [catalogueKey]
     * @property {Help4.widget.help.DataTypes} [dataType]
     * @property {string} [projectVersion]
     */

    const MERGE_ATTRIBUTES = {
        HELP_TILE: [
            'title', 'summaryText', 'content',  'hidden', 'tileIcon',
            'bubbleSize', 'bubbleOrientation', 'bubbleOffset', 'bubbleAnimationType', 'showTitleBar',
            'hotspotAnchor', 'hotspotSize', 'hotspotOffset', 'hotspotStyle', 'hotspotIconType', 'hotspotIconPos', 'hotspotTrianglePos', 'hotspotManualOffset', 'hotspotRectDelta', 'hotspotAnimationType', 'instantHelp',
            'alignWithText', 'conditions', 'callout', '_ctx', '_mac'
        ],
        LINK_TILE: ['title', 'summaryText', 'showAsButton', 'tileIcon', 'linkTo', 'linkLightbox', 'lightboxSizing', 'lightboxSize', 'splash', 'splashOption', 'hidden', 'conditions', '_ctx', '_mac'],
        HELP: ['hidden', 'published', 'playbackTag']
    }

    /**
     * Data handler for help widget.
     * @augments Help4.jscore.DataBase
     * @property {Help4.widget.help.Widget} __widget - the widget
     * @property {Help4.widget.help.CatalogueBackend} __catalogueBackend - the catalogue backend
     * @property {Help4.widget.help.Catalogues} catalogues - the catalogue for the current screen
     * @property {Help4.widget.help.HelpSelection} help - the help selection for the current screen
     * @property {{pub: string[], head: string[]}} helpIds
     */
    Help4.widget.help.Data = class extends Help4.jscore.DataBase {
        /**
         * @override
         * @param {Help4.widget.help.Data.Params} params
         * @param {Object} [derived]
         */
        constructor(params, derived) {
            /** @returns {Help4.typedef.SystemConfiguration} */
            const getConfig = () => this.__widget.getContext().configuration;

            const {/** @type {Help4.widget.help.CatalogueTypeExtension} */ TYPES: T} = Help4.jscore.DataBase;
            super(params, {
                params: {
                    widget:           {type: T.instance, mandatory: true, private: true, readonly: true},
                    catalogueBackend: {type: T.instance, mandatory: true, private: true, readonly: true}
                },
                data: {
                    catalogues: {type: T.widgetHelpCatalogue, retrieve: {onInitialize: true, onUpdate: true}},
                    help:       {type: T.object, init: {pub: [], head: []}},
                    helpIds:    {type: T.object, init: {pub: [], head: []}},
                },
                statics: {
                    _cataloguesLoaded: {init: null, destroy: false},
                    _helpLoaded:       {init: null, destroy: false}
                },
                retrieve: {
                    /** @returns {Promise<Help4.widget.help.Catalogues>} */
                    catalogues: () => {
                        // ATTENTION:
                        // there is no way to recognize inner project changes based on catalogue
                        // so even if the catalogues stay the same, the projects might have changed
                        // therefore invalidate all project information on catalogue retrieve

                        const config = getConfig();

                        // reset loaded information
                        this._cataloguesLoaded = null;
                        this._helpLoaded = null;

                        // update all catalogues
                        return this.__catalogueBackend.load(config);

                        /** next step: {@link _onRetrieveReady} */
                    }
                },
                derived
            });
        }

        /**
         * see {@link Help4.widget.help.Project}
         * @type {string[]}
         */
        static ACCEPTED_ATTRIBUTES = [
            'alias',
            'conditions',
            'contextType',
            'hidden',
            'id',
            'language',
            'loio',
            'playbackTag',
            'product',
            'published',
            'screen',
            'system',
            'tiles',
            'title',
            'version',
            '_standalone'
        ];

        static SCREEN_ID_EXTENSION = '';

        /**
         * @param {Help4.widget.help.TileDescriptor|Help4.widget.help.CatalogueSelection|Help4.widget.help.Project} info
         * @returns {string}
         */
        static descriptorToId(info) {
            if (info._catalogueType) {
                const {_catalogueType, id} = /** @type {Help4.widget.help.Project} */ info;
                // <_catalogueType>!<id>
                return `${_catalogueType}!${id}`;
            } else if (info.projectId) {
                const {catalogueType, projectId, tileId} = /** @type {Help4.widget.help.TileDescriptor} */ info;
                return tileId
                    // <catalogueType>:<projectId>!<tileId>
                    ? `${catalogueType}:${projectId}!${tileId}`
                    // <catalogueType>:<projectId>
                    : `${catalogueType}:${projectId}`;
            } else {
                const {id, catalogueType, id2, catalogueType2} = /** @type {Help4.widget.help.CatalogueSelection} */ info;
                return id2
                    // <catalogueType>!<id>:<catalogueType2>!<id2>
                    ? `${catalogueType}!${id}:${catalogueType2}!${id2}`
                    // <catalogueType>!<id>
                    : `${catalogueType}!${id}`;
            }
        }

        /**
         * @param {string} id
         * @returns {Help4.widget.help.TileDescriptor|Help4.widget.help.CatalogueSelection}
         */
        static idToDescriptor(id) {
            let selection = id.match(/^([^:]+?)!(.+?):(.+?)!(.+)/);
            if (selection) {
                // <catalogueType>!<id>:<catalogueType2>!<id2>
                const [all, catalogueType, id, catalogueType2, id2] = selection;
                return {catalogueType, id, catalogueType2, id2};
            }

            selection = id.match(/^([^:]+?)!(.+)/);
            if (selection) {
                // <catalogueType>!<id>
                const [all, catalogueType, id] = selection;
                return {catalogueType, id};
            }

            let descriptor = id.match(/^(.+?):(.+?)!(.+)/);
            if (descriptor) {
                // <catalogueType>:<projectId>!<tileId>
                const [all, catalogueType, projectId, tileId] = descriptor;
                return {catalogueType, projectId, tileId};
            }

            // <catalogueType>:<projectId>
            const [catalogueType, projectId] = id.split(':');
            return {catalogueType, projectId};
        }

        /**
         * @override
         * @param {string} key
         * @returns {Promise<void>}
         */
        async _onRetrieveReady(key) {
            if (key === 'catalogues') {
                const {configuration: {WM, core: {screenId}}} = this.__widget.getContext();

                // mark catalogues for this screen as loaded
                this._cataloguesLoaded = screenId;

                // update help information
                await this._retrieveHelp();

                // mark help for this screen as loaded
                this._helpLoaded = screenId;

                // for whatsnew integration
                this._fireEvent({type: 'dataRetrieved'});

                WM && Help4.WM._triggerHelpAvailable();
            }
        }

        /**
         * @protected
         * @returns {Promise<void>}
         */
        async _retrieveHelp() {
            const {constructor, __widget} = this;
            const {configuration: {core: {screenId}, WM}} = __widget.getContext();
            const selectionId = `${screenId}${constructor.SCREEN_ID_EXTENSION}`;

            // get the current help selection from catalogues; remove what's new by only using our screenId
            let {
                pub: {selected: {/** @type {Help4.widget.help.CatalogueSelection[]} */ [selectionId]: ps}},
                head: {selected: {/** @type {Help4.widget.help.CatalogueSelection[]} */ [selectionId]: hs}}
            } = /** @type {Help4.widget.help.Catalogue} */ this.catalogues;

            // set default in case no help available
            ps ||= [];
            hs ||= [];

            // avoid duplicate loading, especially in EXT case
            // e.g.
            // PUB  - {id: 'PR_123', catalogueType: 'SEN'}
            // HEAD - {id: 'PR_123', catalogueType: 'SEN', id2: 'PR_456', catalogueType2: 'SEN2'}
            // would create a duplicate load for PR_123
            const toBeLoaded = /** @type {string[][]} */ [];
            [...ps, ...hs].forEach(({id, catalogueType, id2, catalogueType2}) => {
                const roExists = !!toBeLoaded.find(([a, b]) => id === a && catalogueType === b);
                roExists || toBeLoaded.push([id, catalogueType]);

                if (id2) {
                    const rwExists = !!toBeLoaded.find(([a, b]) => id2 === a && catalogueType2 === b) || false;
                    rwExists || toBeLoaded.push([id2, catalogueType2]);
                }
            });

            // load projects
            /** @type {Array<Promise<?{pub: ?Help4.widget.help.Project, head: ?Help4.widget.help.Project}>>} */
            const promises = toBeLoaded.map(([id, catalogueType]) => this.loadProject(id, catalogueType));

            // wait until loaded
            /** @type {Array<{pub: ?Help4.widget.help.Project, head: ?Help4.widget.help.Project}>} */
            const projects = await Help4.Promise.all(promises);

            // combine into one array for pub/head each
            /** @type {Help4.widget.help.Project[]} */ const pubProjects = [];
            /** @type {Help4.widget.help.Project[]} */ const headProjects = [];
            projects.forEach(project => {
                const {pub, head} = project || {};
                pub && ps.find(({id, id2}) => Help4.includes([id, id2], pub.id)) &&  pubProjects.push(pub);
                head && hs.find(({id, id2}) => Help4.includes([id, id2], head.id)) &&  headProjects.push(head);
            });

            // EXT: merge RO and RW
            _mergeEXT.call(this, ps, pubProjects);
            _mergeEXT.call(this, hs, headProjects);

            [...pubProjects, ...headProjects].forEach(
                ({language, tiles}) => tiles.forEach(tile => {
                    // UACP and SEN tiles already have language
                    // add language info to tiles where language its missing. ex: special tiles
                    tile.language ||= language;

                    // WM mode: convert all to ICONS; XRAY-6538
                    if (WM > 1) {
                        if (tile.hotspotStyle !== 'ICON') {
                            tile.hotspotStyle = 'ICON';  // all hotspots will be icons
                            tile.hotspotIconPos = 'D';  // top, right, above
                        }
                        tile.hotspotSize = '1';  // xs
                    }
                })
            );

            const {Data} = Help4.widget.help;
            this.helpIds = {
                pub: pubProjects.map(Data.descriptorToId),
                head: headProjects.map(Data.descriptorToId)
            };

            // store projects
            this.help = {pub: pubProjects, head: headProjects};
        }

        /** @returns {boolean} */
        onScreen() {
            const {configuration: {core: {screenId}}} = this.__widget.getContext();
            return this._cataloguesLoaded === screenId;
        }

        /**
         * gets a project from currently loaded catalogue
         * @param {string} search
         * @param {?Help4.widget.help.CatalogueKeys} [catalogueKey = null]
         * @param {?string} [attributeName = 'id']
         * @returns {?Help4.widget.help.CatalogueProject|{pub: ?Help4.widget.help.CatalogueProject, head: ?Help4.widget.help.CatalogueProject}}
         */
        getCatalogueProject(search, catalogueKey = null, attributeName = 'id') {
            const {/** @type {Help4.widget.help.Catalogues} */ catalogues} = this;

            /**
             * @param {Help4.widget.help.CatalogueProject} project
             * @returns {boolean}
             */
            const find = project => project[attributeName] === search;

            if (catalogueKey) {
                /** @type {Help4.widget.help.Catalogue} */
                const catalogue = catalogues[catalogueKey];
                /** @type {?Help4.widget.help.CatalogueProject} */
                return catalogue.projects.find(find) || null;
            } else {
                let {
                    /** @type {Help4.widget.help.Catalogue} */ pub,
                    /** @type {Help4.widget.help.Catalogue} */ head
                } = catalogues;

                /** @type {?Help4.widget.help.CatalogueProject} */
                const pubProject = pub.projects.find(find) || null;
                /** @type {?Help4.widget.help.CatalogueProject} */
                const headProject = head.projects.find(find) || null;
                /** @type {{pub: ?Help4.widget.help.CatalogueProject, head: ?Help4.widget.help.CatalogueProject}} */
                return {pub: pubProject, head: headProject};
            }
        }

        /**
         * loads a project regardless of catalogue information
         * @param {string} projectId
         * @param {Help4.widget.help.CatalogueTypes} catalogueType
         * @returns {Promise<?{pub: ?Help4.widget.help.Project, head: ?Help4.widget.help.Project}>}
         */
        async loadProject(projectId, catalogueType) {
            const {__widget} = this;
            const {configuration} = /** @type {Help4.widget.help.Widget.Context} */ __widget.getContext();

            // load projects
            /** @type {?Help4.widget.help.Projects} */
            const projects = await Help4.widget.help.project[catalogueType]?.load(projectId, configuration, this);
            if (!projects) return null;

            const {serviceLayer} = configuration.help;
            const isEXT = serviceLayer === Help4.SERVICE_LAYER.ext;

            const {
                QuickTour: {CATALOGUE_TYPE: QuickTour_CATALOGUE_TYPE},
                API: {CATALOGUE_TYPE: API_CATALOGUE_TYPE},
                UR: {CATALOGUE_TYPE: UR_CATALOGUE_TYPE}
            } = Help4.widget.help.catalogues;

            if (isEXT && (catalogueType === 'SEN' || catalogueType === 'UACP')) {
                // in EXT mode: always use published data for RO sources!
                projects.head = Help4.cloneValue(projects.pub);
            }

            return projects;
        }

        /**
         * Will return once the catalogues for the corresponding screen are loaded.
         * @param {string} screenId
         * @returns {Promise<void>}
         */
        waitCataloguesLoaded(screenId) {
            return new Help4.Promise(resolve => {
                const wait = () => {
                    this._cataloguesLoaded === screenId
                        ? resolve()
                        : setTimeout(wait, Help4.Queue.getTime());
                }
                wait();
            });
        }

        /**
         * Will return once the help for the corresponding screen is loaded.
         * @returns {Promise<void>}
         */
        waitHelpLoaded() {
            return new Help4.Promise(resolve => {
                const wait = () => {
                    const {_cataloguesLoaded, _helpLoaded} = this;
                    _helpLoaded && _helpLoaded === _cataloguesLoaded  // same screen as catalogue
                        ? resolve()
                        : setTimeout(wait, Help4.Queue.getTime());
                }
                wait();
            });
        }

        /** @returns {Promise<Help4.widget.help.Project[]>} */
        async getHelp() {
            await this.waitHelpLoaded();
            if (this.isDestroyed()) return [];

            const {Core} = Help4.widget.companionCore;
            const {__widget, help} = this;

            const {configuration} = /** @type {Help4.widget.help.Widget.Context} */ __widget.getContext();
            const catalogueKey = /** @type {Help4.widget.help.CatalogueKeys} */ Core.getCatalogueKey({configuration});
            return help[catalogueKey];
        }

        /** @returns {Promise<Help4.widget.help.ProjectTile[]>} */
        async getHelpTiles() {
            const {
                companionCore: {
                    data: {Help},
                    Core
                },
                help: {
                    project,
                    catalogues: {
                        GlobalHelp: {CATALOGUE_TYPE: GlobalHelp_CATALOGUE_TYPE}
                    }
                }
            } = Help4.widget;
            const {Selector} = Help4.selector;

            const {configuration, controller} = this.__widget.getContext();
            const catalogueKey = Core.getCatalogueKey({configuration});
            const {
                help: {fioriApplication, useABAPHelpTexts, useURHotspots},
                isOpen
            } = configuration;
            const globalHelpPrefix = `[${fioriApplication}]`;

            const urEngine = controller.getEngine('urHarmonization');
            /**
             * a) UrHarmonizationSelector tiles: check for callout / instantHelp, then run UR engine closed,
             * b) other tiles: check for UR migration, add to migration map
             * @returns {boolean} - true if UR engine needs to run when the panel is closed
             * @private
             */
            const _handleUr = ({callout, hotspotAnchor, instantHelp, hidden}) => {
                if (!hidden && hotspotAnchor) {
                    const {ur, migrate} = Help.decodeUrHotspot(hotspotAnchor, false, true) || {};
                    if (ur) {
                        return (callout || instantHelp) && !!ur;
                    } else if (migrate) {
                        urEngine.addSelectorToMigrate(migrate);
                    }
                }
                return false;
            };

            const filteredTiles = /** @type {Help4.widget.help.ProjectTile[]} */ [];

            const projects = await this.getHelp();
            if (this.isDestroyed()) return filteredTiles;

            // XRAY-5995: if an instant hotspot or callout tile is assigned to a UrHotspot,
            //  the urEngine needs to run even when the panel is closed, but there is no need to check if the panel is open
            let runUrClosed = isOpen;

            projects.forEach(({tiles, _catalogueType}) => {
                if (_catalogueType === GlobalHelp_CATALOGUE_TYPE) {
                    // Global Help
                    tiles.forEach(tile => {
                        let {title} = tile;
                        if (title.indexOf(globalHelpPrefix) !== 0) return;  // filter global help tile that does not match to fiori application

                        title = title.substring(globalHelpPrefix.length);  // remove the global help prefix from title;
                        filteredTiles.push(/** @type {Help4.widget.help.ProjectTile} */ {
                            ...tile,
                            title,
                            _catalogueKey: catalogueKey
                        });
                    });
                } else if (project[_catalogueType]?.getTiles) {
                    // API, UR, QuickTour, ...
                    project[_catalogueType]?.getTiles(filteredTiles, catalogueKey);
                } else {
                    // all others

                    tiles = /** @type {Help4.widget.help.ProjectTile[]} */ tiles
                    .map(tile => {
                        runUrClosed ||= (useABAPHelpTexts || useURHotspots) && _handleUr(tile);
                        return {...tile, _catalogueKey: catalogueKey};
                    });

                    filteredTiles.push(...tiles);
                }
            });

            if (!isOpen) urEngine.runClosed(runUrClosed);
            return filteredTiles;
        }

        /**
         * Will return the projects for the corresponding screenId, catalogueType and catalogueKey
         * @param {string} screenId
         * @param {string} catalogueType
         * @param {string} catalogueKey
         * @returns {Help4.widget.help.Project[]}
         */
        getProjects(screenId, catalogueType, catalogueKey) {
            const selected = this.catalogues[catalogueKey].selected[screenId];
            const {id, id2} = selected.find(item => item.catalogueType === catalogueType) || {};
            const projects = [];
            if (id) projects.push(this.getCatalogueProject(id, catalogueKey));
            if (id2) projects.push(this.getCatalogueProject(id2, catalogueKey));

            return projects;
        }
    }

    /**
     * @memberof Help4.widget.help.Data#
     * @private
     * @param {Help4.widget.help.CatalogueSelection[]} selection
     * @param {Help4.widget.help.Project[]} projects
     */
    function _mergeEXT(selection, projects) {
        /**
         * @param {Object} dst
         * @param {Object} src
         * @param {string[]} list
         */
        const merge = (dst, src, list) => {
            list.forEach(key => {
                if (src[key] !== undefined) dst[key] = Help4.cloneValue(src[key]);
            });
        }

        selection.forEach(({id, catalogueType, id2, catalogueType2}) => {
            const roIndex = id ? projects.findIndex(({id: a, _catalogueType: b}) => a === id && b === catalogueType) : -1;
            const rwIndex = id2 ? projects.findIndex(({id: a, _catalogueType: b}) => a === id2 && b === catalogueType2) : -1;
            if (roIndex < 0 && rwIndex < 0) return;  // prevent a strange error from happening

            if (roIndex < 0 || rwIndex < 0) {
                // only RO or RW exists; can be a pure RO (SEN, UACP) or pure RW (SEN) one
                const project = projects[roIndex >= 0 ? roIndex : rwIndex];
                project._ext = project._catalogueType === 'SEN2' ? 'rw' : 'ro';
                return;
            }

            const {HELP, HELP_TILE, LINK_TILE} = MERGE_ATTRIBUTES;
            const ro = /** @type {Help4.widget.help.Project} */ projects[roIndex];
            const rw = /** @type {Help4.widget.help.Project} */ projects[rwIndex];
            const to = /** @type {Help4.widget.help.ProjectTile[]} */ ro.tiles || [];
            const tw = /** @type {Help4.widget.help.ProjectTile[]} */ rw.tiles || [];
            const tn = /** @type {Help4.widget.help.ProjectTile[]} */ [];

            // remove RW project as we merge both into RO
            projects.splice(rwIndex, 1);

            // merge attributes on project level
            merge(ro, rw, HELP);
            ro._ext = 'ext';
            ro._rw = rw.id;

            // 1. add all tiles from RW in RW tile order
            for (const rwTile of tw) {  // add if...
                if (rwTile._standalone) {
                    // RW tile is an independently added one
                    tn.push(rwTile);
                } else {
                    const index = Help4.indexOf(to, 'loio', rwTile.loio);
                    const roTile = index >= 0 && to[index];

                    if (roTile) {
                        // RW tile extends RO one and RO one still exists
                        merge(roTile, rwTile, roTile.type === 'help' ? HELP_TILE : LINK_TILE);
                        tn.push(roTile);
                    }
                }
            }

            // 2. add remaining RO tiles
            for (const roTile of to) {
                if (Help4.indexOf(tw, 'loio', roTile.loio) < 0) {
                    tn.push(roTile);
                }
            }

            ro.tiles = tn;
        });
    }
})();