(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
}};
}
})();