superdesk/superdesk-client-core

View on GitHub
scripts/apps/archive/controllers/UploadController.ts

Summary

Maintainability
F
4 days
Test Coverage
import _ from 'lodash';
import {getDataUrl} from 'core/upload/image-preview-directive';
import {gettext} from 'core/utils';
import {isEmpty, pickBy} from 'lodash';
import {handleBinaryFile} from '@metadata/exif';
import {extensions} from 'appConfig';
import {IPTCMetadata, IUser, IArticle} from 'superdesk-api';
import {appConfig} from 'appConfig';
import {fileUploadErrorModal} from './file-upload-error-modal';
import {showModal} from '@superdesk/common';

const isNotEmptyString = (value: any) => value != null && value !== '';

/* eslint-disable complexity */

function getExifData(file: File): Promise<IPTCMetadata> {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();

        reader.onloadend = () => {
            try {
                const exif: { iptcdata: IPTCMetadata } = handleBinaryFile(reader.result);

                resolve(exif.iptcdata);
            } catch (error) {
                console.error(error);
                reject(error);
            }
        };

        reader.onerror = reject;
        reader.readAsArrayBuffer(file);
    });
}

function mapIPTCExtensions(metadata: IPTCMetadata, user: IUser, parent?: IArticle): Promise<Partial<IArticle>> {
    const meta: Partial<IPTCMetadata> = Object.assign({
        'By-line': user.byline,
    }, pickBy(metadata, isNotEmptyString));

    const item = {
        byline: meta['By-line']?.toString() || user.byline,
        headline: meta.Headline?.toString(),
        description_text: meta['Caption-Abstract']?.toString(),
        copyrightnotice: meta.CopyrightNotice?.toString(),
        language: meta.LanguageIdentifier?.toString(),
        creditline: meta.Credit?.toString(),
    };

    return Object.values(extensions).filter(({activationResult}) =>
        activationResult.contributions?.iptcMapping,
    ).reduce(
        (accumulator, {activationResult}) =>
            accumulator.then((_item) => activationResult.contributions.iptcMapping(meta, _item, parent)),
        Promise.resolve(item),
    ).then((_item: Partial<IArticle>) => pickBy(_item, isNotEmptyString));
}

function serializePromises(promiseCreators: Array<() => Promise<any>>): Promise<Array<any>> {
    let promise = Promise.resolve();

    return Promise.all(promiseCreators.map((promiseCreator) => {
        promise = promise.then(promiseCreator);
        return promise;
    }));
}

