superdesk/superdesk-client-core

View on GitHub
scripts/apps/authoring/metadata/metadata.ts

Summary

Maintainability
F
2 wks
Test Coverage
import _ from 'lodash';
import PreferedCvItemsConfigDirective from './PreferedCvItemsConfigDirective';
import MetaPlaceDirective from './MetaPlaceDirective';
import {getVocabularySelectionTypes} from '../../vocabularies/constants';
import {gettext} from 'core/utils';
import PlacesServiceFactory from './PlacesService';
import {appConfig} from 'appConfig';
import {IArticle, ISubject, IVocabularyItem} from 'superdesk-api';
import {reactToAngular1} from 'superdesk-ui-framework';
import {MetaDataDropdownSingleSelectReact} from './views/MetaDataDropdownSingleSelectReact';
import {sdApi} from 'api';
import {PublishingTargetSelect} from 'core/interactive-article-actions-panel/subcomponents/publishing-target-select';

MetadataCtrl.$inject = [
    '$scope', 'desks', 'metadata', 'privileges', 'datetimeHelper', 'userList',
    'preferencesService', 'archiveService', 'moment', 'content',
];

function getSingleSelection() {
    return getVocabularySelectionTypes().SINGLE_SELECTION.id;
}

const DISABLE_CHILDREN_SEARCH_FIELDS = ['authors'];

function MetadataCtrl(
    $scope, desks, metadata, privileges, datetimeHelper, userList,
    preferencesService, archiveService, moment, content) {
    desks.initialize();

    $scope.change_profile = appConfig.item_profile != null && appConfig.item_profile.change_profile === 1 &&
                            _.get($scope, 'origItem.type') === 'text';

    metadata.initialize().then(() => {
        $scope.metadata = metadata.values;
        return preferencesService.get();
    })
        .then(setAvailableCategories)
        .then(setAvailableCompanyCodes);

    $scope.$watch(() => desks.active.desk, (activeDeskId) => {
        content.getDeskProfiles(activeDeskId ? desks.getCurrentDesk() : null, $scope.item.profile)
            .then((profiles) => {
                $scope.desk_content_types = profiles;
            });
    });

    $scope.processGenre = function() {
        $scope.item.genre = _.map($scope.item.genre, (g) => _.pick(g, 'name'));
    };

    /**
    * Builds a list of categories available for selection in scope. Used by
    * the "category" menu in the Authoring metadata section.
    *
    * @function setAvailableCategories
    * @param {Object} prefs - user preferences setting, including the
    *   preferred categories settings, among other things
    */
    function setAvailableCategories(prefs) {
        var all, // all available categories
            filtered,

            // user's category preference settings , i.e. a map
            // object (<category_code> --> true/false)
            userPrefs;

        all = metadata.values.categories || [];
        userPrefs = prefs['categories:preferred'].selected;
        filtered = _.filter(all, (cat) => _.isEmpty(userPrefs) || userPrefs[cat.qcode]);
        $scope.availableCategories = _.sortBy(filtered, 'name');
    }

    /**
    * Builds a list of company_codes available for selection in scope. Used by
    * the "company_codes" menu in the Authoring metadata section.
    *
    * @function setAvailableCompanyCodes
    */
    function setAvailableCompanyCodes() {
        var all, // all available company codes
            assigned = {}, // company codes already assigned to the article
            filtered,
            itemCompanyCodes; // existing company codes assigned to the article

        all = _.cloneDeep(metadata.values.company_codes || []);

        all.forEach((companyCode) => {
            companyCode.name = companyCode.name + ' (' + companyCode.qcode + ')';
        });

        // gather article's existing company codes
        itemCompanyCodes = $scope.item.company_codes || [];

        itemCompanyCodes.forEach((companyCode) => {
            assigned[companyCode.qcode] = true;
        });

        filtered = _.filter(all, (companyCode) => !assigned[companyCode.qcode]);

        $scope.availableCompanyCodes = _.sortBy(filtered, 'name');
    }

    $scope.$watch('item.publish_schedule_date', (newValue, oldValue) => {
        setPublishScheduleDate(newValue, oldValue);
    });

    $scope.$watch('item.publish_schedule_time', (newValue, oldValue) => {
        setPublishScheduleDate(newValue, oldValue);
    });

    $scope.$watch('item.time_zone', (newValue, oldValue) => {
        if ((newValue || oldValue) && newValue !== oldValue) {
            setTimeZone();
            setPublishScheduleDate(newValue, oldValue);
            setEmbargoTS(newValue, oldValue);

            if (!$scope.item.publish_schedule && !$scope.item.embargo) {
                $scope.item.schedule_settings = null;
            }
        }
    });

    function setTimeZone() {
        $scope.item.schedule_settings = {};
        if (!$scope.item.time_zone) {
            $scope.item.schedule_settings.time_zone = null;
        } else {
            $scope.item.schedule_settings.time_zone = $scope.item.time_zone;
        }
    }

    function setPublishScheduleDate(newValue, oldValue) {
        if ((newValue || oldValue) && newValue !== oldValue) {
            if ($scope.item.publish_schedule_date && $scope.item.publish_schedule_time) {
                $scope.item.publish_schedule = datetimeHelper.mergeDateTime(
                    $scope.item.publish_schedule_date,
                    $scope.item.publish_schedule_time,
                    $scope.item.time_zone,
                );
                setTimeZone();
            } else {
                $scope.item.publish_schedule = null;
            }

            $scope.autosave($scope.item);
        }
    }

    $scope.$watch('item.embargo_date', (newValue, oldValue) => {
        // set embargo time default on initial date selection
        if (newValue && oldValue === undefined) {
            $scope.item.embargo_time = moment('00:01', 'HH:mm')
                .format(appConfig.model.timeformat);
        }

        setEmbargoTS(newValue, oldValue);
    });

    $scope.$watch('item.embargo_time', (newValue, oldValue) => {
        setEmbargoTS(newValue, oldValue);
    });

    /**
     * Listener method which gets invoked when either Embargo Date or Embargo Time has changed. This function takes
     * values of both Embargo Date and Embargo Time to form Timestamp.
     */
    function setEmbargoTS(newValue, oldValue) {
        if ((newValue || oldValue) && newValue !== oldValue) {
            if ($scope.item.embargo_date && $scope.item.embargo_time) {
                $scope.item.embargo = datetimeHelper.mergeDateTime(
                    $scope.item.embargo_date,
                    $scope.item.embargo_time,
                    $scope.item.time_zone,
                );
                setTimeZone();
            } else {
                $scope.item.embargo = null;
            }

            $scope.autosave($scope.item);
        }
    }

    /**
     * Publish Schedule and Embargo are saved as Timestamps in DB but each field has date and time as two different
     * inputs in UI. This function breaks the timestamp fetched from API to Date and Time and assigns those values to
     * the appropriate field.
     */
    function resolvePublishScheduleAndEmbargoTS() {
        var info;
        var embargo = datetimeHelper.removeTZ($scope.item.embargo);
        var publishSchedule = datetimeHelper.removeTZ($scope.item.publish_schedule);

        if ($scope.item.schedule_settings) {
            $scope.item.time_zone = $scope.item.schedule_settings.time_zone;
            if ($scope.item.schedule_settings.utc_embargo) {
                embargo = $scope.item.schedule_settings.utc_embargo;
            }

            if ($scope.item.schedule_settings.utc_publish_schedule) {
                publishSchedule = $scope.item.schedule_settings.utc_publish_schedule;
            }
        }

        if (embargo) {
            info = datetimeHelper.splitDateTime(embargo, $scope.item.time_zone);
            $scope.item.embargo_date = info.date;
            $scope.item.embargo_time = info.time;
        }

        if (publishSchedule) {
            info = datetimeHelper.splitDateTime(publishSchedule, $scope.item.time_zone);
            $scope.item.publish_schedule_date = info.date;
            $scope.item.publish_schedule_time = info.time;
        }
    }

    $scope.unique_name_editable = !!privileges.privileges.metadata_uniquename;

    resolvePublishScheduleAndEmbargoTS();

    metadata.getFilteredCustomVocabularies([]).then((cvs) => {
        $scope.genreInCvs = _.map(cvs, 'schema_field').indexOf('genre') !== -1;
        $scope.placeInCvs = _.map(cvs, 'schema_field').indexOf('place') !== -1;
    });

    $scope.originalCreator = $scope.item.original_creator;
    $scope.versionCreator = $scope.item.version_creator;

    if (!archiveService.isLegal($scope.item)) {
        if ($scope.item.original_creator) {
            userList.getUser($scope.item.original_creator)
                .then((user) => {
                    $scope.originalCreator = user.display_name;
                });
        }
        if ($scope.item.version_creator) {
            userList.getUser($scope.item.version_creator)
                .then((user) => {
                    $scope.versionCreator = user.display_name;
                });
        }
    }
}

