superdesk/superdesk-client-core

View on GitHub
scripts/core/utils.tsx

Summary

Maintainability
C
7 hrs
Test Coverage
import React from 'react';
import gettextjs from 'gettext.js';
import {appConfig, getUserInterfaceLanguage} from 'appConfig';
import {IVocabularyItem, IArticle, IBaseRestApiResponse, ILockInfo, IListViewFieldWithOptions} from 'superdesk-api';
import {assertNever} from './helpers/typescript-helpers';
import {isObject, omit} from 'lodash';
import formatISO from 'date-fns/formatISO';
import {IScope} from 'angular';
import {DEFAULT_LIST_CONFIG, CORE_PROJECTED_FIELDS, UI_PROJECTED_FIELD_MAPPINGS} from 'apps/search/constants';

export const DEFAULT_ENGLISH_TRANSLATIONS = {'': {'language': 'en', 'plural-forms': 'nplurals=2; plural=(n != 1);'}};

const language = getUserInterfaceLanguage();
const filename = `/languages/${language}.json?nocache=${Date.now()}`;

function applyTranslations(translations) {
    const langOverride = appConfig.langOverride ?? {};

    if (langOverride[language] != null) {
        Object.assign(translations, langOverride[language]);
    }

    window.translations = translations;
}

export function isMacOS() {
    if (
        navigator.userAgent.toLowerCase().includes('macintosh')
        || navigator.userAgent.toLowerCase().includes('mac os')
    ) {
        return true;
    }

    return false;
}

function requestListener() {
    const translations = JSON.parse(this.responseText);

    if (translations[''] == null || translations['']['language'] == null || translations['']['plural-forms'] == null) {
        throw new Error(`Language metadata not found in "${filename}"`);
    }

    applyTranslations(translations);
}

if (language === 'en') {
    applyTranslations(DEFAULT_ENGLISH_TRANSLATIONS);
} else {
    const req = new XMLHttpRequest();

    req.addEventListener('load', requestListener);
    req.open('GET', filename, false);
    req.send();
}

export const i18n = gettextjs();

if (window.translations != null) {
    const lang = window.translations['']['language'];

    i18n.setMessages(
        'messages',
        lang,
        window.translations,
        window.translations['']['plural-forms'],
    );

    i18n.setLocale(lang);
}

export type IScopeApply = (fn: () => void) => void;

export function stripHtmlTags(value) {
    const el = document.createElement('div');

    el.innerHTML = value;
    return el.innerText;
}

/** Does not mutate the original array. */
export function arrayInsert<T>(array: Array<T>, item: T, index: number): Array<T> {
    return array.slice(0, index).concat(item).concat(array.slice(index, array.length));
}

export const promiseAllObject = (promises) => new Promise((resolve, reject) => {
    const keys = Object.keys(promises);
    const promisesArray = keys.map((key) => promises[key]);

    return Promise.all(promisesArray)
        .then((promiseAllResults) => {
            const promiseResultsObject = keys.reduce((obj, key, i) => {
                obj[keys[i]] = promiseAllResults[i];
                return obj;
            }, {});

            resolve(promiseResultsObject);
        })
        .catch(reject);
});

export function getProjectedFieldsArticle(): Array<string> {
    const uiConfig = appConfig.list || DEFAULT_LIST_CONFIG;

    const uiFields = [
        ...(uiConfig.priority ?? []),
        ...(uiConfig.firstLine ?? []),
        ...(uiConfig.secondLine ?? []),
    ];

    const projectedFields: Set<string> = new Set();

    CORE_PROJECTED_FIELDS.fields.forEach((field) => {
        projectedFields.add(field);
    });

    uiFields.forEach((_field: string | IListViewFieldWithOptions) => {
        const field = typeof _field === 'string' ? _field : _field.field;

        const adjustedField = UI_PROJECTED_FIELD_MAPPINGS[field] ?? field;

        if (Array.isArray(adjustedField)) {
            adjustedField.forEach((__field) => {
                projectedFields.add(__field);
            });
        } else {
            projectedFields.add(adjustedField);
        }
    });

    return Array.from(projectedFields);
}

export function findParentScope(scope: IScope, predicate: (scope: IScope) => boolean): IScope | null {
    let current = scope.$parent;

    while (current != null) {
        if (predicate(current) === true) {
            return current;
        } else {
            current = current.$parent;
        }
    }
}

