Source: MigrateFioriSpaces.js

(function() {
    /**
     * @typedef {Object} Help4.MigrateFioriSpaces.ProjectIdList
     * @property {string[]} ro - the RO project IDs
     * @property {string[]} rw - the RW project IDs
     */

    /**
     * @typedef {Object} Help4.MigrateFioriSpaces.EntityStreamInfo
     * @property {string} name - stream name
     */

    /**
     * @typedef {Object} Help4.MigrateFioriSpaces.EntityStreamList
     * @property {string} uid - entity UID
     * @property {Help4.MigrateFioriSpaces.EntityStreamInfo[]} Streams - streams of entity
     * @property {Object} [entity_txt] - content of entity.txt
     * @property {Object} [lesson_js] - content of lesson.js
     * @property {XMLDocument} [project_dpr] - content of project.dpr
     */

    /**
     * @typedef {Object} Help4.MigrateFioriSpaces.EntityMetaInfo
     * @property {Help4.MigrateFioriSpaces.EntityStreamInfo[]} Streams
     * @property {number|string} wt - whether WT is available
     * @property {string} wt_location - location of WT, if acquired
     * @property {string} wt_owner - owner of WT, if acquired
     */

    /**
     * @typedef {Object} Help4.MigrateFioriSpaces.EntityInfo
     * @property {Help4.MigrateFioriSpaces.EntityMetaInfo} Meta - entity Meta information
     * @property {string} uid - entity UID
     */

    /**
     * @typedef {Object} Help4.MigrateFioriSpaces.WhoAmI
     * @property {string} name - assembled user name: "<ID> (<Firstname> <Lastname>)
     * @property {string} uid - user UID
     */

    /**
     * @typedef {Object} Help4.MigrateFioriSpaces.DownloadInfo
     * @property {string} url - the server URL as configured for SAP Companion
     * @property {string[]} ids - the project IDs
     * @property {Object} contextTypes - the project contextTypes
     * @property {string} xcsrf - the XCSRF token
     * @property {string} serverBaseUrl - the server base URL
     * @property {string} waBase - the server's relative workarea URL
     * @property {string} waName - the server's workarea ID
     * @property {Help4.MigrateFioriSpaces.EntityStreamList[]} [entities] - entity information
     * @property {Help4.MigrateFioriSpaces.WhoAmI} [whoAmI] - my identification data on the server
     * @property {string[]} [blocked] - all UIDs that are blocked; WTs cannot be acquired
     * @property {string[]} [toBeAcquired] - all UIDs where WTs must be acquired to perform operation
     * @property {string[]} [acquired] - all UIDs where WTs have been acquired
     * @property {string[]} [notAcquired] - all UIDs that we were unable to acquire WTs
     * @property {string[]} [returned] - all UIDs where WTs were returned
     * @property {string[]} [notReturned] - all UIDs that we were unable to return the WT
     * @property {string[]} [modified] - all UIDs where we modified data
     * @property {string[]} [saved] - all UIDs that were successfully saved
     * @property {string[]} [failed] - all UIDs that were save failed
     */

    /**
     * @typedef {Object} Help4.MigrateFioriSpaces.MigrationInfo
     * @property {Object} migrationMap - the migration map; data-help-id vs data-help-id2
     * @property {Help4.MigrateFioriSpaces.DownloadInfo} [ro] - the download information for RO
     * @property {Help4.MigrateFioriSpaces.DownloadInfo} [rw] - the download information for RW
     */

    /**
     * @typedef {Object} Help4.MigrateFioriSpaces.PrintParams
     * @property {boolean} [isText = true] - whether data is text
     * @property {number} [level = 1] - the level of indentation
     * @property {boolean} [warn = false] - whether this is a warning
     * @property {boolean} [error = false] - whether this is an error
     * @property {boolean} [abort = false] - whether an error should be thrown
     */

    /**
     * @typedef {Object} Help4.MigrateFioriSpaces.PrintEntityUrlParams
     * @property {string} text - caption
     * @property {number} [level = 2] - the level of indentation
     * @property {boolean} [warn = false] - whether this is a warning
     * @property {boolean} [error = false] - whether this is an error
     * @property {boolean} [abort = false] - whether an error should be thrown
     */

    /**
     * Migrates all object recordings of all SEN projects from data-help-id to data-help-id2<br>
     * <b>Attention:</b> Does not work with UACP!
     */
    Help4.MigrateFioriSpaces = class {
        /**
         * @constructor
         */
        constructor() {
            /**
             * @private
             * @type {Help4.MigrateFioriSpaces.MigrationInfo}
             */
            this._info = {};
        }

        static NO_CACHE_HEADERS = {
            'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0',
            Pragma: 'no-cache'
        }

        /**
         * Prints console information
         * @throws {Error}
         * @param {*} data
         * @param {Help4.MigrateFioriSpaces.PrintParams} params
         */
        static print(data, {
            isText = true,
            level = 1,
            warn = false,
            error = false,
            abort = false
        } = {}) {
            if (isText) {
                let style = '';

                if (error) {
                    style = 'color:red';
                } else if (warn) {
                    style = 'color:orange';
                }

                switch (level) {
                    case 0:
                        console.log(`%c${data}`, 'border-top:1px solid #000');
                        break;
                    case 1:
                        console.groupEnd();
                        console.group(data);
                        break;
                    case 2:
                        console.log(`%c${data}`, style);
                        break;
                }
            } else {
                if (error) {
                    console.error(data);
                } else if (warn) {
                    console.warn(data);
                } else {
                    console.log(data);
                }
            }

            if (abort) {
                throw new Error('Execution aborted due to error(s).');
            }
        }

        /**
         * Shows links to projects in Manager UI.
         * @param {Help4.MigrateFioriSpaces.DownloadInfo} info
         * @param {string[]} ids
         * @param {Help4.MigrateFioriSpaces.PrintEntityUrlParams} params
         */
        static printEntityUrls(info, ids, {
            text,
            level = 2,
            warn = false,
            error = false,
            abort = false
        }) {
            const url = `${info.serverBaseUrl}#wa/overview/wa!${info.waName}:project!`;

            const urls = ids
            .map(uid => `- ${url}${uid}`)
            .join('\n');

            this.print(`${text}\n${urls}`, {level, warn, error, abort});
        }

        /**
         * Execute migration.
         * @return {Promise<void>}
         */
        async execute() {
            const {MigrateFioriSpaces: MFS, SERVICE_LAYER} = Help4;

            console.clear();
            MFS.print('Fiori Spaces Migration', {level: 0});
            MFS.print('Init');

            const controller = Help4.getController();
            const config = controller?.getConfiguration();
            const model = controller?.getService('model');
            if (!config || !model) MFS.print('System not ready for migration!', {error: true, level: 2, abort: true});

            const {serviceLayer, serviceUrl, serviceUrl2, features, core} = config;
            const {enableFioriSpacesMigration = false} = features || {};
            const {editor = false, screenId = null} = core || {};
            if (!enableFioriSpacesMigration) MFS.print('"enableFioriSpacesMigration" flag is not set!', {error: true, level: 2, abort: true});
            if (!editor) MFS.print('Feature requires "editor" permissions!', {error: true, level: 2, abort: true});
            if (!Help4.Feature.FioriSpacesMigrationDebug) {
                const shell = Help4.getShell();
                if (!(shell instanceof Help4.shell.Fiori)) MFS.print('Fiori shell is required!', {error: true, level: 2, abort: true});
                if (screenId !== 'Shell-home') MFS.print('Migration only works on "Shell-home"!', {error: true, level: 2, abort: true});
            }

            const isUACP = serviceLayer === SERVICE_LAYER.uacp;
            const isEXT = serviceLayer === SERVICE_LAYER.ext;
            if (isUACP) MFS.print('Migration does not work for UACP!', {error: true, level: 2, abort: true});

            const {_info} = this;
            if (!(_info.migrationMap = _getHelpIds.call(this))) {
                MFS.print('Nothing to migrate - OPERATION COMPLETED SUCCESSFULLY!', {level: 2});
                return;
            }

            const {ids, contextTypes} = _getProjectIds.call(this, model, config);
            _createInfo.call(this, ids, contextTypes, {isEXT, serviceUrl, serviceUrl2, model});

            const {ro, rw} = _info;
            MFS.print('project info:', {level: 2});
            MFS.print(_info, {isText: false});
            ro && MFS.printEntityUrls(ro, ro.ids, {text: 'RO projects:'});
            rw && MFS.printEntityUrls(rw, rw.ids, {text: 'RW projects:'});

            if (!ro && !rw) {
                MFS.print('No projects - OPERATION COMPLETED SUCCESSFULLY!', {level: 2});
                return;
            }

            await MFS.WT.check(_info);

            const needsWT = ro?.toBeAcquired.length || rw?.toBeAcquired.length;
            if (needsWT) await MFS.WT.acquire(_info);

            await MFS.Entity.download(_info);
            MFS.Entity.replace(_info);

            const modified = !!(ro?.modified?.length) || !!(rw?.modified?.length);
            if (modified) {
                MFS.Entity.createLessonJS(_info);
                await MFS.Entity.save(_info);
            } else {
                MFS.print('NOTHING MODIFIED!');
            }

            const returnWT = !!(ro?.acquired?.length) || !!(rw?.acquired?.length);
            if (returnWT) await MFS.WT.return(_info);

            MFS.print('OPERATION COMPLETED SUCCESSFULLY!');
            modified && MFS.print('PLEASE RELOAD YOUR BROWSER WINDOW!', {level: 2, error: true});
        }
    }

    /**
     * @memberof Help4.MigrateFioriSpaces#
     * @return {{}|null}
     * @private
     */
    function _getHelpIds() {
        const {methods} = Help4.selector;
        const nodeList = document.querySelectorAll('[data-help-id][data-help-id2]');
        const map = {};

        nodeList.forEach(node => {
            map[node.getAttribute('data-help-id')] = methods.$E(node.getAttribute('data-help-id2'));
        });
        return Object.keys(map).length ? map : null;
    }

    /**
     * Collects a list of all SEN projects currently available on this screen.
     * @private
     * @memberof Help4.MigrateFioriSpaces#
     * @param {Help4.model.ModelBase} model
     * @param {Object} config
     * @return {{ids: Help4.MigrateFioriSpaces.ProjectIdList, contextTypes: {ro: {}, rw: {}}}}
     */
    function _getProjectIds(model, config) {
        /**
         * @param {Help4.model.ModelBase} model
         * @return {{ids: string[], contextTypes: {}}}
         */
        const extract = (model) => {
            const toId = project => project._dataId;
            const contextTypes = {};
            /** @type {Set<string>} */
            const ids = new Set();

            [
                {published: true, whatsNew: true},
                {published: true, whatsNew: false},
                {published: false, whatsNew: true},
                {published: false, whatsNew: false}
            ]
            .forEach(config => {
                const help = model?.getScreenHelp(config);
                if (help) {
                    const id = toId(help);
                    contextTypes[id] = help.contextType;
                    ids.add(id);
                }

                const tours = model
                ?.getScreenTours(config)
                ?.forEach(tour => {
                    const id = toId(tour);
                    contextTypes[id] = tour.contextType;
                    ids.add(id);
                });
            });

            return {ids: [...new Set(ids)], contextTypes};
        }

        let roIds = [];
        let rwIds = [];
        let roCT = {};
        let rwCT = {};
        let r;
        if (config.serviceLayer === Help4.SERVICE_LAYER.ext) {
            if (config.roModel === Help4.SERVICE_LAYER.uacp) {  // UACP migration not possible
                Help4.MigrateFioriSpaces.print('RO model is UACP - will be ignored!', {warn: true, level: 2});
            } else {
                r = extract(model._ro);
                roIds = r.ids;
                roCT = r.contextTypes;
            }

            r = extract(model._rw);
            rwIds = r.ids;
            rwCT = r.contextTypes;
        } else {
            r = extract(model);
            rwIds = r.ids;
            rwCT = r.contextTypes;
        }

        return {
            ids: {ro: roIds, rw: rwIds},
            contextTypes: {ro: roCT, rw: rwCT}
        };
    }

    /**
     * Prepares the download information
     * @private
     * @memberof Help4.MigrateFioriSpaces#
     * @param {Help4.MigrateFioriSpaces.ProjectIdList} ids
     * @param {{ro: {}, rw: {}}} contextTypes
     * @param {{isEXT: boolean, serviceUrl: string, serviceUrl2: string, model: Help4.model.ModelBase}} config
     */
    function _createInfo({ro, rw}, {ro: roCT, rw: rwCT}, {isEXT, serviceUrl, serviceUrl2, model}) {
        const {_info} = this;
        if (isEXT) {
            const {_ro, _rw} = model;
            // ignore RO as we will not get any WTs for it
            // if (ro.length) _info.ro = {url: serviceUrl, ids: ro, contextTypes: roCT, xcsrf: _ro.getXcsrfId()};
            if (rw.length) _info.rw = {url: serviceUrl2, ids: rw, contextTypes: rwCT, xcsrf: _rw.getXcsrfId()};
        } else {
            if (rw.length) _info.rw = {url: serviceUrl, ids: rw, contextTypes: rwCT, xcsrf: model.getXcsrfId()};
        }

        /**
         * @param {Help4.MigrateFioriSpaces.DownloadInfo} info
         */
        const setupServerConfig = info => {
            const {url} = info;
            info.serverBaseUrl = url.replace(/\/wa\/.*$/, '');
            info.waBase = url.replace(/^.*?(\/wa\/)/, '$1');
            info.waName = info.waBase.split('/')[2];  // scheme is "/wa/<name>"
        }

        _info.ro && setupServerConfig(_info.ro);
        _info.rw && setupServerConfig(_info.rw);
    }
})();