MetaTargetedPublishingDirective.$inject = [];
function MetaTargetedPublishingDirective() {
    return {
        scope: {
            list: '=',
            disabled: '=ngDisabled',
            targets: '=',
            autosave: '&',
        },
        templateUrl: 'scripts/apps/authoring/metadata/views/metadata-target-publishing.html',
        link: function(scope, elem) {
            scope.removeTarget = function(target) {
                scope.targets = _.without(scope.targets, target);
                scope.autosave();
            };

            scope.addTarget = function(target) {
                if (angular.isUndefined(scope.targets)) {
                    scope.targets = [];
                }

                let parsedTarget = JSON.parse(target);

                var existing = _.find(scope.targets,
                    {qcode: parsedTarget.qcode, name: parsedTarget.name, allow: !scope.deny});

                if (!existing) {
                    scope.targets.push({qcode: parsedTarget.qcode, name: parsedTarget.name, allow: !scope.deny});
                    scope.autosave();
                }

                reset();
            };

            function reset() {
                scope.target = '';
                scope.deny = false;
            }

            scope.canAddTarget = function() {
                return scope.disabled || !scope.target || scope.target === '';
            };
        },
    };
}

MetadropdownFocusDirective.$inject = ['keyboardManager'];
function MetadropdownFocusDirective(keyboardManager) {
    return {
        require: 'dropdown',
        link: function(scope, elem, attrs, dropdown) {
            scope.$watch(dropdown.isOpen, (isOpen) => {
                if (isOpen) {
                    _.defer(() => {
                        var keyboardOptions = {inputDisabled: false, propagate: false};
                        // narrow the selection to consider only dropdown list's button items
                        var buttonList = elem.find('.dropdown__menu button');

                        if (buttonList.length > 0) {
                            buttonList[0].focus();
                        }

                        keyboardManager.push('up', () => {
                            if (buttonList.length > 0) {
                                var focusedElem = elem.find('button:focus')[0];
                                var indexValue = _.findIndex(buttonList, (chr) => chr === focusedElem);
                                // select previous item on key UP

                                if (indexValue > 0 && indexValue < buttonList.length) {
                                    buttonList[indexValue - 1].focus();
                                }
                            }
                        }, keyboardOptions);

                        keyboardManager.push('down', () => {
                            if (buttonList.length > 0) {
                                var focusedElem = elem.find('button:focus')[0];
                                var indexValue = _.findIndex(buttonList, (chr) => chr === focusedElem);
                                // select next item on key DOWN

                                if (indexValue < buttonList.length - 1) {
                                    buttonList[indexValue + 1].focus();
                                }
                            }
                        }, keyboardOptions);
                    });
                } else if (isOpen === false) {
                    keyboardManager.pop('down');
                    keyboardManager.pop('up');
                }
            });
        },
    };
}

