Source: controller/Persistence.js

(function() {
    /**
     * @typedef {Object} Help4.controller.Persistence.Status
     * @property {string[]} announcementsAlways
     * @property {string[]} announcementsOnce
     * @property {string[]} callouts
     * @property {string[]} hotspots
     * @property {string[]} whatsnew
     */

    const SERIALIZABLE = {
        p: {product: 'p', version: 'v', system: 's', screenId: 'i', editor: 'e'},
        t: {_mode: 'd', _context: 'x', _autoTourStarted: 't', _isEditorView: 'v', _minimized: 'z', _open: 'o', _activeMLTranslation: 'm'},
        c: {dataId: 'i', isWhatsNew: 'w'},
        h: '_handler',
        m: 'service.model',
        r: 'service.tracking',
        w: 'widget'
    };

    /** serialization handling for controller */
    Help4.controller.Persistence = class {
        static cmp4Data = null;

        /**
         * @param {Help4.controller.Controller} controller
         * @param {Object} [params = {}]
         * @param {boolean} [params.full = true] - whether full storage is enabled
         * @returns {Object}
         */
        static serialize(controller, {full = true} = {}) {
            const {
                _params,
                _screenConfig,
                /** @type {Help4.controller.CMP4} */ _cmp4
            } = controller;

            const o = {};

            const addCMP3 = (key, config, data) => {
                if (!data) return;

                o[key] = {};
                for (const [itemKey, itemShortcut] of Object.entries(config)) {
                    switch (typeof data[itemKey]) {
                        case 'undefined':
                            continue;  // skip
                        case 'boolean':
                            o[key][itemShortcut] = Number(data[itemKey]);
                            break;
                        default:
                            o[key][itemShortcut] = data[itemKey];
                            break;
                    }
                }
            }

            for (const [key, config] of Object.entries(SERIALIZABLE)) {
                switch (key) {
                    case 'h':
                    case 'm':
                    case 'r':
                        let s = !config.indexOf('service.')
                            ? controller.getService(config.replace(/service\./, ''))
                            : controller[config];
                        s = s?.serialize();
                        if (s) o[key] = s;
                        break;

                    case 'w':  // CMP4
                        let data = _cmp4?.serialize({full}) || this.cmp4Data;
                        if (data) data = _serializeCMP4.call(this, data, {full});  // minify footprint of CMP4 status
                        if (data) o[key] = data;
                        break;

                    case 'p': addCMP3(key, config, _params); break;
                    case 't': addCMP3(key, config, controller); break;
                    case 'c': addCMP3(key, config, _screenConfig); break;
                }
            }

            // not full: CMP4 only
            return full ? o : {w: o.w};
        }

        /**
         * @param {Object} data
         * @param {string} searchKey
         * @returns {*|null}
         */
        static getSerializedData(data, searchKey) {
            for (const [key, value] of Object.entries(SERIALIZABLE)) {
                if (value === searchKey) {
                    if (searchKey === 'widget') {  // CMP4
                        return _deserializeCMP4.call(this, data[key]);
                    } else {
                        return data[key];
                    }
                }
                if (!data[key] || typeof value !== 'object') continue;

                for (const [key2, value2] of Object.entries(value)) {
                    if (key2 === searchKey) return data[key][value2];
                }
            }

            return null;
        }

        /**
         * @param {Help4.controller.Controller} controller
         * @param {Object} obj
         * @param {Help4.typedef.SystemConfiguration} config
         * @returns {?Object}
         */
        static deserialize(controller, obj, config) {
            if (!obj) return null;

            const {
                DEFAULT_MODE,
                MODES: {helpEdit: helpEditMode}
            } = Help4.controller;

            // sanity: timeout
            const {_params} = controller;
            if (_params.multipageTimeout > 0) {
                const ts = new Date().getTime();
                if (ts - obj._ > _params.multipageTimeout) return null;
            }

            // sanity: mode is required
            let mode = this.getSerializedData(obj, '_mode');
            // sanity: mode is NOT required for CMP4; clamp to default if needed
            if (config.CMP4 && !mode) mode = DEFAULT_MODE;
            if (!mode) return null;

            // sanity: product, version & system match
            if (this.getSerializedData(obj, 'product') !== _params.product ||
                this.getSerializedData(obj, 'version') !== _params.version ||
                this.getSerializedData(obj, 'system') !== _params.system)
            {
                return null;
            }

            // sanity: helpEdit mode cross-app nav not supported
            if (mode === helpEditMode && this.getSerializedData(obj, 'screenId') !== _params.screenId) {
                mode = DEFAULT_MODE;
            }

            // we are on same or other screen
            // current project is contained within obj

            // set simple values
            const tracking = controller.getService('tracking');
            tracking.deserialize(this.getSerializedData(obj, 'service.tracking'));
            this._autoTourStarted = this.getSerializedData(obj, '_autoTourStarted') || null;
            this._isEditorView = !!this.getSerializedData(obj, '_isEditorView');
            this._context = this.getSerializedData(obj, '_context');
            this._activeMLTranslation = !!this.getSerializedData(obj, '_activeMLTranslation');
            this._minimized = !!this.getSerializedData(obj, '_minimized');
            this._open = !!this.getSerializedData(obj, '_open');

            // do not override edit mode switch from URL (edithelp=true or help-editor=1)
            if (!_params.urlConfig || _params.urlConfig.editor == null) {
                const model = controller.getService('model');
                model.setEditor(_params.editor = !!this.getSerializedData(obj, 'editor'));
            }

            // prepare load of current screen data
            const {_open, _minimized, _autoTourStarted} = this;
            return {
                mode,
                reason: 'deserialize',
                openHandler: _open,
                deserialize: obj,
                dataId: this.getSerializedData(obj, 'dataId'),
                isWhatsNew: !!this.getSerializedData(obj, 'isWhatsNew'),
                readCatalogue: _open || _params.readCatalogue,
                openMinimized: _minimized,
                autoStartTour: _autoTourStarted || _params.autoStartTour,
                carouselTab: this._context?.carouselTab
            }
        }

        /**
         * @param {Object} params
         * @param {?Object} params.standard
         * @param {?Object} params.cmp3session
         * @param {?Object} params.cmp3local
         * @returns {Object}
         */
        static migrate({standard, cmp3session, cmp3local}) {
            /**
             * - standard    - standard persistence object shared by CMP3 and CMP4
             * - cmp3session - used for splash="always" announcements: {@link Help4.COOKIE_KEYS.ANNOUNCEMENT}
             * - cmp3local   - used for non-always announcements, whatsnew, hotspot animation and callouts: {@link Help4.COOKIE_KEYS}
             */

            // get CMP3 information
            const {COOKIE_KEYS} = Help4;
            const cmp3 = /** @type {Help4.controller.Persistence.Status} */ {
                announcementsAlways: cmp3session?.[COOKIE_KEYS.ANNOUNCEMENT] || [],
                announcementsOnce: cmp3local?.[COOKIE_KEYS.ANNOUNCEMENT] || [],
                callouts: cmp3local?.[COOKIE_KEYS.CALLOUT] || [],
                hotspots: cmp3local?.[COOKIE_KEYS.HOTSPOT_ANIMATION] || [],
                whatsnew: cmp3local?.[COOKIE_KEYS.WHATS_NEW] || []
            };

            // get CMP4 information
            const {w: {s: {help = {}, whatsnew = {}} = {}} = {}} = standard || {};
            const {d: {view: {
                callouts = [],
                hotspotAnimation: helpHotspots = [],
                announcementsAlways = [],
                announcementsOnce = []
            } = {}} = {}} = help;
            const {d: {view: {
                hotspotAnimation: whatsnewHotspots = []
            } = {}} = {}} = whatsnew;
            const cmp4 = /** @type {Help4.controller.Persistence.Status} */ {
                announcementsAlways,
                announcementsOnce,
                callouts,
                hotspots: [...helpHotspots, ...whatsnewHotspots],
                whatsnew: []
            };

            // convert CMP3 status to CMP4
            const cmp4Update = _toCmp4.call(this, cmp3, cmp4);
            if (cmp4Update?.required) {
                const {
                    announcementsAlways,
                    announcementsOnce,
                    callouts,
                    hotspots,
                    whatsnew
                } = cmp4Update.data;
                const {
                    hasAnnouncementsAlways,
                    hasAnnouncementsOnce,
                    hasCallouts,
                    hasHotspots,
                    hasWhatsnew
                } = cmp4Update.has;

                standard ||= {};
                standard.w ||= {};
                standard.w.s ||= {};
                standard.w.s.help ||= {};
                standard.w.s.help.d ||= {};
                standard.w.s.help.d.view ||= {};
                standard.w.s.whatsnew ||= {};
                standard.w.s.whatsnew.d ||= {};
                standard.w.s.whatsnew.d.view ||= {};

                const {
                    help: {d: {view: helpView}},
                    whatsnew: {d: {view: whatsnewView}}
                } = standard.w.s;

                // the announcement & callouts information is full override: also contains CMP4 data
                if (hasAnnouncementsAlways) helpView.announcementsAlways = announcementsAlways;
                if (hasAnnouncementsOnce) helpView.announcementsOnce = announcementsOnce;
                if (hasCallouts) helpView.callouts = callouts;

                // the hotspots information is extend: does not contain CMP4 data
                if (hasHotspots) {
                    // we cannot know whether this hotspots are meant for help or whatsnew
                    // therefore we add all migrated hotspots to both lists
                    helpView.hotspotAnimation ||= [];
                    helpView.hotspotAnimation.push(...hotspots);
                    whatsnewView.hotspotAnimation ||= [];
                    whatsnewView.hotspotAnimation.push(...hotspots);
                }

                return standard;
            }

            return null;
        }
    }

    /**
     * reduces the CMP4 status to a minified version of it
     * @memberof Help4.controller.Persistence
     * @private
     * @param {Object} data
     * @param {Object} params
     * @param {boolean} params.full - whether full storage is enabled
     * @returns {?Object}
     */
    function _serializeCMP4(data, {full}) {
        /** see {@link Help4.controller.CMP4#serialize} */
        const minified = {};

        const {mltPreferredLanguage, mltTranslationActive, mltTargetLanguage, docked, position, status} = data;
        minified.mltpl = mltPreferredLanguage;
        minified.mltta = Number(mltTranslationActive);
        minified.mlttl = mltTargetLanguage;
        minified.d = Number(docked);
        if (position) {
            const {left, top} = position;
            minified.px = left;
            minified.py = top;
        }

        const s = {};
        for (const {name, started, active, visible, data} of Object.values(status)) {
            const o = {};
            if (full) {
                if (!started) o.s = 0;
                if (active) o.a = 1;
                if (!visible) o.v = 0;
            }

            if (data) {
                if (name === 'help' && typeof data?.view === 'object') {
                    // both information are stored in CMP3 data
                    delete data.view.announcementsAlways;
                    delete data.view.announcementsOnce;
                }

                if (typeof data === 'object') {
                    if (Object.keys(data).length) o.d = data;
                } else {
                    o.d = data;
                }
            }
            if (Object.keys(o).length) s[name] = o;
        }
        if (Object.keys(s).length) minified.s = s;

        return Object.keys(minified).length ? minified : null;
    }

    /**
     * extracts the CMP4 status from a minified version
     * @memberof Help4.controller.Persistence
     * @private
     * @param {Object} data
     */
    function _deserializeCMP4(data) {
        /** see {@link Help4.controller.CMP4#deserialize} */
        const {mltpl, mltta, mlttl, d, px, py, s: widgetInfo} = data || {};
        const full = {};

        full.mltPreferredLanguage = mltpl;
        full.mltTranslationActive = !!Number(mltta);
        full.mltTargetLanguage = mlttl;
        full.docked = !!Number(d);
        full.position = px == null ? null : {left: px, top: py};
        const status = full.status = {};

        if (widgetInfo) {
            for (const [widgetName, {s, a, v, d}] of Object.entries(widgetInfo)) {
                status[widgetName] = {
                    name: widgetName,
                    started: !!s,
                    active: !!a,
                    visible: !!v,
                    data: d || null
                }
            }
        }

        return full;
    }

    /**
     * converts a CMP3 status to CMP4
     * @memberof Help4.controller.Persistence
     * @private
     * @param {Help4.controller.Persistence.Status} cmp3
     * @param {Help4.controller.Persistence.Status} cmp4
     * @returns {{required: boolean, data: Help4.controller.Persistence.Status, has: {hasAnnouncementsAlways: boolean, hasAnnouncementsOnce: boolean, hasCallouts: boolean, hasHotspots: boolean, hasWhatsnew: boolean}}}
     */
    function _toCmp4(cmp3, cmp4) {
        const {PERSISTENCE_REGEXP} = Help4.widget.help.view2;
        const RX_FROM_CMP3 = /^.*?#(.*?)#.*?#(.*?)#(.*)$/;

        const replacerFromCmp3 = (store, all, version, screenId, tileId) => {
            store.push({version, screenId, tileId});
            return '';
        }
        const replacerFromCmp4 = (store, all, screenId, catalogueType, projectId, version, tileId) => {
            store.push({version, screenId, tileId});
            return '';
        }

        /**
         * @param {string} key
         * @returns {string[]}
         */
        const getMissing = key => {
            const storeCmp3 = [];
            cmp3[key].forEach(id => id.replace(RX_FROM_CMP3, (...args) => replacerFromCmp3(storeCmp3, ...args)));

            const storeCmp4 = [];
            cmp4[key].forEach(id => id.replace(PERSISTENCE_REGEXP, (...args) => replacerFromCmp4(storeCmp4, ...args)));

            return storeCmp3
            // remove duplicates
            .filter(({version: v3, screenId: s3, tileId: t3}) => !storeCmp4.find(({version: v4, screenId: s4, tileId: t4}) => v3 === v4 && s3 === s4 && t3 === t4))
            // will fill to asterisk '*' characters for catalogueType and projectId
            .map(({version, screenId, tileId}) => `${screenId}:*:*:${version}:${tileId}`);
        }

        return _mix.call(this, cmp4, {
            announcementsAlways: getMissing('announcementsAlways'),
            announcementsOnce: getMissing('announcementsOnce'),
            callouts: getMissing('callouts'),
            hotspots: getMissing('hotspots'),
            whatsnew: []
        }, {
            mixHotspots: false
        });
    }

    /**
     * mix the current and missing information
     * @memberof Help4.controller.Persistence
     * @private
     * @param {Help4.controller.Persistence.Status} current
     * @param {Help4.controller.Persistence.Status} missing
     * @param {Object} [params = {}]
     * @param {boolean} [params.mixAnnouncementsAlways = true]
     * @param {boolean} [params.mixAnnouncementsOnce = true]
     * @param {boolean} [params.mixCallouts = true]
     * @param {boolean} [params.mixHotspots = true]
     * @param {boolean} [params.mixWhatsnew = true]
     * @returns {{required: boolean, data: Help4.controller.Persistence.Status, has: {hasAnnouncementsAlways: boolean, hasAnnouncementsOnce: boolean, hasCallouts: boolean, hasHotspots: boolean, hasWhatsnew: boolean}}}
     */
    function _mix(current, missing, params = {}) {
        const {
            announcementsAlways,
            announcementsOnce,
            callouts,
            hotspots,
            whatsnew
        } = missing;
        const {
            mixAnnouncementsAlways = true,
            mixAnnouncementsOnce = true,
            mixCallouts = true,
            mixHotspots = true,
            mixWhatsnew = true
        } = params;

        const hasAnnouncementsAlways = !!announcementsAlways.length;
        const hasAnnouncementsOnce = !!announcementsOnce.length;
        const hasCallouts = !!callouts.length;
        const hasHotspots = !!hotspots.length;
        const hasWhatsnew = !!whatsnew.length;

        const required = hasAnnouncementsAlways ||
            hasAnnouncementsOnce ||
            hasCallouts ||
            hasHotspots ||
            hasWhatsnew;

        const data = {
            announcementsAlways: mixAnnouncementsAlways ? [...current.announcementsAlways, ...announcementsAlways] : [...announcementsAlways],
            announcementsOnce: mixAnnouncementsOnce ? [...current.announcementsOnce, ...announcementsOnce] : [...announcementsOnce],
            callouts: mixCallouts ? [...current.callouts, ...callouts] : [...callouts],
            hotspots: mixHotspots ? [...current.hotspots, ...hotspots] : [...hotspots],
            whatsnew: mixWhatsnew ? [...current.whatsnew, ...whatsnew] : [...whatsnew]
        };

        return {required, data, has: {
            hasAnnouncementsAlways,
            hasAnnouncementsOnce,
            hasCallouts,
            hasHotspots,
            hasWhatsnew
        }};
    }
})();