kahuna/public/js/search/results.js (510 lines of code) (raw):
import angular from 'angular';
import Rx from 'rx';
import moment from 'moment';
import '../services/scroll-position';
import '../services/panel';
import '../util/async';
import '../util/rx';
import '../util/seq';
import '../util/constants/sendToCapture-config';
import '../components/gu-lazy-table/gu-lazy-table';
import '../components/gu-lazy-preview/gu-lazy-preview';
import '../components/gu-lazy-table-shortcuts/gu-lazy-table-shortcuts';
import '../components/gu-lazy-preview-shortcuts/gu-lazy-preview-shortcuts';
import '../components/gr-archiver/gr-archiver';
import '../components/gr-delete-image/gr-delete-image';
import '../components/gr-undelete-image/gr-un-delete-image';
import '../components/gr-downloader/gr-downloader';
import '../components/gr-batch-export-original-images/gr-batch-export-original-images';
import '../components/gr-panel-button/gr-panel-button';
import '../components/gr-toggle-button/gr-toggle-button';
import '../components/gr-confirmation-modal/gr-confirmation-modal';
import {
INVALIDIMAGES,
sendToCaptureAllValid, sendToCaptureCancelBtnTxt, sendToCaptureConfirmBtnTxt, sendToCaptureInvalid,
sendToCaptureSuccess, sendToCaptureFailure, announcementId,
sendToCaptureMixed,
sendToCaptureTitle,
VALIDIMAGES
} from "../util/constants/sendToCapture-config";
export var results = angular.module('kahuna.search.results', [
'kahuna.services.scroll-position',
'kahuna.services.panel',
'util.async',
'util.rx',
'util.seq',
'gu.lazyTable',
'gu.lazyTableShortcuts',
'gu.lazyPreview',
'gu.lazyPreviewShortcuts',
'gr.archiver',
'gr.downloader',
'gr.batchExportOriginalImages',
'gr.deleteImage',
'gr.undeleteImage',
'gr.panelButton',
'gr.toggleButton',
'gr.confirmationModal'
]);
function compact(array) {
return array.filter(angular.isDefined);
}
// Global session-level state to remember the uploadTime of the first
// result in the last search. This allows to always paginate the same
// set of results, as well as recovering the same set of results if
// navigating back to the same search.
// Note: I tried to do this using non-URL $stateParams and it was a
// rabbit-hole that doesn't seem to have any end. Hence this slightly
// horrid global state.
let lastSearchFirstResultTime;
results.controller('SearchResultsCtrl', [
'$rootScope',
'$scope',
'$state',
'$stateParams',
'$window',
'$timeout',
'$log',
'$q',
'inject$',
'delay',
'onNextEvent',
'scrollPosition',
'mediaApi',
'selection',
'selectedImages$',
'results',
'panels',
'isReloadingPreviousSearch',
'globalErrors',
function($rootScope,
$scope,
$state,
$stateParams,
$window,
$timeout,
$log,
$q,
inject$,
delay,
onNextEvent,
scrollPosition,
mediaApi,
selection,
selectedImages$,
results,
panels,
isReloadingPreviousSearch,
globalErrors) {
const ctrl = this;
ctrl.$onInit = () => {
ctrl.showSendToPhotoSales = () => $window._clientConfig.showSendToPhotoSales;
};
// Panel control
ctrl.metadataPanel = panels.metadataPanel;
ctrl.collectionsPanel = panels.collectionsPanel;
ctrl.images = [];
if (ctrl.image && ctrl.image.data.softDeletedMetadata !== undefined) { ctrl.isDeleted = true; }
ctrl.newImagesCount = 0;
ctrl.newImagesLastCheckedMoment = moment();
// Preview control
ctrl.previewView = false;
// Map to track image->position and help remove duplicates
let imagesPositions;
// FIXME: This is being refreshed by the router.
// Make it watch a $stateParams collection instead
// See: https://github.com/guardian/media-service/pull/64#discussion-diff-17351746L116
ctrl.loading = true;
ctrl.revealNewImages = revealNewImages;
ctrl.applyOrgOwnedFilter = applyOrgOwnedFilter;
ctrl.getLastSeenVal = getLastSeenVal;
ctrl.imageHasBeenSeen = imageHasBeenSeen;
// large limit, but still a limit to ensure users don't reach unusable levels of performance
ctrl.maxResults = 100000;
// If not reloading a previous search, discard any previous
// state related to the last search
if (! isReloadingPreviousSearch) {
lastSearchFirstResultTime = undefined;
}
// Initial search to find upper `until` boundary of result set
// (i.e. the uploadTime of the newest result in the set)
// TODO: avoid this initial search (two API calls to init!)
ctrl.searched = search({length: 1, orderBy: 'newest'}).then(function(images) {
ctrl.totalResults = images.total;
// FIXME: https://github.com/argo-rest/theseus has forced us to co-opt the actions field for this
ctrl.orgOwnedCount = images.$response?.$$state?.value?.actions;
ctrl.hasQuery = !!$stateParams.query;
ctrl.initialSearchUri = images.uri;
ctrl.embeddableUrl = window.location.href;
// images will be the array of loaded images, used for display
ctrl.images = [];
// imagesAll will be a sparse array of all the results
const totalLength = Math.min(images.total, ctrl.maxResults);
ctrl.imagesAll = [];
ctrl.imagesAll.length = totalLength;
// TODO: ultimately we want to manage the state in the
// results stream exclusively
results.clear();
results.resize(totalLength);
imagesPositions = new Map();
checkForNewImages();
// Keep track of time of the latest result for all
// subsequent searches (so we always query the same set of
// results), unless we're reloading a previous search in
// which case we reuse the previous time too
const until = $stateParams.until || null;
const latestTime = until || moment().toISOString();
if (latestTime && ! isReloadingPreviousSearch) {
lastSearchFirstResultTime = latestTime;
}
return images;
}).catch(error => {
ctrl.loadingError = error;
return $q.reject(error);
}).finally(() => {
ctrl.loading = false;
});
ctrl.loadRange = function(start, end) {
const length = end - start + 1;
search({offset: start, length: length, countAll: false}).then(images => {
// Update imagesAll with newly loaded images
images.data.forEach((image, index) => {
const position = index + start;
const imageId = image.data.id;
// If image already present in results at a
// different position (result set shifted due to
// items being spliced in or deleted?), get rid of
// item at its previous position to avoid
// duplicates
const existingPosition = imagesPositions.get(imageId);
if (angular.isDefined(existingPosition) &&
existingPosition !== position) {
$log.info(`Detected duplicate image ${imageId}, ` +
`old ${existingPosition}, new ${position}`);
delete ctrl.imagesAll[existingPosition];
results.set(existingPosition, undefined);
}
ctrl.imagesAll[position] = image;
imagesPositions.set(imageId, position);
results.set(position, image);
});
// images should not contain any 'holes'
ctrl.images = compact(ctrl.imagesAll);
});
};
// == Vertical position ==
// Logic to resume vertical position when navigating back to the same results
onNextEvent($scope, 'gu-lazy-table:height-changed').
// Attempt to resume the top position ASAP, so as to limit
// visible jump
then(() => scrollPosition.resume($stateParams)).
// When navigating back, resuming the position immediately
// doesn't work, so we try again after a little while
then(() => delay(30)).
then(() => scrollPosition.resume($stateParams)).
then(scrollPosition.clear);
const pollingPeriod = 15 * 1000; // ms
// FIXME: this will only add up to 50 images (search capped)
function checkForNewImages() {
$timeout(() => {
// Use explicit `until`, or blank it to find new images
const until = $stateParams.until || null;
const latestTime = lastSearchFirstResultTime;
search({since: latestTime, length: 0, until}).then(resp => {
// FIXME: minor assumption that only the latest
// displayed image is matching the uploadTime
ctrl.newImagesCount = resp.total;
// FIXME: https://github.com/argo-rest/theseus has forced us to co-opt the actions field for this
ctrl.newOrgOwnedCount = resp.$response?.$$state?.value?.actions;
if (ctrl.newImagesCount > 0) {
$rootScope.$emit('events:new-images', { count: ctrl.newImagesCount});
}
ctrl.lastestTimeMoment = moment(latestTime);
ctrl.newImagesLastCheckedMoment = moment();
if (! scopeGone) {
checkForNewImages();
}
});
}, pollingPeriod);
}
function revealNewImages() {
// FIXME: should ideally be able to just call $state.reload(),
// but there seems to be a bug (alluded to in the docs) when
// notify is false, so forcing to true explicitly instead:
$window.scrollTo(0,0);
$state.transitionTo($state.current, $stateParams, {
reload: true, inherit: false, notify: true
});
}
ctrl.maybeOrgOwnedValue = window._clientConfig.maybeOrgOwnedValue;
const isOrgOwnedClause = `is:${ctrl.maybeOrgOwnedValue}`;
function applyOrgOwnedFilter() {
$window.scrollTo(0,0);
const toParams = $stateParams.query?.includes(isOrgOwnedClause)
? $stateParams
: {
...$stateParams,
query: $stateParams.query
? `${$stateParams.query} ${isOrgOwnedClause}`
: isOrgOwnedClause
};
$state.transitionTo(
$state.current,
toParams,
{ reload: true, inherit: false, notify: true }
);
}
var seenSince;
const lastSeenKey = 'search.seenFrom';
function getLastSeenVal(image) {
const key = getQueryKey();
var val = {};
val[key] = image.data.uploadTime;
// Tracking to potentially kill this feature off
$rootScope.$emit('track:event', 'Mark as seen', 'Clicked', null, null, {image: image});
return val;
}
function imageHasBeenSeen(image) {
return image.data.uploadTime <= seenSince;
}
$scope.$watch(() => $window.localStorage.getItem(lastSeenKey), function() {
seenSince = getSeenSince();
});
// TODO: Move this into localstore service
function getSeenSince() {
return JSON.parse($window.localStorage.getItem(lastSeenKey) || '{}')[getQueryKey()];
}
function getQueryKey() {
return $stateParams.query || '*';
}
function search({until, since, offset, length, orderBy, countAll} = {}) {
// FIXME: Think of a way to not have to add a param in a million places to add it
/*
* @param `until` can have three values:
*
* - `null` => Don't send over a date, which will default to `now()` on the server.
* Used in `checkForNewImages` with no until in `stateParams` to search
* for the new image count
*
* - `string` => Override the use of `stateParams` or `lastSearchFirstResultTime`.
* Used in `checkForNewImages` when a `stateParams.until` is set.
*
* - `undefined` => Default. We then use the `lastSearchFirstResultTime` if available to
* make sure we aren't loading any new images into the result set and
* `checkForNewImages` deals with that. If it's the first search, we
* will use `stateParams.until` if available.
*/
if (angular.isUndefined(until)) {
until = lastSearchFirstResultTime || $stateParams.until;
}
if (angular.isUndefined(since)) {
since = $stateParams.since;
}
if (angular.isUndefined(orderBy)) {
orderBy = $stateParams.orderBy;
}
if (angular.isUndefined(countAll)) {
countAll = true;
}
return mediaApi.search($stateParams.query, angular.extend({
ids: $stateParams.ids,
archived: $stateParams.archived,
free: $stateParams.nonFree === 'true' ? undefined : true,
// Disabled while paytype filter unavailable
//payType: $stateParams.payType || 'free',
uploadedBy: $stateParams.uploadedBy,
takenSince: $stateParams.takenSince,
takenUntil: $stateParams.takenUntil,
modifiedSince: $stateParams.modifiedSince,
modifiedUntil: $stateParams.modifiedUntil,
until: until,
since: since,
offset: offset,
length: length,
orderBy: orderBy,
hasRightsAcquired: $stateParams.hasRightsAcquired,
hasCrops: $stateParams.hasCrops,
syndicationStatus: $stateParams.syndicationStatus,
persisted: $stateParams.persisted,
countAll
}));
}
ctrl.clearSelection = () => {
selection.clear();
};
ctrl.shareSelection = () => {
const sharedImagesIds = ctrl.selectedImages.map(image => image.data.id);
const sharedUrl = $window._clientConfig.rootUri + "/search?nonFree=true&ids=" + sharedImagesIds.join(',');
navigator.clipboard.writeText(sharedUrl);
globalErrors.trigger('clipboard', sharedUrl);
};
const imageHasSyndicationUsage = (image) => {
return image.data.usages.data.some(usage =>
usage.data.platform === 'syndication'
);
};
const validatePhotoSalesSelection = (images) => {
const validImages = [];
const invalidImages = [];
images.forEach(image => {
if (image.data.uploadedBy === 'Capture_AutoIngest' || imageHasSyndicationUsage(image)) {
invalidImages.push(image);
} else {
validImages.push(image);
}
});
return [validImages, invalidImages];
};
ctrl.showPaid = undefined;
mediaApi.getSession().then(session => {
ctrl.showPaid = session.user.permissions.showPaid ? session.user.permissions.showPaid : undefined;
});
ctrl.sendToPhotoSales = () => {
try {
const validImages = validatePhotoSalesSelection(ctrl.selectedImages)[0];
validImages.map(image => {
mediaApi.syndicateImage(image.data.id, "Capture", "true");
});
ctrl.clearSelection();
const notificationEvent = new CustomEvent("newNotification", {
detail: {
announceId: announcementId,
description: sendToCaptureSuccess,
category: "success",
lifespan: "transient"
},
bubbles: true
});
window.dispatchEvent(notificationEvent);
} catch (err) {
console.log(err);
const notificationEvent = new CustomEvent("newNotification", {
detail: {
announceId: announcementId,
description: sendToCaptureFailure,
category: "error",
lifespan: "transient"
},
bubbles: true
});
window.dispatchEvent(notificationEvent);
}
};
ctrl.displayConfirmationModal = () => {
const [validImages, invalidImages] = validatePhotoSalesSelection(ctrl.selectedImages);
const title = sendToCaptureTitle;
let eventType;
let detailObj;
if (validImages.length !== 0 && invalidImages.length === 0) {
// All images selected are valid
eventType = "displayModal";
detailObj = {
title: title,
message: sendToCaptureAllValid,
cancelBtnTxt: sendToCaptureCancelBtnTxt,
confirmBtnTxt: sendToCaptureConfirmBtnTxt,
okayFn: ctrl.sendToPhotoSales
};
} else if (validImages.length !== 0 && invalidImages.length !== 0) {
// Some valid images, some invalid images selected
eventType = "displayModal";
detailObj = {
title: title,
message: sendToCaptureMixed.replace(VALIDIMAGES, validImages.length.toString()).replace(INVALIDIMAGES, invalidImages.length.toString()),
cancelBtnTxt: sendToCaptureCancelBtnTxt,
confirmBtnTxt: sendToCaptureConfirmBtnTxt,
okayFn: ctrl.sendToPhotoSales
};
} else if (validImages.length === 0 && invalidImages.length !== 0) {
// No valid images selected
eventType = "newNotification";
detailObj = {
announceId: announcementId,
description: sendToCaptureInvalid,
category: "warning",
lifespan: "transient"
};
}
const customEvent = new CustomEvent(eventType, {
detail: detailObj,
bubbles: true
});
window.dispatchEvent(customEvent);
};
const inSelectionMode$ = selection.isEmpty$.map(isEmpty => ! isEmpty);
inject$($scope, inSelectionMode$, ctrl, 'inSelectionMode');
inject$($scope, selection.count$, ctrl, 'selectionCount');
inject$($scope, selection.items$, ctrl, 'selectedItems');
function canBeDeleted(image) {
if (image && image.data.softDeletedMetadata !== undefined) { ctrl.isDeleted = true; }
return image.getAction('delete').then(angular.isDefined);
}
// TODO: move to helper?
const selectionIsDeletable$ = selectedImages$.flatMap(selectedImages => {
const allDeletablePromise = $q.
all(selectedImages.map(canBeDeleted).toArray()).
then(allDeletable => allDeletable.every(v => v === true));
return Rx.Observable.fromPromise(allDeletablePromise);
});
inject$($scope, selectedImages$, ctrl, 'selectedImages');
inject$($scope, selectionIsDeletable$, ctrl, 'selectionIsDeletable');
// TODO: avoid expensive watch expressions and let stream push
// selected status to each image instead?
ctrl.imageHasBeenSelected = (image) => ctrl.selectedItems.has(image.uri);
const toggleSelection = (image) => selection.toggle(image.uri);
ctrl.select = (image) => {
selection.add(image.uri);
$window.getSelection().removeAllRanges();
};
ctrl.deselect = (image) => {
selection.remove(image.uri);
$window.getSelection().removeAllRanges();
};
ctrl.onImageClick = function (image, $event) {
if (ctrl.inSelectionMode) {
// TODO: prevent text selection?
if ($event.shiftKey) {
var lastSelectedUri = ctrl.selectedItems.last();
var lastSelectedIndex = ctrl.images.findIndex(image => {
return image.uri === lastSelectedUri;
});
var imageIndex = ctrl.images.indexOf(image);
if (imageIndex === lastSelectedIndex) {
toggleSelection(image);
return;
}
var start = Math.min(imageIndex, lastSelectedIndex);
var end = Math.max(imageIndex, lastSelectedIndex) + 1;
const imageURIs = ctrl.images
.slice(start, end)
.map(image => image.uri);
selection.union(imageURIs);
$window.getSelection().removeAllRanges();
}
else {
$window.getSelection().removeAllRanges();
toggleSelection(image);
}
}
};
const freeUpdatesListener = $rootScope.$on('images-updated', (e, updatedImages) => {
updatedImages.map(updatedImage => {
var index = ctrl.images.findIndex(i => i.data.id === updatedImage.data.id);
if (index !== -1) {
ctrl.images[index] = updatedImage;
}
var indexAll = ctrl.imagesAll.findIndex(i => i && i.data.id === updatedImage.data.id);
if (indexAll !== -1) {
ctrl.imagesAll[indexAll] = updatedImage;
}
});
// TODO: should not be needed here, the results list
// should listen to these events and update itself
// outside of any controller.
results.map(image => {
if (image == undefined){
return image;
}
const maybeUpdated = updatedImages.find(i => i.data.id === image.data.id);
if (maybeUpdated !== undefined) {
return maybeUpdated;
}
return image;
});
});
const updateImageArray = (images, image) => {
const index = images.findIndex(i => image.data.id === i.data.id);
if (index > -1){
images.splice(index, 1);
}
};
const updatePositions = (image) => {
// an image has been deleted, so update the imagePositions map, by
// decrementing the value of all images after the one deleted.
var positionIndex = imagesPositions.get(image.data.id);
imagesPositions.delete(image.data.id);
imagesPositions.forEach((value, key) => {
if (value > positionIndex) {
imagesPositions.set(key, value - 1);
}
});
};
const freeImageDeleteListener = $rootScope.$on('images-deleted', (e, images) => {
images.forEach(image => {
// TODO: should not be needed here, the selection and
// results should listen to these events and update
// itself outside of any controller
ctrl.deselect(image);
const indexAll = ctrl.imagesAll.findIndex(i => image.data.id === i.data.id);
results.removeAt(indexAll);
updateImageArray(ctrl.images, image);
updateImageArray(ctrl.imagesAll, image);
updatePositions(image);
ctrl.totalResults--;
});
});
// Safer than clearing the timeout in case of race conditions
// FIXME: nicer (reactive?) way to do this?
var scopeGone = false;
ctrl.batchOperations = [];
ctrl.buildBatchProgressGradient = () => {
const completed = ctrl.batchOperations
.map(({ completed }) => completed)
.reduce((acc, x) => acc + x, 0);
const total = ctrl.batchOperations
.map(({ total }) => total)
.reduce((acc, x) => acc + x, 0);
const percentage = Math.round(((completed * 1.0) / total) * 100);
return {
background:
`linear-gradient(90deg, #00adee ${percentage}%, transparent ${percentage}%)`
};
};
$scope.$on("events:batch-operations:start", (e, entry) => {
ctrl.batchOperations = [entry, ...ctrl.batchOperations];
if (entry.key === "peopleInImage" && ctrl.batchOperations.length > 1){
const total = ctrl.batchOperations.reduce((acc, operations) => acc + parseInt(operations.total), 0);
ctrl.batchOperations = [Object.assign({}, entry, { total })];
}
window.onbeforeunload = function() {
return 'Batch update in progress, are you sure you want to leave?';
};
});
$scope.$on("events:batch-operations:progress", (e, { key, completed }) => {
ctrl.batchOperations = ctrl.batchOperations.map(entry => {
if (entry.key === key) {
if (entry.key === "peopleInImage") {
completed = ctrl.batchOperations[0].completed + 1;
}
return Object.assign({}, entry, { completed });
}
return entry;
});
});
$scope.$on("events:batch-operations:complete", (e, { key }) => {
if (ctrl.batchOperations[0].key === 'peopleInImage' && ctrl.batchOperations[0].total !== ctrl.batchOperations[0].completed) {
return;
}
else {
ctrl.batchOperations = ctrl.batchOperations.filter(entry => entry.key !== key);
if (ctrl.batchOperations.length === 0) {
window.onbeforeunload = null;
}
}
});
$scope.$on('$destroy', () => {
// only save scroll position if we're destroying grid scope (avoids issue regarding ng-if triggering scope refresh)
if (0 < $scope.ctrl.images.length) {
scrollPosition.save($stateParams);
}
freeUpdatesListener();
freeImageDeleteListener();
scopeGone = true;
});
}
]);