MetaDropdownDirective.$inject = ['$filter', 'metadata'];
function MetaDropdownDirective($filter, metadata) {
    return {
        scope: {
            list: '=',
            disabled: '=ngDisabled',
            item: '=',
            field: '@',
            icon: '@',
            label: '@',
            change: '&',
            cv: '=',
            key: '@',
            tabindex: '=',
            containingDirective: '@',
        },
        templateUrl: 'scripts/apps/authoring/metadata/views/metadata-dropdown.html',
        link: function(scope, elem) {
            scope.multiInputFields = ['place', 'genre', 'anpa_category', 'subject', 'authors'];
            scope.getLocaleName = metadata.getLocaleName;

            scope.select = function(item) {
                var fieldObject: {[fieldId: string]: any} = {};
                const isMultiInputField = scope.multiInputFields.includes(scope.field);

                if (item) {
                    if (!isMultiInputField) {
                        // single input field
                        // we use 'name' attribute for string fields and the whole object for other fields
                        fieldObject[scope.field] = scope.key ? item[scope.key] : (item.name || item);
                    } else if (scope.cv && scope.cv._id != null) {
                        // if there is cv as well as field, store cv._id as scheme
                        // so that it can be differentiated from another cv inside same parent field(subject).
                        // ex: subject:[{name: "a", qcode: "a", scheme: "new-cv"}]
                        item.scheme = scope.cv._id;
                        fieldObject[scope.field] = (scope.item[scope.field] || []).filter((v) =>
                            v.scheme !== scope.cv._id);
                        fieldObject[scope.field].push(item);
                    } else {
                        fieldObject[scope.field] = scope.key ? item[scope.key] : [item];
                    }
                } else if (scope.cv) {
                    // if there is cv as well as field don't set fieldObject[scope.field] to null.
                    // as in the backend it is set to "nullable: false" for ex: subject.
                    fieldObject[scope.field] = scope.item[scope.field].filter((v) => v.scheme !== scope.cv._id);
                } else if (isMultiInputField) {
                    fieldObject[scope.field] = [];
                } else {
                    fieldObject[scope.field] = null;
                }

                _.extend(scope.item, fieldObject);
                scope.change({item: scope.item, field: scope.field});

                // retain focus on same dropdown control after selection.
                _.defer(() => {
                    elem.find('.dropdown__toggle').focus();
                });

                if (scope.values) {
                    scope.selected = scope.values[fieldObject[scope.field]] || null;
                }

                // is needed when `select` is called from react component e.g. MetaDataDropdownSingleSelectReact
                scope.$applyAsync();
            };

            scope.$watch(':: list', () => {
                scope.values = _.keyBy(scope.list, 'qcode');
            });

            // UI binding assignment only if we are editing a content profile (NOT in authoring header or other places)
            if (scope.containingDirective === 'sdContentSchemaEditor') {
                scope.item[scope.field] = scope.item.default;
            }

            scope.findItemByScheme = (items: Array<ISubject>, scheme: string) => {
                const term = items.find((item) => item.scheme === scheme);

                return metadata.getLocaleName(term, scope.item);
            };

            scope.$applyAsync(() => {
                if (scope.list) {
                    if (scope.field === 'place') {
                        scope.places = _.groupBy(scope.list, 'group');
                    } else if (scope.field === 'genre') {
                        scope.list = $filter('sortByName')(scope.list);
                    }
                }
            });
        },
    };
}

MetaTagsDirective.$inject = ['api', '$timeout'];
function MetaTagsDirective(api, $timeout) {
    var ENTER = 13;
    var ESC = 27;

    return {
        scope: {
            item: '=',
            field: '@',
            sourceField: '@',
            change: '&',
            disabled: '=',
        },
        templateUrl: 'scripts/apps/authoring/metadata/views/metadata-tags.html',
        link: function(scope, element) {
            var inputElem = element.find('input')[0];

            scope.adding = false;
            scope.refreshing = false;
            scope.newTag = null;
            scope.tags = null;
            scope.extractedTags = null;
            scope.item[scope.field] = scope.item[scope.field] || [];

            var add = function(tag) {
                scope.tags.push(tag);
                scope.tags = _.uniq(scope.tags);
                scope.toggle(tag);
                cancel();
            };

            var cancel = function() {
                scope.newTag = null;
                scope.adding = false;
            };

            scope.$watch('adding', () => {
                if (scope.adding) {
                    $timeout(() => {
                        inputElem.focus();
                    }, 0, false);
                }
            });

            scope.key = function($event) {
                if ($event.keyCode === ENTER && !$event.shiftKey) {
                    add(scope.newTag);
                } else if ($event.keyCode === ESC && !$event.shiftKey) {
                    cancel();
                }
            };

            scope.isSelected = function(tag) {
                return scope.item[scope.field].indexOf(tag) !== -1;
            };

            scope.toggle = function(tag) {
                if (!scope.disabled) {
                    if (scope.isSelected(tag)) {
                        _.pull(scope.item[scope.field], tag);
                    } else {
                        scope.item[scope.field].push(tag);
                    }
                    scope.change({item: scope.item});
                }
            };

            scope.refresh = function() {
                scope.refreshing = true;
                var body = (scope.item[scope.sourceField] || '')
                    .replace(/<br[^>]*>/gi, '&nbsp;')
                    .replace(/(<figcaption\b[^>]*>)[^<>]*(<\/figcaption>)/gi, '')
                    .replace(/<\/?[^>]+>/gi, '')
                    .trim()
                    .replace(/&nbsp;/g, ' ');

                if (body) {
                    api.save('keywords', {text: body})
                        .then((result) => {
                            scope.extractedTags = _.map(result.keywords, 'text');
                            scope.tags = _.uniq(scope.extractedTags.concat(scope.item[scope.field]));
                            scope.refreshing = false;
                        });
                } else {
                    scope.refreshing = false;
                }
            };

            scope.refresh();
        },
    };
}