/**
 * Works the same way as `gettext`, except that it's possible to also use React components
 * as placeholders, not only strings.
 */
const gettextReact = (
    text: string,
    params: {[placeholder: string]: string | number | React.ComponentType},
): Array<JSX.Element> => {
    let matches: Array<{index: number, str: string, placeholder: string}> = [];

    for (const placeholder of Object.keys(params)) {
        /**
         * Multiple instances of a placeholder might be present.
         * Different placeholders may be mixed in between of each other.
         * The loop below will find all instances and push to `matches`.
         */
        let lastIndex = 0;

        let match = text.slice(lastIndex, text.length).match(new RegExp(`{{\\s*${placeholder}\\s*}}`));

        while (match != null) {
            matches.push({index: lastIndex + match.index, str: match['0'], placeholder: placeholder});

            lastIndex += match.index + match['0'].length;
            match = text.slice(lastIndex, text.length).match(new RegExp(`{{\\s*${placeholder}\\s*}}`));
        }
    }

    matches = matches.sort((a, b) => a.index - b.index);

    const result: Array<JSX.Element> = [];

    let fromIndex = 0;

    for (const match of matches) {
        const plainText = text.slice(fromIndex, match.index);

        result.push(<span key={fromIndex + '-str'}>{plainText}</span>);

        const Replacement = params[match.placeholder];

        if (typeof Replacement === 'function') {
            result.push(<Replacement key={fromIndex} />);
        } else {
            result.push(<span key={fromIndex}>{Replacement}</span>);
        }

        fromIndex = match.index + match.str.length;
    }

    const endText = text.slice(fromIndex, text.length);

    result.push(<span key={fromIndex}>{endText}</span>);

    return result;
};

// example: gettext('Item was locked by {{user}}.', {user: 'John Doe'});
export const gettext = (
    text: string,
    params: {[placeholder: string]: string | number | React.ComponentType} = {},
) => {
    if (!text) {
        return '';
    }

    let translated = i18n.gettext(text);

    const hasReactPlaceholders = Object.values(params ?? {}).some((val) => typeof val === 'function');

    if (hasReactPlaceholders) {
        return gettextReact(translated, params ?? {});
    } else {
        Object.keys(params ?? {}).forEach((param) => {
            translated = translated.replace(new RegExp(`{{\\s*${param}\\s*}}`, 'g'), params[param]);
        });

        return translated;
    }
};

/*
    Example:

    gettextPlural(
        6,
        'Item was locked by {{user}}.',
        '{{count}} items were locked by multiple users.',
        {count: 6, user: 'John Doe'},
    );
*/
export const gettextPlural = (
    count: number,
    text: string,
    pluralText: string,
    params: {[key: string]: string | number | React.ComponentType} = {},
): string => {
    if (!text) {
        return '';
    }

    let translated = i18n.ngettext(text, pluralText, count);

    Object.keys(params ?? {}).forEach((param) => {
        translated = translated.replace(new RegExp(`{{\\s*${param}\\s*}}`), params[param]);
    });

    return translated;
};

/**
 * Escape given string for reg exp
 *
 * @url https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
 *
 * @param {string} string
 * @return {string}
 */
