kahuna/public/js/edits/service.js (195 lines of code) (raw):
import angular from 'angular';
import Rx from 'rx';
import {editsApi} from '../services/api/edits-api';
import {mediaApi} from '../services/api/media-api';
import { overwrite, prepend, append } from '../util/constants/editOptions';
import { trackAll } from '../util/batch-tracking';
import { getMetadataDiff } from './metadataDiff';
export var service = angular.module('kahuna.edits.service', [
editsApi.name,
mediaApi.name
]);
// TODO: For now we're sending over the image so we can compare against it to
// see when it's synced. We should have a link on the resource to be able to do
// this.
service.factory('editsService',
['$rootScope', '$q', 'editsApi', 'mediaApi', 'apiPoll', 'imageAccessor',
function($rootScope, $q, editsApi, mediaApi, apiPoll, imageAccessor) {
/**
* @param edit {Resource} the edit you'd like to match
* @param image {Resource} the image which you're searching in
* @return {Promise.<Resource>}
*/
function findMatchingEditInImage(edit, image) {
return edit.getUri().then(uri => {
const edits = image.data.userMetadata.data;
const matchingEdit = Object.keys(edits)
.map(key => edits[key])
.find(r => r.uri === uri);
return matchingEdit;
});
}
/**
* Searches for the `edit` in `image` and compares the two
* @param edit {Resource}
* @param image {Resource}
* @returns {Promise.<Resource>|reject} return the `edit` resource on `success`
*/
function matches(edit, image) {
// find that matching resource
return findMatchingEditInImage(edit, image).then(matchingEdit =>
matchingEdit && angular.equals(matchingEdit.data, edit.data) ?
{ edit, image } : $q.reject('data not matching')
);
}
/**
*
* @param image {Resource} image to observe for synchronisation
* @param check {Function} a function that takes the new image as an argument
* to compare against
* @returns {Promise}
*/
function getSynced(image, check) {
const checkSynced = () => image.get().then(check);
return apiPoll(checkSynced);
}
/**
* @param resource {Resource} resource to update
* @param data {*} add to original `data`
* @param originalImage {Resource} the image used to check if we've re-indexed yet
* @returns {Promise.<Resource>} completed when information is synced
*/
function add(resource, data, originalImage) {
runWatcher(resource, 'update-start');
return resource.post({ data }).then(edit =>
getSynced(originalImage, newImage => matches(edit, newImage))).
then(({ edit }) => {
runWatcher(resource, 'update-end');
return edit;
}).
catch(() => runWatcher(resource, 'update-error'));
}
function firstAsPromise(stream$) {
const defer = $q.defer();
const unsubscribe = stream$.subscribe(defer.resolve, defer.reject);
defer.promise.finally(unsubscribe);
return defer.promise;
}
// A pool a requests. Its promise will be resolved with the last
// request added to the pool.
function createRequestPool() {
const requestsPromises = new Rx.Subject();
const latestCompleted = requestsPromises.flatMapLatest(Rx.Observable.fromPromise);
return {
registerPromise: (promise) => requestsPromises.onNext(promise),
promise: firstAsPromise(latestCompleted)
};
}
function withWatcher(resource, promise) {
runWatcher(resource, 'update-start');
return promise.
then(val => {
runWatcher(resource, 'update-end');
return val;
}).
catch(e => {
runWatcher(resource, 'update-error');
return $q.reject(e);
});
}
// Map of Resource to request pool, storing all currently active
// update requests for a given Resource
const updateRequestPools = new Map();
// inBatch determines whether the function chain should eventually emit an angular message
// as emitting multiple times is very performance heavy
// ideally this should be refactored out.
function registerUpdateRequest(resource, originalImage, inBatch = false) {
const requestPool = createRequestPool();
const promise = withWatcher(resource, requestPool.promise).
then(({ edit, image }) => {
if (!inBatch) {
$rootScope.$emit('images-updated', [image]);
}
return edit;
});
const newRequest = {
registerPromise: requestPool.registerPromise,
promise
};
// Register request pool, free once done
updateRequestPools.set(resource, newRequest);
promise.finally(() => updateRequestPools.delete(resource));
return newRequest;
}
// inBatch determines whether the function chain should eventually emit an angular message
// as emitting multiple times is very performance heavy
// ideally this should be refactored out.
/**
* @param resource {Resource} resource to update
* @param data {*} PUTs `data` and replaces old data
* @param originalImage {Resource} the image used to check if we've re-indexed yet
* @param inBatch {Boolean} is this being called multiple times? (see comment)
* @returns {Promise.<Resource>} completed when information is synced
*/
function update(resource, data, originalImage, inBatch = false) {
const newRequest = resource.put({ data }).
then(edit => getSynced(originalImage, newImage => matches(edit, newImage)));
const existingRequestPool = updateRequestPools.get(resource) ||
registerUpdateRequest(resource, originalImage, inBatch);
existingRequestPool.registerPromise(newRequest);
return existingRequestPool.promise;
}
// HACK: This is a very specific action that we use the `updateRequestPool` ast this action
// actually updates the metadata as a sideeffect.
// ALSO: inBatch determines whether the function chain should eventually emit an angular message
// as emitting multiple times is very performance heavy
// ideally this should be refactored out.
function updateMetadataFromUsageRights(originalImage, inBatch = false) {
const resource = originalImage.data.userMetadata.data.metadata;
const newRequest = resource.perform('set-from-usage-rights').
then(edit => getSynced(originalImage, newImage => matches(edit, newImage)));
const existingRequestPool = updateRequestPools.get(resource) ||
registerUpdateRequest(resource, originalImage, inBatch);
existingRequestPool.registerPromise(newRequest);
return existingRequestPool.promise;
}
// Event handling
// TODO: Use proper names from http://en.wikipedia.org/wiki/Watcher_%28comics%29
/**
* @type {Map.<String => Map>} a map with key as the resource URI and value
* as a watcher Map (see `createWatcher`).
*/
const watchers = new Map();
/**
* @returns {Map.<String => Set>} a map of `key = event` and a Set of
* callback functions to rn on that event.
*/
const publicWatcherEvents = ['update-start', 'update-end', 'update-error'];
function createWatcher() {
return new Map(
publicWatcherEvents.map(event => [event, new Set()])
);
}
function runWatcher(resource, event) {
resource.getUri().then(uri => {
const watcher = watchers.get(uri);
if (watcher) {
watcher.get(event).forEach(cb => cb());
}
});
}
/**
* @param resource {Resource}
* @param event {String} event that matches in `publicEvents`
* @param cb {Function} callback to run on event
* @return {Function} function you should run to de-register event
*/
function on(resource, event, cb) {
resource.getUri().then(uri => {
var watcher = watchers.get(uri);
if (!watcher) {
watchers.set(uri, createWatcher());
// just a wee bit of mutability as we don't have `Option`s just `undefined`s
watcher = watchers.get(uri);
}
watcher.get(event).add(cb);
});
return () => off(resource, event, cb);
}
function off(resource, event, cb) {
resource.getUri().then(uri => {
watchers.get(uri).get(event).delete(cb);
});
}
function canUserEdit(image) {
return image.getLink('edits')
.then(() => true, () => false);
}
// inBatch determines whether the function chain should eventually emit an angular message
// as emitting multiple times is very performance heavy
// ideally this should be refactored out.
function updateMetadataField (image, field, value, inBatch = false) {
var metadata = image.data.metadata;
if (metadata[field] === value) {
/*
Nothing has changed.
Per the angular-xeditable docs, returning false indicates success but model
will not be updated.
http://vitalets.github.io/angular-xeditable/#onbeforesave
NOTE: Tying a service to a UI component isn't ideal as it means
consumers of this function have to either xeditable or adopt the
same behaviour as xeditable.
*/
return $q.when(false);
}
var proposedMetadata = angular.copy(metadata);
proposedMetadata[field] = value;
// peopleInImage is a special case. This turns a comma-separated string into an array trimmed of whitespace
if (field === 'peopleInImage' || field === 'keywords' ) {
proposedMetadata[field] = value.toString().split(',')
.map(s => s.trim())
.filter(s => s !== "");
}
var changed = getMetadataDiff(image, proposedMetadata);
if (field === 'location') {
Object.assign(changed, value);
}
return update(image.data.userMetadata.data.metadata, changed, image, inBatch)
.then(() => image.get());
}
function updateDomainMetadataField (image, domainMetadataName, field, value) {
const domainMetadata = image.data.metadata.domainMetadata;
if (domainMetadata && domainMetadata[domainMetadataName] && domainMetadata[domainMetadataName][field] && domainMetadata[domainMetadataName][field] === value) {
/*
Nothing has changed.
*/
return $q.when(false);
}
let proposedDomainMetadata = domainMetadata ? angular.copy(domainMetadata) : {};
// if model and field exists, update value
// else if model exists and field does not exist, add field and value else
if (domainMetadata && domainMetadata[domainMetadataName]) {
proposedDomainMetadata[domainMetadataName][field] = value;
} else {
proposedDomainMetadata[domainMetadataName] = { [field]: value };
}
const changed = getMetadataDiff(image, { ...image.data.metadata, domainMetadata: proposedDomainMetadata });
return update(image.data.userMetadata.data.metadata, changed, image).then(() => image.get());
}
function getNewFieldValue(image, field, value, editOption) {
switch (editOption) {
case prepend.key:
return value + ' ' + imageAccessor.readMetadata(image)[field];
case append.key:
return imageAccessor.readMetadata(image)[field] + ' ' + value;
default:
return value;
}
}
function batchUpdateMetadataField(images, field, value, editOption = overwrite.key) {
return trackAll($q, $rootScope, field, images, (image) => {
const newFieldValue = getNewFieldValue(image, field, value, editOption);
return updateMetadataField(image, field, newFieldValue, true).then(
updated => updated || image // updateMetadataField returns false if no change
);
}, 'images-updated');
}
return {
update, add, on, canUserEdit, updateMetadataFromUsageRights,
updateMetadataField, batchUpdateMetadataField, updateDomainMetadataField
};
}]);