MetaWordsListDirective.$inject = [];
function MetaWordsListDirective() {
    return {
        scope: {
            item: '=',
            field: '@',
            disabled: '=',
            list: '=',
            change: '&',
            header: '@',
            style: '@',
        },
        template: require('./views/metadata-words-list.html'),
        link: function(scope, element) {
            scope.words = [];
            scope.selectedTerm = '';

            scope.$applyAsync(() => {
                element.find('input, select').addClass('line-input');

                if (scope.list) {
                    scope.words = scope.list;
                }
            });

            /**
             * sdTypeahead directive invokes this method and is responsible for searching word(s) where the word.name
             * matches wordToFind.
             *
             * @return {Array} list of word(s)
             */
            scope.search = function(wordToFind) {
                if (!wordToFind) {
                    scope.words = scope.list;
                } else {
                    scope.words = _.filter(scope.list,
                        (t) => t.name.toLowerCase().indexOf(wordToFind.toLowerCase()) !== -1);
                }

                scope.selectedTerm = wordToFind;
                return scope.words;
            };

            /**
             * sdTypeahead directive invokes this method and is responsible for updating the item with user selected
             * word.
             *
             * @param {Object} item selected word object
             */
            scope.select = function(item) {
                var keyword: any = item ? item.qcode : scope.selectedTerm;
                var t: any = _.clone(scope.item[scope.field]) || [];
                var index = _.findIndex(t, (word: any) => word.toLowerCase() === keyword.toLowerCase());

                if (index < 0) {
                    t.push(keyword);

                    var o = {};

                    o[scope.field] = t;
                    _.extend(scope.item, o);
                    scope.change({item: scope.item});
                }

                scope.selectedTerm = '';
            };

            /**
             * Removes the term from the user selected terms
             */
            scope.removeTerm = function(term) {
                var temp = scope.item[scope.field].filter((item) => !_.isEqual(item, term));

                // build object
                var o = {};

                o[scope.field] = temp;

                _.extend(scope.item, o);

                scope.change({item: scope.item});
            };
        },
    };
}

/**
 * Wraping  'sd-typeahead' directive for editing of metadata list attributes
 *
 * @param {Object} item - specify the content item itself
 * @param {String} field - specify the (metadata) filed under the item which will be edited
 * @param {Boolean} disabled - whether component should be disabled for editing or not
 * @param {Array} list - list of available values that can be added
 * @param {String} unique - specify the name of the field, in list item which is unique (qcode, value...)
 * @param {Boolean} searchUnique - to search unique field as well as name field
 * @param {Boolean} selectEntireCategory - to allow a whole category to be selected (i.e. field without parent)
 *
 */