UploadController.$inject = [
    '$scope',
    '$q',
    'upload',
    'api',
    'archiveService',
    'session',
    'desks',
    'notify',
    '$location',
    'modal',
];
export function UploadController(
    $scope,
    $q,
    upload,
    api,
    archiveService,
    session,
    desks,
    notify,
    $location,
    modal,
) {
    $scope.items = [];
    $scope.saving = false;
    $scope.failed = false;
    $scope.enableSave = false;
    $scope.currentUser = session.identity;
    $scope.uniqueUpload = $scope.locals && $scope.locals.data && $scope.locals.data.uniqueUpload === true;
    $scope.maxUploads = !$scope.uniqueUpload && $scope.locals && $scope.locals.data &&
        $scope.locals.data.maxUploads ? $scope.locals.data.maxUploads : undefined;
    $scope.allowPicture = !($scope.locals && $scope.locals.data && $scope.locals.data.allowPicture === false);
    $scope.allowVideo = !($scope.locals && $scope.locals.data && $scope.locals.data.allowVideo === false);
    $scope.allowAudio = !($scope.locals && $scope.locals.data && $scope.locals.data.allowAudio === false);
    $scope.validator = _.omit(appConfig.validator_media_metadata, ['archive_description']);
    $scope.parent = $scope.locals?.data?.parent || null;
    $scope.deskSelectionAllowed = ($location.path() !== '/workspace/personal') && $scope.locals &&
        $scope.locals.data && $scope.locals.data.deskSelectionAllowed === true;

    if ($scope.deskSelectionAllowed === true) {
        Promise.all([desks.fetchDesks(), desks.getCurrentDesk()]).then(([_desks, currentDesk]) => {
            $scope.desks = _desks._items;
            $scope.selectedDesk = currentDesk;
        });
    }

    $scope.selectDesk = (desk) => {
        $scope.selectedDesk = desk;
    };

    let pseudoId = 0;
    const getPseudoId = () => ++pseudoId;

    const getItemByMetaId = (metaId) => $scope.items.find((item) => item.meta_id === metaId);

    $scope.onRemoveItem = (imageMeta) => {
        $scope.items = $scope.items.filter((item) => item.meta_id !== imageMeta._id);
        $scope.imagesMetadata = $scope.items.map((item) => item.meta);
    };

    $scope.imagesMetadata = [];
    $scope.getProgress = (imageMeta) => {
        const item = getItemByMetaId(imageMeta._id);

        if (item == null) {
            return 0;
        } else {
            return item.progress || 0;
        }
    };
    $scope.getThumbnailHtml = (imageMeta) => {
        const item = getItemByMetaId(imageMeta._id);

        return item == null ? '' : item.thumbnailHtml;
    };
    $scope.getIconForItemType = (imageMeta) => {
        const item = getItemByMetaId(imageMeta._id);

        return 'icon-' + (item.cssType === 'image' ? 'photo' : item.cssType);
    };
    $scope.invokeImagesInput = () => {
        var el: HTMLElement = document.querySelector('#images-input');

        el.click();
    };

    $scope.isDragging = false;

    $scope.drag = ($isDragging, $class, $event) => {
        $scope.isDragging = $isDragging;
        $scope.$apply();
    };

    $scope.handleImageMetadataEdit = (editedMetadataItems) => {
        editedMetadataItems.forEach((metaItem) => {
            const item = $scope.items.find((i) => i.meta_id === metaItem._id);

            if (item != null) {
                item.meta = metaItem;

                // the item is not created yet, so has no real id
                // it only has local pseudo-id for multi metadata editing to work
                delete item.meta._id;
            }
        });

        return $scope.save();
    };

    var uploadFile = function(item) {
        var handleError = function(reason) {
            if (reason && reason.data && reason.data.code) {
                notify.error(gettext('Upload Error:') + ' ' + reason.data.code);
            }

            item.model = false;
            $scope.failed = true;
            return $q.reject(reason);
        };

        return item.upload || api.archive.getUrl()
            .then((url) => {
                item.upload = upload.start({
                    method: 'POST',
                    url: url,
                    data: {media: item.file},
                    headers: api.archive.getHeaders(),
                });
                item.upload.then((response) => {
                    if (response.data._issues) {
                        return handleError(response);
                    }

                    item.progress = 100;

                    item.model = response.data;
                    return item;
                }, handleError, (progress) => {
                    // limit progress to 90% and set 100 only after request is done
                    item.progress = Math.min(Math.round(progress.loaded / progress.total * 100.0), 90);
                });

                return item.upload;
            });
    };

    var checkFail = function() {
        $scope.failed = _.some($scope.items, {model: false});
    };

    var initFile = function(file, meta, id) {
        var item = {
            file: file,
            meta: meta,
            progress: 0,
            cssType: file.type.split('/')[0],
            thumbnailHtml: '',
        };

        if (id != null) {
            item.meta._id = id;
            item['meta_id'] = id;
        }

        $scope.items.unshift(item);
        $scope.enableSave = $scope.items.length > 0;
        return item;
    };

    $scope.addFiles = function(files: Array<File>) {
        $scope.isDragging = false;

        if (!files.length) {
            return false;
        }
        if ($scope.uniqueUpload && files.length > 1) {
            notify.error(gettext('Only one file can be uploaded'));
            return false;
        }
        if (!$scope.uniqueUpload && $scope.maxUploads && (files.length + $scope.items.length) > $scope.maxUploads) {
            notify.error(gettext('Select at most {{maxUploads}} files to upload.', {maxUploads: $scope.maxUploads}));
            return false;
        }

        let acceptedFiles: Array<{ file: File, getThumbnail: (file: File) => Promise<string> }> = [];
        let invalidFiles = [];

        const fileDimensionsValid = (file: File) => {
            if (appConfig.pictures) {
                return getDataUrl(file).then((dataUrl) => {
                    return new Promise((resolve) => {
                        let img = document.createElement('img');

                        img.src = dataUrl;
                        img.onload = function() {
                            if (img.width && img.width >= appConfig.pictures.minWidth
                                && img.height > appConfig.pictures.minHeight) {
                                return resolve({valid: true, name: file.name});
                            } else {
                                return resolve({
                                    valid: false,
                                    name: file.name,
                                    width: img.width,
                                    height: img.height,
                                    type: file.type,
                                });
                            }
                        };
                    });
                });
            } else {
                return Promise.resolve({valid: true});
            }
        };

        return Promise.all(_.map(files, (file): any => {
            if (file.type.startsWith('image')) {
                if (!$scope.allowPicture) {
                    return Promise.resolve({error: {isAllowedFileType: false}});
                }
                return fileDimensionsValid(file).then((data: {[key: string]: string}) => {
                    if (data.valid) {
                        return {
                            file: file,
                            getThumbnail: () => getDataUrl(file).then((uri) => `<img src="${uri}" />`),
                        };
                    } else {
                        return {error: data};
                    }
                });
            } else if (file.type.startsWith('video')) {
                if (!$scope.allowVideo) {
                    return Promise.resolve({error: {isAllowedFileType: false}});
                }
                return Promise.resolve({
                    file: file,
                    getThumbnail: () => Promise.resolve('<i class="icon--2x icon-video"></i>'),
                });
            } else if (file.type.startsWith('audio')) {
                if (!$scope.allowAudio) {
                    return Promise.resolve({error: {isAllowedFileType: false}});
                }
                return Promise.resolve({
                    file: file,
                    getThumbnail: () => Promise.resolve('<i class="icon--2x icon-audio"></i>'),
                });
            } else {
                return Promise.resolve({error: {isAllowedFileType: false}});
            }
        })).then((result) => {
            let uploadOfDisallowedFileTypesAttempted: boolean = false;

            result.forEach((file) => {
                if (!file.error) {
                    acceptedFiles.push({
                        file: file.file,
                        getThumbnail: file.getThumbnail,
                    });
                } else if (file.error.isAllowedFileType === false) {
                    uploadOfDisallowedFileTypesAttempted = true;
                } else {
                    invalidFiles.push(file.error);
                }
            });

            if (uploadOfDisallowedFileTypesAttempted) {
                const allowedTypes = [];

                if ($scope.allowPicture) {
                    allowedTypes.push(gettext('image'));
                }

                if ($scope.allowVideo) {
                    allowedTypes.push(gettext('video'));
                }

                if ($scope.allowAudio) {
                    allowedTypes.push(gettext('audio'));
                }

                const message = gettext(
                    'Only the following files are allowed: {{fileTypes}}',
                    {fileTypes: allowedTypes.join(', ')},
                );

                notify.error(message);
            }

            showModal(fileUploadErrorModal(invalidFiles));

            return acceptedFiles.length < 1
                ? Promise.resolve()
                : Promise.all(acceptedFiles.map(
                    ({file, getThumbnail}) =>
                        getExifData(file)
                            .then(
                                (fileMeta) => mapIPTCExtensions(fileMeta, $scope.currentUser, $scope.parent),
                                () => ({}), // proceed with upload on exif parsing error
                            )
                            .then((meta) => {
                                const item = initFile(file, meta, getPseudoId());

                                return getThumbnail(file).then((htmlString) => item.thumbnailHtml = htmlString);
                            }),
                )).then(() => {
                    $scope.$applyAsync(() => {
                        $scope.imagesMetadata = $scope.items.map((item) => item.meta);
                    });
                });
        });
    };

    $scope.upload = function() {
        if (isEmpty($scope.items)) {
            return Promise.resolve();
        }

        // upload items in sequence, and resolve when all are done
        return serializePromises($scope.items.map((item) => {
            if (!item.model && !item.progress) {
                item.upload = null;
                return () => uploadFile(item);
            }

            return () => Promise.resolve(item);
        }));
    };

    $scope.save = function() {
        $scope.saving = true;
        return $scope.upload().then(() => {
            $q.all(_.map($scope.items, (item) => {
                archiveService.addTaskToArticle(item.meta, $scope.selectedDesk);
                return api.archive.update(item.model, item.meta);
            })).then((results) => {
                $scope.resolve(results);
            });
        })
            .finally(() => {
                $scope.saving = false;
                checkFail();
            });
    };

    $scope.cancel = function() {
        $scope.reject();
    };

    $scope.tryAgain = function() {
        $scope.failed = null;
        $scope.upload();
    };

    $scope.canUpload = () => {
        if ($scope.uniqueUpload) {
            return $scope.items.length === 0;
        }
        return $scope.maxUploads === undefined || $scope.maxUploads > $scope.items.length;
    };

    if ($scope.locals && $scope.locals.data) {
        if ($scope.locals.data.files) {
            $scope.addFiles($scope.locals.data.files);
        } else {
            $scope.addFiles($scope.locals.data);
        }
    }
}