(function() {
    /**
     * Write Token operation class
     */
    Help4.MigrateFioriSpaces.WT = class {
        /**
         * @param {Help4.MigrateFioriSpaces.MigrationInfo} info
         * @return {Promise<void>}
         */
        static async check(info) {
            const {MigrateFioriSpaces: MFS} = Help4;

            /**
             * @param {Help4.MigrateFioriSpaces.DownloadInfo} info
             * @return {Promise<Help4.MigrateFioriSpaces.EntityInfo[]>}
             */
            const queryServer = info => {
                return new Help4.Promise((resolve, reject) => {
                    const {serverBaseUrl, waBase, ids, xcsrf} = info;
                    const request = ids.map(id => ({
                        url: `${waBase}/project/${id}`,
                        method: 'GET',
                        body: '{}'
                    }));
                    request.unshift({url: '/self', method: 'GET', body: '{}'});

                    const success = ({response}) => {
                        info.whoAmI = response.shift();
                        resolve(response);
                    }

                    Help4.ajax.Ajax({
                        headers: MFS.NO_CACHE_HEADERS,
                        method: 'POST',
                        url: `${serverBaseUrl}/multiple_request`,
                        data: {request},
                        saml: true,
                        xcsrf,
                        success,
                        error: () => reject(new Error(`Unable to communicate with server: ${serverBaseUrl}`))
                    });
                });
            }

            /**
             * @param {Help4.MigrateFioriSpaces.DownloadInfo} info
             * @param {string} name
             * @return {Promise<void>}
             */
            const loadAndCheck = async (info, name) => {
                const response = await queryServer(info);
                const me = info.whoAmI.name;
                MFS.print(`${name} - Who am I: ${me}`, {level: 2});

                info.entities = [];
                /** @type {string[]} */
                const blocked = info.blocked = [];
                /** @type {string[]} */
                const toBeAcquired = info.toBeAcquired = [];
                response.forEach(entity => {
                    const {wt, wt_location, wt_owner, Streams} = entity.Meta;
                    const {uid} = entity;

                    info.entities.push({Streams, uid});

                    if (wt == 1) {
                        toBeAcquired.push(uid);
                    } else if (wt_location !== '' || wt_owner !== me) {
                        blocked.push(uid);
                    }
                });

                if (blocked.length) {
                    // blocked WT exist, abort further operation
                    MFS.printEntityUrls(info, blocked, {text: `${name} - Blocked WTs:`, error: true, abort: true});
                }
                if (toBeAcquired.length) {
                    MFS.printEntityUrls(info, toBeAcquired, {text: `${name} - WTs to be acquired:`});
                } else {
                    MFS.print(`${name} - All WTs already acquired`, {level: 2});
                }
            }

            // do NOT implement parallel XHR query execution as the SEN server has issues doing this
            // and might fail with SAML and log us in as anonymous
            const {ro, rw} = info;
            MFS.print('Checking projects on server');
            if (ro) await loadAndCheck(ro, 'RO');
            if (rw) await loadAndCheck(rw, 'RW');
        }

        /**
         * @param {Help4.MigrateFioriSpaces.MigrationInfo} info
         * @return {Promise<void>}
         */
        static async acquire(info) {
            const {MigrateFioriSpaces: MFS} = Help4;

            /**
             * @param {Help4.MigrateFioriSpaces.DownloadInfo} info
             * @param {string} name
             * @return {Promise<void>}
             */
            const acquireWTs = (info, name) => {
                return new Help4.Promise((resolve, reject) => {
                    const {toBeAcquired, serverBaseUrl, waBase, whoAmI: {name: me}, xcsrf} = info;
                    if (!toBeAcquired.length) return resolve();

                    const body = JSON.stringify({self_get_wt: 1});
                    const request = toBeAcquired.map(uid => ({
                        url: `${waBase}/project/${uid}`,
                        method: 'POST',
                        body
                    }));

                    const success = ({response}) => {
                        request.forEach((item, index) => {
                            const {wt, wt_k, wt_location, wt_owner} = response[index].response;
                            item.success = wt == 0 && !!wt_k && wt_location === '' && wt_owner === me;
                            item.uid = item.url.replace(/^.*\//, '');
                        });

                        /** @type {string[]} */
                        const acquired = info.acquired = [];
                        /** @type {string[]} */
                        const notAcquired = info.notAcquired = [];
                        request.forEach(item => item.success ? acquired.push(item.uid) : notAcquired.push(item.uid));

                        MFS.printEntityUrls(info, acquired, {text: `${name} - Acquired WTs:`});
                        if (notAcquired.length) {
                            MFS.printEntityUrls(info, notAcquired, {text: `${name} - Not acquired WTs:`, error: true, abort: true});
                        }

                        resolve();
                    }

                    Help4.ajax.Ajax({
                        headers: MFS.NO_CACHE_HEADERS,
                        method: 'POST',
                        url: `${serverBaseUrl}/multiple_request`,
                        data: {request},
                        saml: true,
                        xcsrf,
                        success,
                        error: () => reject(new Error(`Unable to communicate with server: ${serverBaseUrl}`))
                    });
                });
            };

            const {ro, rw} = info;
            MFS.print('Acquiring missing WTs');
            if (ro) await acquireWTs(ro, 'RO');
            if (rw) await acquireWTs(rw, 'RW');
        }

        /**
         * @param {Help4.MigrateFioriSpaces.MigrationInfo} info
         * @return {Promise<void>}
         */
        static async return(info) {
            const {MigrateFioriSpaces: MFS} = Help4;

            /**
             * @param {Help4.MigrateFioriSpaces.DownloadInfo} info
             * @param {string} name
             * @return {Promise<void>}
             */
            const returnWTs = (info, name) => {
                return new Help4.Promise((resolve, reject) => {
                    const {acquired, serverBaseUrl, waBase, whoAmI: {name: me}, xcsrf} = info;
                    if (!acquired?.length) return resolve();

                    const body = JSON.stringify({self_return_wt: 1});
                    const request = acquired.map(uid => ({
                        url: `${waBase}/project/${uid}`,
                        method: 'POST',
                        body
                    }));

                    const success = ({response}) => {
                        request.forEach((item, index) => {
                            const {wt} = response[index].response;
                            item.success = wt == 1;
                            item.uid = item.url.replace(/^.*\//, '');
                        });

                        /** @type {string[]} */
                        const returned = info.returned = [];
                        /** @type {string[]} */
                        const notReturned = info.notReturned = [];
                        request.forEach(item => item.success ? returned.push(item.uid) : notReturned.push(item.uid));

                        MFS.printEntityUrls(info, returned, {text: `${name} - Returned WTs:`});
                        if (notReturned.length) {
                            MFS.printEntityUrls(info, notReturned, {text: `${name} - Not returned WTs:`, error: true, abort: true});
                        }

                        resolve();
                    }

                    Help4.ajax.Ajax({
                        headers: MFS.NO_CACHE_HEADERS,
                        method: 'POST',
                        url: `${serverBaseUrl}/multiple_request`,
                        data: {request},
                        saml: true,
                        xcsrf,
                        success,
                        error: () => reject(new Error(`Unable to communicate with server: ${serverBaseUrl}`))
                    });
                });
            }

            const {ro, rw} = info;
            MFS.print('Returning acquired WTs');
            if (ro) await returnWTs(ro, 'RO');
            if (rw) await returnWTs(rw, 'RW');
        }
    }
})();

(function() {
    /**
     * Entity data operation class
     */
    Help4.MigrateFioriSpaces.Entity = class {
        /**
         * @param {Help4.MigrateFioriSpaces.MigrationInfo} info
         * @return {Promise<void>}
         */
        static async download(info) {
            const {MigrateFioriSpaces: MFS} = Help4;

            /**
             * @param {Help4.MigrateFioriSpaces.DownloadInfo} info
             * @param {string} name
             * @return {Promise<void>}
             */
            const download = (info, name) => {
                const ACCEPTED_STREAMS = ['entity.txt', 'project.dpr'];
                const DATA_TYPE = {'project.dpr': 'xml'};

                return new Help4.Promise((resolve, reject) => {
                    const {serverBaseUrl, waBase, xcsrf} = info;

                    const request = [];
                    info.entities.forEach(entity => {
                        ACCEPTED_STREAMS.forEach((name) => {
                            const url = `${waBase}/project/${entity.uid}/${name}`;
                            const dataType = DATA_TYPE[name];
                            dataType ? request.push({url, dataType}) : request.push({url});
                        });
                    });

                    const success = (response) => {
                        const result = {};

                        request.forEach((item, index) => {
                            const uid = item.url.replace(/^.*project\/(.*)\/.*$/, '$1');
                            const filename = item.url.replace(/^.*\/(.*)$/, '$1');
                            const stream = filename.replace(/\./, '_');
                            let data = response[index];

                            result[filename] ||= [];
                            result[filename].push(uid);

                            const idx = Help4.indexOf(info.entities, 'uid', uid);
                            if (idx >= 0) info.entities[idx][stream] = data;
                        });

                        for (let [filename, list] of Object.entries(result)) {
                            MFS.printEntityUrls(info, list, {text: `${name} - Downloaded "${filename}" for:`});
                        }

                        resolve();
                    }

                    Help4.ajax.EnableNow({
                        headers: MFS.NO_CACHE_HEADERS,
                        method: 'POST',
                        url: `${serverBaseUrl}/multifile`,
                        dataType: 'multipart',
                        data: {request},
                        saml: true,
                        xcsrf,
                        success,
                        error: () => reject(new Error(`Unable to communicate with server: ${serverBaseUrl}`))
                    });
                });
            }

            const {ro, rw} = info;
            MFS.print('Downloading entities');
            if (ro) await download(ro, 'RO');
            if (rw) await download(rw, 'RW');
        }

        /**
         * @param {Help4.MigrateFioriSpaces.MigrationInfo} info
         */
        static replace(info) {
            const {MigrateFioriSpaces: MFS} = Help4;

            /**
             * @param {Help4.MigrateFioriSpaces.DownloadInfo} info
             * @param {Object} migrationMap
             * @param {string} name
             */
            const replace = (info, migrationMap, name) => {
                const {Selector, methods: {DataAttrSelector}} = Help4.selector;
                const ACCEPTED_TEMPLATES = {help_tile: true, guide_click: true};
                const ACCEPTED_RULE = 'DataAttrSelector';
                const MACRO_ID = 'Macro';
                const INIT_ID = 'Init';
                const TEMPLATE_ID = 'template_name';
                const HOTSPOT_ID = 'hotspot';
                /** @type {string[]} */
                const modified = [];

                /** @type {Help4.MigrateFioriSpaces.EntityStreamList} */
                for (const entity of info.entities) {
                    const projectDpr = entity.project_dpr;
                    const macros = projectDpr?.getElementsByTagName(MACRO_ID) || [];

                    for (const macro of macros) {
                        const init = macro.getElementsByTagName(INIT_ID)[0];
                        const templateName = init?.getAttribute(TEMPLATE_ID);
                        if (!ACCEPTED_TEMPLATES[templateName]) continue;

                        let attrib = init.getAttribute(HOTSPOT_ID);
                        /** @type {{rule: string, value: string}|null} */
                        const hotspot = attrib ? Selector.base64ToUtf8(attrib) : null;
                        if (hotspot?.rule !== ACCEPTED_RULE) continue;

                        const recordedValue = hotspot.value.match(/data-help-id=(['"])(.*?)\1/)?.[2];  // get data-help-id from recording
                        if (!recordedValue) continue;

                        // the data-help-id from the element is different than the one from the recording
                        // as the recorded value is run through Help4.selector.method.$E
                        const element = DataAttrSelector.getElement(hotspot.value);
                        const elementValue = element?.getAttribute('data-help-id');  // get the ID from DOM
                        if (!elementValue || !migrationMap[elementValue]) continue;  // check whether migration is required

                        const newValue = hotspot.value.replace(recordedValue, migrationMap[elementValue]);  // the migrationMap[elementValue] is already escaped; see _getHelpIds
                        if (hotspot.value !== newValue) {
                            hotspot.value = newValue;
                            attrib = Selector.utf8ToBase64(hotspot);
                            init.setAttribute(HOTSPOT_ID, attrib);
                            modified.push(entity.uid);
                        }
                    }
                }

                info.modified = modified;
                MFS.printEntityUrls(info, modified, {text: `${name} - Modified "project.dpr" for:`});
            }

            MFS.print('Replacing IDs');
            const {ro, rw} = info;
            ro && replace(ro, info.migrationMap, 'RO');
            rw && replace(rw, info.migrationMap, 'RW');
        }

        /**
         * @param {Help4.MigrateFioriSpaces.MigrationInfo} info
         */
        static createLessonJS(info) {
            const {MigrateFioriSpaces: MFS} = Help4;

            /**
             * @param {Help4.MigrateFioriSpaces.DownloadInfo} info
             * @param {string} name
             */
            const create = (info, name) => {
                const {XmlHelper} = Help4.model.wpb;
                const {modified, entities, contextTypes} = info;

                for (const uid of modified) {
                    const index = Help4.indexOf(entities, 'uid', uid);
                    const entity = entities[index];
                    if (!entity) continue;

                    XmlHelper.set(entity.project_dpr);
                    XmlHelper.setMode(contextTypes[entity.uid].toLowerCase());
                    entity.lesson_js = XmlHelper.toLessonJs(entity.entity_txt);
                }

                MFS.printEntityUrls(info, modified, {text: `${name} - Created:`});
            }

            MFS.print('Creating "lesson.js"');
            const {ro, rw} = info;
            ro && create(ro, 'RO');
            rw && create(rw, 'RW');
        }

        /**
         * @param {Help4.MigrateFioriSpaces.MigrationInfo} info
         */
        static async save(info) {
            const {MigrateFioriSpaces: MFS} = Help4;

            /**
             * @param {Object} data
             * @param {string} delimiter
             * @return {string}
             */
            const createMultipartContent = (data, delimiter) => {
                const c = [];
                for (const [key, value] of Object.entries(data)) {
                    c.push(...createMultipartContentPart(key, value, delimiter));
                }
                c.push(...createMultipartContentPart('end', null, delimiter));
                return c.join('\r\n');
            }

            /**
             * @param {string} type
             * @param {string|null} data
             * @param {string} del
             * @return {string[]}
             */
            const createMultipartContentPart = (type, data, del) => {
                const d = {
                    meta: ['Content-Disposition: form-data; name="meta"'],
                    projectDpr: ['Content-Disposition: form-data; name="file"; filename="project.dpr"', 'Content-Type: text/html'],
                    lessonJs: ['Content-Disposition: form-data; name="file"; filename="lesson.js"', 'Content-Type: text/html']
                };

                let r = ['--part-' + del];

                if (d[type]) {
                    if (!data) return [];
                    r.push(...d[type], '', data);
                } else if (type === 'end') {
                    r[0] += '--';
                } else {
                    return [];
                }

                return r;
            }

            /**
             * @param {string} serverUrl
             * @param {string} xcsrf
             * @param {string} uid
             * @param {string} projectDpr
             * @param {string} lessonJs
             * @return {Promise<boolean>}
             */
            const save = (serverUrl, xcsrf, uid, projectDpr, lessonJs) => {
                const delimiter = Help4.createId();
                const meta = Help4.JSON.stringify({
                    files_complete: true,
                    send_mail_to_watchers: false
                });
                const content = createMultipartContent({meta, projectDpr, lessonJs}, delimiter);

                return new Help4.Promise(resolve => {
                    Help4.ajax.Ajax({
                        url: serverUrl + uid,
                        method: 'POST',
                        headers: {'Content-Type': 'multipart/form-data; boundary=part-' + delimiter},
                        dataType: 'text',
                        data: content,
                        saml: true,
                        xcsrf,
                        success: () => resolve(true),
                        error: () => resolve(false)
                    });
                });
            }

            /**
             * @param {Help4.MigrateFioriSpaces.DownloadInfo} info
             * @param {string} name
             * @return {Promise<void>}
             */
            const prepare = async (info, name) => {
                const {XmlHelper} = Help4.model.wpb;

                const {modified, entities, contextTypes, serverBaseUrl, waBase, xcsrf} = info;
                const serverUrl = `${serverBaseUrl}${waBase}/project/`;
                const saved = [], failed = [];

                for (const uid of modified) {
                    const index = Help4.indexOf(entities, 'uid', uid);
                    const entity = entities[index];
                    if (!entity) continue;

                    XmlHelper.set(entity.project_dpr);
                    XmlHelper.setMode(contextTypes[entity.uid].toLowerCase());

                    const xml = window.ActiveXObject
                        ? entity.project_dpr.xml
                        : (new XMLSerializer()).serializeToString(entity.project_dpr);
                    const json = Help4.JSON.stringify(entity.lesson_js);

                    const success = await save(serverUrl, xcsrf, uid, xml, json);
                    success ? saved.push(uid) : failed.push(uid);
                }

                info.saved = saved;
                info.failed = failed;

                saved.length && MFS.printEntityUrls(info, saved, {text: `${name} - Saved:`});
                failed.length && MFS.printEntityUrls(info, failed, {text: `${name} - Failed:`, error: true, abort: true});
            }

            MFS.print('Saving entities');
            const {ro, rw} = info;
            if (ro) await prepare(ro, 'RO');
            if (rw) await prepare(rw, 'RW');
        }
    }
})();