MetaTermsDirective.$inject = ['metadata', '$filter', '$timeout', 'preferencesService', 'desks'];
function MetaTermsDirective(metadata, $filter, $timeout, preferencesService, desks) {
    return {
        scope: {
            item: '=',
            field: '@',
            disabled: '=ngDisabled',
            list: '=',
            unique: '@',
            postprocessing: '&',
            change: '&',
            header: '@',
            reloadList: '@',
            cv: '=',
            includeParent: '@',
            tabindex: '=',
            searchUnique: '@',
            setLanguage: '@',
            helperText: '@',
            disableEntireCategory: '@',
            ignoreScheme: '@',

            /**
             * Placeholder is used in multi-editing.
             * If items have different values, placeholder is shown instead of values.
             */
            placeholder: '=',
        },
        templateUrl: 'scripts/apps/authoring/metadata/views/metadata-terms.html',
        link: function MetaTermsDirectiveLink(scope, elem, attrs) {
            metadata.subjectScope = scope;
            scope.getLocaleName = metadata.getLocaleName;
            const reloadList = scope.reloadList === 'true';
            const includeParent = scope.includeParent === 'true';
            const searchUnique = scope.searchUnique === 'true';
            const disabledChildrenSearch = DISABLE_CHILDREN_SEARCH_FIELDS.includes(scope.field);
            const ignoreScheme = scope.ignoreScheme === 'true';

            scope.allowEntireCat = scope.disableEntireCategory !== 'true';

            scope.combinedList = [];

            scope.tree = {};
            scope.termPath = [];

            scope.$watch('unique', (value) => {
                scope.uniqueField = value || 'qcode';
            });

            // add scheme to terms
            if (scope.list && scope.cv) {
                scope.list.forEach((listItem) => {
                    listItem.scheme = scope.cv._id;
                });
            }

            scope.$watchCollection('list', (items) => {
                if (!items || items.length === 0) {
                    return;
                }

                var tree: any = {}, updates = {};

                if (scope.cv && scope.cv.dependent) {
                    updates[scope.field] = [];
                }

                items.forEach((item) => {
                    var parent = item.parent || null;

                    if (!tree.hasOwnProperty(parent)) {
                        tree[parent] = [item];
                    } else {
                        tree[parent].push(item);
                    }
                    // checks for dependent dropdowns to remain selected items if new list has them (not to reset)
                    angular.forEach(scope.item[scope.field], (selectedItem) => {
                        if (scope.cv && scope.cv.dependent) {
                            if (selectedItem.scheme === scope.cv._id) {
                                if (item.name === selectedItem.name) {
                                    updates[scope.field].push(selectedItem);
                                }
                                // this is for subject (which is not dependent)
                            } else if (updates[scope.field].indexOf(selectedItem) === -1) {
                                updates[scope.field].push(selectedItem);
                            }
                        }
                    });
                });

                _.extend(scope.item, updates);

                scope.terms = filterSelected(items);
                scope.tree = tree;
                scope.activeTree = tree.null;
                scope.combinedList = _.union(scope.list, scope.item[scope.field] ? scope.item[scope.field] : []);
                setPreferredItems();
            });

            scope.$watch('item[field]', (selected) => {
                if (!selected) {
                    scope.selectedItems = [];
                    return;
                }

                scope.terms = filterSelected(scope.list);
                if (_.get(scope, 'cv._id')) { // filter out items from current cv
                    scope.selectedItems = selected.filter((term) => term.scheme === (scope.cv._id || scope.cv.id));
                } else {
                    scope.selectedItems = selected.filter(
                        (term) => ignoreScheme === true || !term.scheme || term.scheme === scope.field,
                    );
                }
            }, true);

            scope.$on('$destroy', () => {
                metadata.subjectScope = null;
            });

            scope.openParent = function(term, $event) {
                var parent = _.find(scope.list, {[scope.uniqueField]: term.parent});

                scope.openTree(parent, $event);
            };

            scope.openTree = function(term, $event) {
                if ($event.altKey) {
                    let activeTree = scope.tree[term ? term[scope.uniqueField] : null];

                    return angular.forEach(activeTree, (_term) => {
                        scope.selectTerm(_term, $event);
                    });
                }

                scope.activeTerm = term;
                scope.termPath.push(term);
                scope.activeTree = scope.tree[term ? term[scope.uniqueField] : null];
                $event.stopPropagation();
                _.defer(() => {
                    const el = elem.find('button:not([disabled]):not(.dropdown__toggle)');

                    if (
                        typeof el === 'object'
                        && el != null
                        && typeof el[0] === 'object'
                        && typeof el[0].focus === 'function'
                    ) {
                        el[0].focus();
                    }
                });
                scope.activeList = false;
            };

            scope.isSelected = (term) => {
                return !!_.find(scope.item[scope.field], term);
            };

            scope.activeList = false;
            scope.selectedTerm = '';

            scope.searchTerms = function(term) {
                if (!term) {
                    scope.terms = filterSelected(scope.list);
                    scope.activeList = false;
                } else {
                    let searchList;

                    if (disabledChildrenSearch) {
                        searchList = scope.list.filter((item) => !item.parent);
                    } else if (reloadList) {
                        searchList = scope.list;
                    } else {
                        searchList = scope.combinedList;
                    }

                    scope.terms = $filter('sortByName')(_.filter(filterSelected(searchList), (t) => {
                        var searchObj = {};
                        const termLower = term.toLowerCase();
                        // if there are translations available search term from the translations
                        const searchFromTranslations = t.translations != null && t.translations.name != null
                            && t.translations.name[scope.item.language] != null;

                        searchObj[scope.uniqueField] = t[scope.uniqueField];

                        if (searchFromTranslations) {
                            return t.translations.name[scope.item.language].toLowerCase().includes(termLower)
                                && !_.find(scope.item[scope.field], searchObj);
                        }

                        if (searchUnique) {
                            // In case we want to search by some unique field like qcode as well as name
                            // see SD-4829
                            return t.name.toLowerCase().includes(termLower)
                                || t[scope.uniqueField].toLowerCase().includes(termLower)
                                && !_.find(scope.item[scope.field], searchObj);
                        }

                        return t.name.toLowerCase().includes(termLower)
                            || (t.user != null && t.user.username.toLowerCase().includes(termLower))
                            // make sure to skip the terms which are already added for ex:
                            // {qcode: "1", name: "Arbeidsliv", scheme: "subject_custom"}  is already added
                            // and if user search for "Arbeidsliv" again he shouln't get any search results
                            && !_.find(scope.item[scope.field], searchObj);
                    }));
                    scope.activeList = true;
                }
                return scope.terms;
            };

            function filterSelected(terms) {
                var selected = {};

                angular.forEach(scope.item[scope.field], (term) => {
                    if (term) {
                        if (scope.cv && scope.cv._id !== term.scheme) {
                            return;
                        }
                        selected[term[scope.uniqueField]] = 1;
                    }
                });

                return _.filter(terms, (term) => term && !selected[term[scope.uniqueField]]);
            }

            function addTerm(term) {
                if (!term) {
                    return;
                }

                let isSelected = filterSelected([term]).length === 0;

                // Only select terms that are not already selected
                if (!isSelected) {
                    // instead of simple push, extend the item[field] in order to trigger dirty $watch
                    var t = [];

                    if (term.selection_type !== getSingleSelection()) {
                        t = _.clone(scope.item[scope.field]) || [];
                    }

                    if (scope.cv && scope.cv.selection_type === getSingleSelection()) {
                        t = _.filter(t, (_term) => _term.scheme !== scope.cv._id);
                    }

                    // build object
                    var o: any = {};

                    if (term.language && scope.setLanguage) {
                        o.language = term.language;
                    }

                    t.push(angular.extend({}, term, {
                        scheme: scope.cv ? scope.cv._id : null,
                    }));

                    o[scope.field] = t;

                    _.extend(scope.item, o);
                }
            }

            scope.selectTerm = function(term, $event) {
                // while searching, allow to search for categories but don't select them
                if (scope.tree?.[term?.[scope.uniqueField]] && !scope.allowEntireCat) {
                    scope.openTree(term, $event);
                } else if (term) {
                    addTerm(term);

                    if (includeParent) {
                        scope.termPath.forEach((_term) => {
                            addTerm(_term);
                        });
                    }

                    // make sure we run scope.change even if popup stays opened
                    $timeout(() => {
                        scope.$applyAsync(() => {
                            scope.postprocessing();
                            scope.change({item: scope.item, field: scope.field});
                        });
                    }, 50, false);

                    if ($event && ($event.ctrlKey || $event.metaKey || appConfig.features.keepMetaTermsOpenedOnClick)) {
                        $event.stopPropagation();
                        return;
                    }

                    scope.activeTerm = '';
                    scope.selectedTerm = '';
                    scope.termPath = [];
                    scope.searchTerms();

                    if (!reloadList) {
                        // Remove the selected term from the terms
                        scope.terms = _.without(scope.terms, term);
                        scope.activeTree = scope.terms;
                    }

                    // retain focus and initialise activeTree on same dropdown control after selection.
                    _.defer(() => {
                        if (!_.isEmpty(elem.find('.dropdown__toggle'))) {
                            elem.find('.dropdown__toggle').focus();
                        }
                        if (reloadList) {
                            scope.activeTerm = null;
                            scope.searchTerms(null);
                            scope.activeTree = scope.tree.null;
                        } else {
                            scope.terms = _.clone(scope.activeTree) || [];
                            scope.allSelected = scope.terms.length === 0;
                        }
                    });
                }
            };

            scope.removeTerm = function(term) {
                var tempItem = {},
                    subjectCodesArray = scope.item[scope.field],
                    filteredArray = subjectCodesArray.filter((item) => !_.isEqual(item, term));

                tempItem[scope.field] = filteredArray;

                _.extend(scope.item, tempItem);

                if (!reloadList) {
                    scope.terms.push(term);
                    if (scope.activeTree.find((item) => item.qcode === term.qcode) == null) {
                        scope.activeTree.push(term);
                    }
                    scope.activeTree = $filter('sortByName')(scope.activeTree);
                    scope.allSelected = false;
                }

                scope.terms = $filter('sortByName')(scope.terms);
                scope.change({item: scope.item, field: scope.field});
                elem.find('.dropdown__toggle').focus(); // retain focus
            };

            scope.setPreferredView = (view, $event) => {
                scope.preferredView = view;
                $event.stopPropagation();

                if (scope.activeTerm) {
                    scope.openParent({}, $event);
                }
            };

            function setPreferredItems() {
                scope.preferredView = null;
                scope.userPreferredItems = [];
                scope.deskPreferredItems = [];
                if (_.get(scope, 'cv._id')) {
                    preferencesService.get('cvs:preferred_items').then((prefered) => {
                        const userPrefs = _.get(prefered, 'value', {});
                        const deskPrefs = _.get(desks.getCurrentDesk(), 'preferred_cv_items', {});

                        scope.userPreferredItems = getPreferredItems(userPrefs);
                        scope.deskPreferredItems = getPreferredItems(deskPrefs);

                        if (scope.userPreferredItems.length) {
                            scope.preferredView = 'user';
                        } else if (scope.deskPreferredItems.length) {
                            scope.preferredView = 'desk';
                        }
                    });
                }
            }

            function getPreferredItems(prefs) {
                const preferredItems = _.get(prefs, scope.cv._id, []);

                return preferredItems
                    .map((preferedItem) =>
                        scope.list.find((item) => item[scope.uniqueField] === preferedItem[scope.uniqueField]))
                    .filter((item) => item != null); // filter out items missing in cv
            }

            scope.$on('clear: selectedUsageTerms', (e) => {
                scope.selectedItems = [];
                scope.activeTree = scope.tree.null;
                scope.allSelected = false;
            });
        },
    };
}