export function escapeRegExp(string) {
    return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

export function getVocabularyItemNameTranslated(term: IVocabularyItem, _lang?: string) {
    const _language = _lang ?? getUserInterfaceLanguage();

    // FIXME: Remove replacing _/- when language codes are normalized on the server.

    return term.translations?.name?.[_language]
        ?? term.translations?.name?.[_language.replace('_', '-')]
        ?? term.name;
}

export function translateArticleType(type: IArticle['type']) {
    switch (type) {
    case 'audio':
        return gettext('audio');
    case 'composite':
        return gettext('composite');
    case 'graphic':
        return gettext('graphic');
    case 'picture':
        return gettext('picture');
    case 'preformatted':
        return gettext('preformatted');
    case 'text':
        return gettext('text');
    case 'video':
        return gettext('video');
    default:
        assertNever(type);
    }
}

export function getUserSearchMongoQuery(searchString: string) {
    return {
        $or: [
            {username: {$regex: searchString, $options: 'i'}},
            {display_name: {$regex: searchString, $options: 'i'}},
            {first_name: {$regex: searchString, $options: 'i'}},
            {last_name: {$regex: searchString, $options: 'i'}},
            {email: {$regex: searchString, $options: 'i'}},
            {sign_off: {$regex: searchString, $options: 'i'}},
        ],
    };
}

export function getItemTypes() {
    const item_types = [
        {type: 'all', label: gettext('all')},
        {type: 'text', label: gettext('text')},
        {type: 'picture', label: gettext('picture')},
        {type: 'graphic', label: gettext('graphic')},
        {type: 'composite', label: gettext('package')},
        {type: 'highlight-pack', label: gettext('highlights package')},
        {type: 'video', label: gettext('video')},
        {type: 'audio', label: gettext('audio')},
    ];

    return item_types.filter(
        (item) => (
            appConfig.features.hideCreatePackage ?
                item.type !== 'composite' && item.type !== 'highlight-pack' :
                true
        ));
}

type IWeekday =
    'sunday'
    | 'monday'
    | 'tuesday'
    | 'wednesday'
    | 'thursday'
    | 'friday'
    | 'saturday';

export function getWeekDayIndex(weekday: IWeekday): number {
    return [
        'sunday',
        'monday',
        'tuesday',
        'wednesday',
        'thursday',
        'friday',
        'saturday',
    ].indexOf(weekday);
}

export function isElasticDateFormat(date: string) {
    return date.startsWith('now+') || date.startsWith('now-');
}

export function isScrolledIntoViewVertically(element: HTMLElement, container: HTMLElement): boolean {
    const elementTop = element.offsetTop;
    const elementBottom = element.offsetTop + element.offsetHeight;

    const topVisible = elementTop >= container.scrollTop;
    const bottomVisible = elementBottom < container.scrollTop + container.offsetHeight;

    return topVisible && bottomVisible;
}

/**
 * Note: `{a: false}` will be converted to '?a=false'.
 * If you need to exclude keys when value is `false`,
 * do so before passing the object to this function.
 */
export function toQueryString(
    params: {}, // key value pairs e.g. {}
): string {
    if (Object.keys(params).length < 1) {
        return '';
    }

    return '?' + Object.keys(params).map((key) =>
        `${key}=${isObject(params[key]) ? JSON.stringify(params[key]) : encodeURIComponent(params[key])}`,
    ).join('&');
}

/**
 * Output example: "1970-01-19T22:57:38"
 */
export function toServerDateFormat(date: Date): string {
    return formatISO(date).slice(0, 19);
}

/**
 * Parse server date without timezone so it won't convert it to local timezone.
 */
export function fromServerDateFormat(date: string): Date {
    return new Date(date.slice(0, 19));
}

export function getArticleLabel(item: IArticle): string {
    const headlineTrimmed = item.headline?.trim();
    const sluglineTrimmed = item.slugline?.trim();

    if (headlineTrimmed?.length > 0) {
        return headlineTrimmed;
    } else if (sluglineTrimmed?.length > 0) {
        return sluglineTrimmed;
    } else {
        return `[${gettext('Untitled')}]`;
    }
}

export function downloadFile(data: string, mimeType: string, fileName: string) {
    const a = document.createElement('a');

    document.body.appendChild(a);
    const blob = new Blob([data], {type: mimeType}),
        url = window.URL.createObjectURL(blob);

    a.href = url;
    a.download = fileName;
    a.click();
    window.URL.revokeObjectURL(url);
    a.remove();
}

export function stripBaseRestApiFields<T extends {}>(entity: T): T {
    type IKeys = { [P in keyof Required<IBaseRestApiResponse>]: 1 };

    const keysObject: IKeys = {
        _updated: 1,
        _created: 1,
        _id: 1,
        _etag: 1,
        _links: 1,
        _status: 1,
        _current_version: 1,
        _latest_version: 1,
    };

    const keysArray = Object.keys(keysObject);

    return omit(entity, keysArray) as T;
}

export function stripLockingFields<T extends {}>(entity: T): T {
    type IKeys = { [P in keyof Required<ILockInfo>]: 1 };

    const keysObject: IKeys = {
        _lock: 1,
        _lock_action: 1,
        _lock_session: 1,
        _lock_expiry: 1,
        _lock_time: 1,
        _lock_user: 1,
    };

    const keysArray = Object.keys(keysObject);

    return omit(entity, keysArray) as T;
}