MetaLocatorsDirective.$inject = ['places'];
function MetaLocatorsDirective(places) {
    return {
        scope: {
            item: '=',
            fieldprefix: '@',
            field: '@',
            disabled: '=ngDisabled',
            change: '&',
            postprocessing: '&',
            header: '@',
            tabindex: '=',
            keepinput: '=',
        },

        controller: function() {
            this.selectedTerm = '';
        },
        controllerAs: '$ctrl',

        templateUrl: 'scripts/apps/authoring/metadata/views/metadata-locators.html',
        link: function(scope, element) {
            scope.locators = [];

            scope.$applyAsync(() => {
                if (scope.item) {
                    if (scope.fieldprefix && scope.item[scope.fieldprefix] &&
                        scope.item[scope.fieldprefix][scope.field]) {
                        scope.$ctrl.selectedTerm = scope.item[scope.fieldprefix][scope.field].city;
                    } else if (scope.item[scope.field]) {
                        scope.$ctrl.selectedTerm = scope.item[scope.field].city;
                    }
                }

                if (scope.list) {
                    setLocators(scope.list);
                }
            });

            function setLocators(list) {
                scope.locators = list.slice(0, 10);
                scope.total = list.length;
                return scope.locators;
            }

            // update visible city on some external change, like after undo/redo
            scope.$watch('item[fieldprefix][field].city || item[field].city', (located) => {
                scope.$ctrl.selectedTerm = located;
            });

            /**
             * sdTypeahead directive invokes this method and is responsible for searching located object(s) where the
             * city name matches locatorToFind.
             *
             * @return {Array} list of located object(s)
             */
            scope.searchLocator = (locatorToFind) => {
                scope.$ctrl.selectedTerm = locatorToFind;
                scope.loading = true;
                return places.searchDateline(locatorToFind, scope.item.language)
                    .then(setLocators)
                    .finally(() => {
                        scope.$applyAsync(() => {
                            scope.loading = false;
                        });
                    });
            };

            /**
             * sdTypeahead directive invokes this method and is responsible for updating the item with user selected
             * located object.
             *
             * @param {Object} locator user selected located object
             */
            scope.selectLocator = function(locator) {
                var updates = {};
                let loc = locator;

                if (!loc && scope.$ctrl.selectedTerm) {
                    var previousLocator = scope.fieldprefix && scope.item[scope.fieldprefix] != null
                        ? scope.item[scope.fieldprefix][scope.field]
                        : scope.item[scope.field];

                    if (previousLocator && scope.$ctrl.selectedTerm === previousLocator.city) {
                        loc = previousLocator;
                    } else {
                        loc = {city: scope.$ctrl.selectedTerm, city_code: scope.$ctrl.selectedTerm, tz: 'UTC',
                            dateline: 'city', country: '', country_code: '', state_code: '', state: ''};
                    }
                }

                if (loc) {
                    if (angular.isDefined(scope.fieldprefix)) {
                        if (!angular.isDefined(scope.item[scope.fieldprefix])) {
                            _.extend(scope.item, {dateline: {}});
                        }
                        updates[scope.fieldprefix] = scope.item[scope.fieldprefix];
                        updates[scope.fieldprefix][scope.field] = loc;
                    } else {
                        updates[scope.field] = loc;
                    }

                    scope.$ctrl.selectedTerm = loc.city;
                    _.extend(scope.item, updates);
                }
                if (scope.item[scope.fieldprefix] != null && loc === null) {
                    scope.item[scope.fieldprefix][scope.field] = null;
                }
                var selectedLocator = {item: scope.item, city: scope.$ctrl.selectedTerm};

                scope.postprocessing(selectedLocator);
                scope.change(selectedLocator);
            };
        },
    };
}

MetadataService.$inject = ['api', 'subscribersService', 'vocabularies', '$rootScope', 'session', '$filter'];
export function MetadataService(api, subscribersService, vocabularies, $rootScope, session, $filter) {
    var service = {
        values: {},
        helper_text: {},
        popup_width: {},
        single_value: {},
        cvs: [],
        search_cvs: appConfig.search_cvs || [
            {id: 'subject', name: 'Subject', field: 'subject', list: 'subjectcodes'},
            {id: 'companycodes', name: 'Company Codes', field: 'company_codes', list: 'company_codes'},
        ],
        search_config: appConfig.search || {
            slugline: 1, headline: 1, unique_name: 1, story_text: 1, byline: 1,
            keywords: 1, creator: 1, from_desk: 1, to_desk: 1, spike: 1,
            scheduled: 1, company_codes: 1, ingest_provider: 1, marked_desks: 1,
            featuremedia: 1,
        },
        subjectScope: null,
        loaded: null,
        _urgencyByValue: {},
        _priorityByValue: {},
        fetchMetadataValues: function() {
            var self = this;

            return vocabularies.getAllActiveVocabularies().then((result) => {
                _.each(result, (vocabulary) => {
                    self.values[vocabulary._id] = vocabulary.items;
                    if (_.has(vocabulary, 'helper_text')) {
                        self.helper_text[vocabulary._id] = vocabulary.helper_text;
                    }
                    if (_.has(vocabulary, 'popup_width')) {
                        self.popup_width[vocabulary._id] = vocabulary.popup_width;
                    }
                    if (_.has(vocabulary, 'selection_type')) {
                        self.single_value[vocabulary._id] = vocabulary.selection_type === getSingleSelection();
                    }
                });
                self.cvs = result;
                self.values.regions = _.sortBy(self.values.geographical_restrictions,
                    (target) => target.value && target.value.toLowerCase() === 'all' ? '' : target.name,
                );
                self.values.subscriberTypes = _.sortBy(self.values.subscriber_types,
                    (target) => target.value && target.value.toLowerCase() === 'all' ? '' : target.name,
                );

                if (self.values.urgency) {
                    self._urgencyByValue = _.keyBy(self.values.urgency, 'qcode');
                }

                if (self.values.priority) {
                    self._priorityByValue = _.keyBy(self.values.priority, 'qcode');
                }
            });
        },
        fetchSubscribers: function() {
            var self = this;

            self.values.customSubscribers = [];
            return subscribersService.fetchTargetableSubscribers().then((items) => {
                _.each(items, (item) => {
                    self.values.customSubscribers.push({_id: item._id, name: item.name});
                });
            });
        },
        fetchSubjectcodes: function(code) {
            var self = this;

            return api.get('/subjectcodes').then((result) => {
                self.values.subjectcodes = result._items;
            });
        },
        fetchAuthors: function(code) {
            var self = this;

            self.values.authors = [];

            return api.getAll('users', {is_author: 1}).then((users) => {
                var first;

                _.each(users, (user) => {
                    var authorMetadata = {_id: user._id, name: user.display_name, user: user};

                    if (session.identity.is_author && user._id === session.identity._id) {
                        // we want logged user to appear first
                        first = authorMetadata;
                    } else {
                        self.values.authors.push(authorMetadata);
                    }
                    _.each(self.values.author_roles, (role) => {
                        self.values.authors.push({
                            _id: [user._id, role.qcode],
                            role: role.qcode,
                            name: role.name,
                            parent: user._id,
                            sub_label: user.display_name,
                            private: user.private});
                    });
                });
                self.values.authors = $filter('sortByName')(self.values.authors);
                if (first) {
                    self.values.authors.unshift(first);
                }
            });
        },
        removeSubjectTerm: function(term) {
            if (!this.subjectScope) {
                return;
            }

            var self = this,
                tempItem = {};

            angular.forEach(this.search_cvs || [], (cv) => {
                if (_.isNil(term)) { // clear subject scope
                    self.subjectScope.item[cv.id].length = 0;
                } else {
                    var subjectCodesArray = self.subjectScope.item[cv.id],
                        filteredArray = _.without(subjectCodesArray, term);

                    if (filteredArray.length === subjectCodesArray.length) {
                        _.remove(filteredArray, {name: term});
                    }
                    tempItem[cv.id] = filteredArray;
                }
            });

            _.extend(self.subjectScope.item, tempItem);
            self.subjectScope.change({item: self.subjectScope.item});
        },
        fetchCities: function() {
            var self = this;

            if (!self.citiesPromise) {
                self.citiesPromise = api.get('/cities').then((result) => {
                    self.values.cities = result._items;
                    return self.values.cities;
                });
            }

            return self.citiesPromise;
        },
        fetchAgendas: function() {
            var self = this;

            if ($rootScope.features.agenda) {
                return api.get('/agenda').then((result) => {
                    var agendas = [];

                    _.each(result._items, (item) => {
                        if (item.is_enabled) {
                            agendas.push({name: item.name, id: item._id, qcode: item.name});
                        }
                    });
                    self.values.agendas = agendas;
                });
            }
        },
        fetchEventsPlanningFilters: function() {
            var self = this;

            if ($rootScope.features.events_planning_filters) {
                return api.get('/events_planning_filters').then((result) => {
                    self.values.eventsPlanningFilters = _.get(result, '_items', [])
                        .map((item) => ({name: item.name, qcode: item._id}));
                });
            }
        },
        getFilteredCustomVocabularies: function(qcodes) {
            return this.fetchMetadataValues().then(() => this.cvs.filter((cv) => {
                var cvService = cv.service || {};

                if (cvService.all) {
                    cv.terms = (cv.items || []).filter((item) => {
                        if (item.service) {
                            return qcodes.some((qcode) => !!item.service[qcode]);
                        } else {
                            return true;
                        }
                    });
                    return true;
                } else {
                    cv.terms = cv.items;
                    return qcodes.some((qcode) => !!cvService[qcode]);
                }
            }));
        },
        /**
         * Get custom vocabularies for selected services, used in authoring
         *
         * @param {Array} qcodes - selected service qcodes
         * @param {Object} editor
         * @param {Object} schema
         * @return {Array}
         */
        getCustomVocabulariesForArticleHeader: function(qcodes, editor, schema) {
            return this.getFilteredCustomVocabularies(qcodes)
                .then(
                    (cvs) => cvs.filter((cv) => (cv.terms.length || cv.read_only) &&
                        (editor[cv._id] || schema[cv._id])),
                );
        },
        /**
         * Get all available custom vocabularies, used in content profile editor
         *
         * @param {Ojbect} editor
         * @param {Object} schema
         * @return {Array}
         */
        getAllCustomVocabulariesForArticleHeader: function(editor, schema) {
            return this.fetchMetadataValues().then(() => {
                const customVocabulariesForArticleHeader = this.cvs.filter(
                    (cv) => cv.field_type == null && cv.service && (editor[cv._id] || schema[cv._id]),
                );

                const customTextAndDateVocabularies = this.cvs.filter(
                    (cv) => cv.field_type === 'text' || cv.field_type === 'date',
                );

                return {customVocabulariesForArticleHeader, customTextAndDateVocabularies};
            });
        },
        initialize: function() {
            if (!this.loaded) {
                this.loaded = this.fetchMetadataValues()
                    .then(angular.bind(this, this.fetchSubjectcodes))
                    .then(angular.bind(this, this.fetchAuthors))
                    .then(angular.bind(this, this.fetchSubscribers))
                    .then(angular.bind(this, this.fetchCities))
                    .then(angular.bind(this, this.fetchAgendas))
                    .then(angular.bind(this, this.fetchEventsPlanningFilters));
            }

            return this.loaded;
        },
        urgencyByValue: function(value) {
            return this._urgencyByValue[value] || null;
        },
        priorityByValue: function(value) {
            return this._priorityByValue[value] || null;
        },
        getLocaleName: (term: IVocabularyItem, item: IArticle) => sdApi.vocabularies.getVocabularyItemLabel(term, item),
    };

    $rootScope.$on('subscriber:create', () => service.fetchSubscribers());
    $rootScope.$on('subscriber:update', () => service.fetchSubscribers());

    // add new cvs to the list when created
    $rootScope.$on('vocabularies:created', (event, data) => {
        api.find('vocabularies', data.vocabulary_id).then((cv) => {
            service.cvs = service.cvs.concat([cv]);
        });
    });

    // update cv on change
    $rootScope.$on('vocabularies:updated', (event, data) => {
        api.find('vocabularies', data.vocabulary_id).then((cv) => {
            service.cvs = service.cvs.map((_cv) => _cv._id === cv._id ? cv : _cv);
        });
    });

    return service;
}

MetadataListItem.$inject = [];
function MetadataListItem() {
    return {
        scope: {
            label: '=',
        },
        transclude: true,
        templateUrl: 'scripts/apps/authoring/metadata/views/metadata-list-item.html',
    };
}

angular.module('superdesk.apps.authoring.metadata', [
    'superdesk.apps.authoring.widgets',
    'superdesk.apps.publish',
    'superdesk.apps.vocabularies',
    'vs-repeat',
])
    .config(['authoringWidgetsProvider', function(authoringWidgetsProvider) {
        authoringWidgetsProvider
            .widget('metadata', {
                icon: 'info',
                label: gettext('Info'),
                removeHeader: true,
                template: 'scripts/apps/authoring/metadata/views/metadata-widget.html',
                order: 1,
                side: 'right',
                display: {
                    authoring: true,
                    packages: true,
                    killedItem: true,
                    legalArchive: true,
                    archived: true,
                    picture: true,
                    personal: true,
                },
            });
    }])

    .controller('MetadataWidgetCtrl', MetadataCtrl)
    .service('metadata', MetadataService)
    .service('places', PlacesServiceFactory)
    .directive('sdMetaTarget', MetaTargetedPublishingDirective)
    .directive('sdMetadataListItem', MetadataListItem)
    .directive('sdMetaTerms', MetaTermsDirective)
    .directive('sdMetaTags', MetaTagsDirective)
    .directive('sdMetaDropdown', MetaDropdownDirective)
    .component(
        'sdTargetSubscribers',
        reactToAngular1(
            PublishingTargetSelect,
            [
                'value',
                'onChange',
            ],
        ),
    )
    .component(
        'sdMetaDataDropdownSingleSelectReact',
        reactToAngular1(
            MetaDataDropdownSingleSelectReact,
            [
                'selectedItemLabel',
                'options',
                'onChange',
                'tabIndex',
                'disabled',
                'language',
            ],
        ),
    )
    .directive('sdMetaWordsList', MetaWordsListDirective)
    .directive('sdMetadropdownFocus', MetadropdownFocusDirective)
    .directive('sdMetaLocators', MetaLocatorsDirective)
    .directive('sdPreferedCvItemsConfig', PreferedCvItemsConfigDirective)
    .directive('sdMetaPlace', MetaPlaceDirective)
;