Commit 5279e095 authored by Nick Stenning's avatar Nick Stenning

Merge pull request #1855 from hypothesis/store-resource

Introduce Store resource
parents 5964631d 732f11a8
......@@ -12,22 +12,12 @@ imports = [
resolve =
storeConfig: ['$q', 'annotator', ($q, annotator) ->
if annotator.plugins.Store then return
storeReady = $q.defer()
resolve = (options) ->
annotator.options.Store ?= {}
angular.extend annotator.options.Store, options
storeReady.resolve()
annotator.subscribe 'serviceDiscovery', resolve
storeReady.promise.finally ->
annotator.unsubscribe 'serviceDiscovery', resolve
]
store: ['store', (store) -> store.$promise]
configure = [
'$locationProvider', '$routeProvider', '$sceDelegateProvider', 'streamerProvider',
($locationProvider, $routeProvider, $sceDelegateProvider, streamerProvider) ->
'$locationProvider', '$routeProvider', '$sceDelegateProvider',
($locationProvider, $routeProvider, $sceDelegateProvider) ->
$locationProvider.html5Mode(true)
$routeProvider.when '/a/:id',
......
......@@ -10,9 +10,9 @@
###
class Auth
this.$inject = ['$location', '$rootScope',
this.$inject = ['$http', '$location', '$rootScope',
'annotator', 'documentHelpers', 'identity']
constructor: ( $location, $rootScope,
constructor: ( $http, $location, $rootScope,
annotator, documentHelpers, identity) ->
{plugins} = annotator
_checkingToken = false
......@@ -34,15 +34,16 @@ class Auth
plugins.Auth.withToken (token) =>
_checkingToken = false
@user = token.userId
$http.defaults.headers.common['X-Annotator-Auth-Token'] = assertion
$rootScope.$apply()
# Fired when the identity-service forgets authentication.
# Destroys the Annotator.Auth plugin instance and sets
# the user to null.
onlogout = =>
plugins.Auth?.element.removeData('annotator:headers')
plugins.Auth?.destroy()
delete plugins.Auth
delete $http.defaults.headers.common['X-Annotator-Auth-Token']
@user = null
_checkingToken = false
......
class AppController
this.$inject = [
'$location', '$route', '$scope', '$timeout', '$window',
'annotator', 'auth', 'documentHelpers', 'drafts', 'flash', 'identity',
'$location', '$route', '$scope', '$window',
'annotator', 'auth', 'documentHelpers', 'drafts', 'identity',
'permissions', 'streamer', 'streamfilter'
]
constructor: (
$location, $route, $scope, $timeout, $window,
annotator, auth, documentHelpers, drafts, flash, identity,
$location, $route, $scope, $window,
annotator, auth, documentHelpers, drafts, identity,
permissions, streamer, streamfilter,
) ->
......@@ -21,106 +21,33 @@ class AppController
return unless data?.length
switch action
when 'create', 'update', 'past'
plugins.Store?._onLoadAnnotations data
annotator.loadAnnotations data
when 'delete'
for annotation in data
annotation = plugins.Threading.idTable[annotation.id]?.message
continue unless annotation?
plugins.Store?.unregisterAnnotation(annotation)
annotator.deleteAnnotation(annotation)
annotator.publish 'annotationDeleted', (annotation)
streamer.onmessage = (data) ->
if !data or data.type != 'annotation-notification'
return
return if !data or data.type != 'annotation-notification'
action = data.options.action
payload = data.payload
if $scope.socialView.name is 'single-player'
payload = payload.filter (ann) -> ann.user is auth.user
applyUpdates(action, payload)
$scope.$digest()
initStore = ->
# Initialize the storage component.
Store = plugins.Store
delete plugins.Store
if auth.user or annotator.socialView.name is 'none'
annotator.addPlugin 'Store', annotator.options.Store
$scope.store = plugins.Store
return unless Store
Store.destroy()
# XXX: Hacky hacky stuff to ensure that any search requests in-flight
# at this time have no effect when they resolve and that future events
# have no effect on this Store. Unfortunately, it's not possible to
# unregister all the events or properly unload the Store because the
# registration loses the closure. The approach here is perhaps
# cleaner than fishing them out of the jQuery private data.
# * Overwrite the Store's handle to the annotator, giving it one
# with a noop `loadAnnotations` method.
Store.annotator = loadAnnotations: angular.noop
# * Make all api requests into a noop.
Store._apiRequest = angular.noop
# * Ignore pending searches
Store._onLoadAnnotations = angular.noop
# * Make the update function into a noop.
Store.updateAnnotation = angular.noop
# Sort out which annotations should remain in place.
user = auth.user
view = annotator.socialView.name
cull = (acc, annotation) ->
if view is 'single-player' and annotation.user != user
acc.drop.push annotation
else if permissions.permits('read', annotation, user)
acc.keep.push annotation
else
acc.drop.push annotation
acc
{keep, drop} = Store.annotations.reduce cull, {keep: [], drop: []}
Store.annotations = []
if plugins.Store?
plugins.Store.annotations = keep
else
drop = drop.concat keep
# Clean up the ones that should be removed.
do cleanup = (drop) ->
return if drop.length == 0
[first, rest...] = drop
annotator.deleteAnnotation first
$timeout -> cleanup rest
oncancel = ->
$scope.dialog.visible = false
reset = ->
$scope.dialog.visible = false
# Update any edits in progress.
for draft in drafts.all()
annotator.publish 'beforeAnnotationCreated', draft
# Reload services
initStore()
streamer.close()
streamer.open($window.WebSocket, streamerUrl)
$scope.$watch 'socialView.name', (newValue, oldValue) ->
return if newValue is oldValue
initStore()
if newValue is 'single-player' and not auth.user
annotator.show()
flash 'info',
'You will need to sign in for your highlights to be saved.'
$scope.$on '$routeChangeStart', (event, newRoute, oldRoute) ->
return if newRoute.redirectTo
# Clean up any annotations that need to be unloaded.
for id, container of $scope.threading.idTable when container.message
# Remove annotations not belonging to this user when highlighting.
if annotator.tool is 'highlight' and annotation.user != auth.user
annotator.publish 'annotationDeleted', container.message
drafts.remove annotation
# Remove annotations the user is not authorized to view.
else if not permissions.permits 'read', container.message, auth.user
annotator.publish 'annotationDeleted', container.message
drafts.remove container.message
$scope.$watch 'sort.name', (name) ->
return unless name
......@@ -130,20 +57,27 @@ class AppController
when 'Location' then ['-!!message', 'message.target[0].pos.top']
$scope.sort = {name, predicate}
$scope.$watch 'store.entities', (entities, oldEntities) ->
return if entities is oldEntities
$scope.$watch (-> auth.user), (newVal, oldVal) ->
return if newVal is oldVal
if isFirstRun and not (newVal or oldVal)
$scope.login()
else
$scope.dialog.visible = false
# Skip the remaining if this is the first evaluation.
return if oldVal is undefined
if entities.length
streamfilter
.resetFilter()
.addClause('/uri', 'one_of', entities)
# Update any edits in progress.
for draft in drafts.all()
annotator.publish 'beforeAnnotationCreated', draft
streamer.send({filter: streamfilter.getFilter()})
# Reopen the streamer.
streamer.close()
streamer.open($window.WebSocket, streamerUrl)
$scope.$watch 'auth.user', (newVal, oldVal) ->
return if newVal is undefined
reset()
$scope.login() if isFirstRun and not (newVal or oldVal)
# Reload the view.
$route.reload()
$scope.login = ->
$scope.dialog.visible = true
......@@ -177,7 +111,6 @@ class AppController
delete $scope.selectedAnnotations
delete $scope.selectedAnnotationsCount
$scope.socialView = annotator.socialView
$scope.sort = name: 'Location'
$scope.threading = plugins.Threading
......@@ -185,21 +118,16 @@ class AppController
class AnnotationViewerController
this.$inject = [
'$location', '$routeParams', '$scope',
'annotator', 'streamer', 'streamfilter'
'annotator', 'streamer', 'store', 'streamfilter'
]
constructor: (
$location, $routeParams, $scope,
annotator, streamer, streamfilter
annotator, streamer, store, streamfilter
) ->
# Tells the view that these annotations are standalone
$scope.isEmbedded = false
$scope.isStream = false
# Clear out loaded annotations and threads
# XXX: Resolve threading, storage, and streamer better for all routes.
annotator.plugins.Threading?.pluginInit()
annotator.plugins.Store?.annotations = []
# Provide no-ops until these methods are moved elsewere. They only apply
# to annotations loaded into the stream.
$scope.focus = angular.noop
......@@ -210,11 +138,10 @@ class AnnotationViewerController
$location.path('/stream').search('q', query)
id = $routeParams.id
$scope.$watch 'store', ->
if $scope.store
$scope.store.loadAnnotationsFromSearch({_id: id}).then ->
$scope.store.loadAnnotationsFromSearch({references: id})
store.SearchResource.get _id: $routeParams.id, ({rows}) ->
annotator.loadAnnotations(rows)
store.SearchResource.get references: $routeParams.id, ({rows}) ->
annotator.loadAnnotations(rows)
streamfilter
.setPastDataNone()
......@@ -225,12 +152,44 @@ class AnnotationViewerController
streamer.send({filter: streamfilter.getFilter()})
class ViewerController
this.$inject = ['$scope', 'annotator']
constructor: ( $scope, annotator ) ->
this.$inject = [
'$scope', '$route',
'annotator', 'auth', 'flash', 'streamer', 'streamfilter', 'store'
]
constructor: (
$scope, $route,
annotator, auth, flash, streamer, streamfilter, store
) ->
# Tells the view that these annotations are embedded into the owner doc
$scope.isEmbedded = true
$scope.isStream = true
loaded = []
loadAnnotations = ->
if annotator.tool is 'highlight'
return unless auth.user
query = user: auth.user
for p in annotator.providers
for e in p.entities when e not in loaded
loaded.push e
store.SearchResource.get angular.extend(uri: e, query), (results) ->
annotator.loadAnnotations(results.rows)
streamfilter.resetFilter().addClause('/uri', 'one_of', loaded)
if auth.user and annotator.tool is 'highlight'
streamfilter.addClause('/user', auth.user)
streamer.send({filter: streamfilter.getFilter()})
$scope.$watch (-> annotator.tool), (newVal, oldVal) ->
return if newVal is oldVal
$route.reload()
$scope.$watchCollection (-> annotator.providers), loadAnnotations
$scope.focus = (annotation) ->
if angular.isObject annotation
highlights = [annotation.$$tag]
......
......@@ -159,9 +159,11 @@ AnnotationController = [
switch @action
when 'create'
annotator.publish 'annotationCreated', model
model.$create().then ->
annotator.publish 'annotationCreated', model
when 'delete', 'edit'
annotator.publish 'annotationUpdated', model
model.$update(id: model.id).then ->
annotator.publish 'annotationUpdated', model
@editing = false
@action = 'view'
......@@ -181,8 +183,7 @@ AnnotationController = [
# Construct the reply.
references = [references..., id]
reply = {references, uri}
annotator.publish 'beforeAnnotationCreated', reply
reply = annotator.createAnnotation {references, uri}
if auth.user?
if permissions.isPublic model.permissions
......@@ -275,9 +276,11 @@ AnnotationController = [
# Save highlights once logged in.
if highlight and this.isHighlight()
if auth.user
if model.user and not model.id
highlight = false # skip this on future updates
model.permissions = permissions.private()
annotator.publish 'annotationCreated', model
model.$create().then ->
annotator.publish 'annotationCreated', model
highlight = false # skip this on future updates
else
drafts.add model, => this.revert()
......@@ -309,17 +312,6 @@ annotation = [
'$document', 'annotator',
($document, annotator) ->
linkFn = (scope, elem, attrs, [ctrl, thread, threadFilter, counter]) ->
# Helper function to remove the temporary thread created for a new reply.
prune = (message) ->
return if message.id? # threading plugin will take care of it
return unless thread.container.message is message
thread.container.parent?.removeChild(thread.container)
if thread?
annotator.subscribe 'annotationDeleted', prune
scope.$on '$destroy', ->
annotator.unsubscribe 'annotationDeleted', prune
# Observe the embedded attribute
attrs.$observe 'annotationEmbedded', (value) ->
ctrl.embedded = value? and value != 'false'
......
###*
# @ngdoc service
# @name Permissions
......
class Annotator.Plugin.Discovery extends Annotator.Plugin
pluginInit: ->
svc = $('link')
.filter ->
this.rel is 'service' and this.type is 'application/annotatorsvc+json'
.filter ->
this.href
return unless svc.length
href = svc[0].href
$.getJSON href, (data) =>
return unless data?.links?
options =
prefix: href.replace /\/$/, ''
urls: {}
if data.links.search?.url?
options.urls.search = data.links.search.url
for action, info of (data.links.annotation or {}) when info.url?
options.urls[action] = info.url
for action, url of options.urls
options.urls[action] = url.replace(options.prefix, '')
@annotator.publish 'serviceDiscovery', options
......@@ -6,6 +6,7 @@ class Annotator.Plugin.Threading extends Annotator.Plugin
events:
'beforeAnnotationCreated': 'beforeAnnotationCreated'
'annotationCreated': 'annotationCreated'
'annotationDeleted': 'annotationDeleted'
'annotationsLoaded': 'annotationsLoaded'
......@@ -59,14 +60,32 @@ class Annotator.Plugin.Threading extends Annotator.Plugin
if !container.message && container.children.length == 0
parent.removeChild(container)
delete this.idTable[container.message?.id]
beforeAnnotationCreated: (annotation) =>
this.thread([annotation])
annotationDeleted: ({id}) =>
container = this.getContainer id
container.message = null
this.pruneEmpties(@root)
annotationCreated: (annotation) =>
references = annotation.references or []
if typeof(annotation.references) == 'string' then references = []
ref = references[references.length-1]
parent = if ref then @idTable[ref] else @root
for child in (parent.children or []) when child.message is annotation
@idTable[annotation.id] = child
break
annotationDeleted: (annotation) =>
if id of this.idTable
container = this.idTable[id]
container.message = null
delete this.idTable[id]
this.pruneEmpties(@root)
else
for id, container of this.idTable
for child in container.children when child.message is annotation
child.message = null
this.pruneEmpties(@root)
return
annotationsLoaded: (annotations) =>
messages = (@root.flattenChildren() or []).concat(annotations)
......
......@@ -29,15 +29,12 @@ renderFactory = ['$$rAF', ($$rAF) ->
class Hypothesis extends Annotator
events:
'beforeAnnotationCreated': 'beforeAnnotationCreated'
'annotationCreated': 'digest'
'annotationDeleted': 'annotationDeleted'
'annotationUpdated': 'digest'
'annotationsLoaded': 'digest'
# Plugin configuration
options:
noDocAccess: true
Discovery: {}
Threading: {}
# Internal state
......@@ -47,17 +44,12 @@ class Hypothesis extends Annotator
tool: 'comment'
visibleHighlights: false
this.$inject = ['$document', '$window']
constructor: ( $document, $window ) ->
this.$inject = ['$document', '$window', 'store']
constructor: ( $document, $window, store ) ->
super ($document.find 'body')
window.annotator = this
@providers = []
@socialView =
name: "none" # "single-player"
this.patch_store()
@store = store
# Load plugins
for own name, opts of @options
......@@ -69,13 +61,13 @@ class Hypothesis extends Annotator
whitelist = ['target', 'document', 'uri']
this.addPlugin 'Bridge',
gateway: true
formatter: (annotation) =>
formatter: (annotation) ->
formatted = {}
for k, v of annotation when k in whitelist
formatted[k] = v
formatted
parser: (annotation) =>
parsed = {}
parser: (annotation) ->
parsed = new store.AnnotationResource()
for k, v of annotation when k in whitelist
parsed[k] = v
parsed
......@@ -84,21 +76,16 @@ class Hypothesis extends Annotator
window: source
origin: origin
scope: "#{scope}:provider"
onReady: =>
if source is $window.parent then @host = channel
entities = []
onReady: => if source is $window.parent then @host = channel
channel = this._setupXDM options
provider = channel: channel, entities: []
channel.call
method: 'getDocumentInfo'
success: (info) =>
entityUris = {}
entityUris[info.uri] = true
for link in info.metadata.link
entityUris[link.href] = true if link.href
for href of entityUris
entities.push href
this.plugins.Store?.loadAnnotations()
provider.entities = (link.href for link in info.metadata.link)
@providers.push provider
@element.scope().$digest()
this.digest()
# Allow the host to define it's own state
......@@ -111,10 +98,6 @@ class Hypothesis extends Annotator
method: 'setVisibleHighlights'
params: this.visibleHighlights
@providers.push
channel: channel
entities: entities
_setupXDM: (options) ->
# jschannel chokes FF and Chrome extension origins.
if (options.origin.match /^chrome-extension:\/\//) or
......@@ -195,6 +178,27 @@ class Hypothesis extends Annotator
_setupDocumentAccessStrategies: -> this
_scan: -> this
createAnnotation: (annotation) ->
annotation = new @store.AnnotationResource(annotation)
this.publish 'beforeAnnotationCreated', annotation
annotation
deleteAnnotation: (annotation) ->
annotation.$delete(id: annotation.id).then =>
this.publish 'annotationDeleted', annotation
annotation
loadAnnotations: (annotations) ->
annotations = for annotation in annotations
container = @plugins.Threading.idTable[annotation.id]
if container?.message
angular.copy annotation, container.message
this.publish 'annotationUpdated', container.message
continue
else
annotation
super (new @store.AnnotationResource(a) for a in annotations)
# Do nothing in the app frame, let the host handle it.
setupAnnotation: (annotation) -> annotation
......@@ -263,77 +267,9 @@ class Hypothesis extends Annotator
if scope.selectedAnnotations?[annotation.id]
delete scope.selectedAnnotations[annotation.id]
@_setSelectedAnnotations scope.selectedAnnotations
@digest()
patch_store: ->
scope = @element.scope()
Store = Annotator.Plugin.Store
# When the Store plugin is first instantiated, don't load annotations.
# They will be loaded manually as entities are registered by participating
# frames.
Store.prototype.loadAnnotations = ->
query = limit: 1000
@annotator.considerSocialView.call @annotator, query
entities = {}
for p in @annotator.providers
for uri in p.entities
unless entities[uri]?
entities[uri] = true
this.loadAnnotationsFromSearch (angular.extend {}, query, uri: uri)
this.entities = Object.keys(entities)
# When the store plugin finishes a request, update the annotation
# using a monkey-patched update function which updates the threading
# if the annotation has a newly-assigned id and ensures that the id
# is enumerable.
Store.prototype.updateAnnotation = (annotation, data) =>
# Update the annotation with the new data
annotation = angular.extend annotation, data
# Update the thread table
update = (parent) ->
for child in parent.children when child.message is annotation
scope.threading.idTable[data.id] = child
return true
return false
# Check its references
references = annotation.references or []
if typeof(annotation.references) == 'string' then references = []
for ref in references.slice().reverse()
container = scope.threading.idTable[ref]
continue unless container?
break if update container
# Check the root
update scope.threading.root
# Update the view
this.digest()
considerSocialView: (query) ->
switch @socialView.name
when "none"
# Sweet, nothing to do, just clean up previous filters
delete query.user
when "single-player"
if @user?
query.user = @element.injector().get('auth').user
else
delete query.user
setTool: (name) ->
return if name is @tool
if name is 'highlight'
this.socialView.name = 'single-player'
else
this.socialView.name = 'none'
@tool = name
this.publish 'setTool', name
for p in @providers
......
###*
# @ngdoc service
# @name store
#
# @description
# The `store` service handles the backend calls for the restful API. This is
# created dynamically from the API index as the angular $resource() method
# supports the same keys as the index document. This will make a resource
# constructor for each endpoint eg. store.AnnotationResource() and
# store.SearchResource().
###
angular.module('h')
.service('store', [
'$document', '$http', '$resource',
($document, $http, $resource) ->
# Find any service link tag
svc = $document.find('link')
.filter -> @rel is 'service' and @type is 'application/annotatorsvc+json'
.filter -> @href
.prop('href')
camelize = (string) ->
string.replace /(?:^|_)([a-z])/g, (_, char) -> char.toUpperCase()
store =
$resolved: false
# We call the service_url and the backend api gives back
# the actions and urls it provides.
$promise: $http.get(svc)
.finally -> store.$resolved = true
.then (response) ->
for name, actions of response.data.links
# For each action name we configure an ng-resource.
# For the search resource, one URL is given for all actions.
# For the annotations, each action has its own URL.
prop = "#{camelize(name)}Resource"
store[prop] = $resource(actions.url or svc, {}, actions)
store
])
......@@ -5,7 +5,7 @@ ST_CLOSED = 3
###*
# @ngdoc service
# @name Streamer
# @name streamer
#
# @property {string} clientId A unique identifier for this client.
#
......@@ -59,7 +59,6 @@ class Streamer
# Give the application a chance to initialize the connection
self.onopen(name: 'open')
# Process queued messages
self._sendQueue()
......
class StreamSearchController
this.inject = [
'$scope', '$rootScope', '$routeParams',
'annotator', 'queryparser', 'searchfilter', 'streamer', 'streamfilter'
'annotator', 'auth', 'queryparser', 'searchfilter', 'store',
'streamer', 'streamfilter'
]
constructor: (
$scope, $rootScope, $routeParams
annotator, queryparser, searchfilter, streamer, streamfilter
annotator, auth, queryparser, searchfilter, store,
streamer, streamfilter
) ->
# Clear out loaded annotations and threads
# XXX: Resolve threading, storage, and streamer better for all routes.
annotator.plugins.Threading?.pluginInit()
annotator.plugins.Store?.annotations = []
# Initialize the base filter
streamfilter
.resetFilter()
.setMatchPolicyIncludeAll()
.setPastDataHits(50)
# Apply query clauses
$scope.search.query = $routeParams.q
......@@ -30,10 +26,14 @@ class StreamSearchController
$scope.shouldShowThread = (container) -> true
streamer.send({filter: streamfilter.getFilter()})
$scope.$on '$destroy', ->
$scope.search.query = ''
$scope.$watch (-> auth.user), ->
query = angular.extend limit: 10, $scope.search.query
store.SearchResource.get query, ({rows}) ->
annotator.loadAnnotations(rows)
angular.module('h')
.controller('StreamSearchController', StreamSearchController)
// Generated by CoffeeScript 1.6.3
/*
** Annotator 1.2.6-dev-6623b2c
** https://github.com/okfn/annotator/
**
** Copyright 2012 Aron Carroll, Rufus Pollock, and Nick Stenning.
** Dual licensed under the MIT and GPLv3 licenses.
** https://github.com/okfn/annotator/blob/master/LICENSE
**
** Built at: 2014-12-11 01:33:34Z
*/
/*
//
*/
// Generated by CoffeeScript 1.6.3
(function() {
var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
__hasProp = {}.hasOwnProperty,
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
__indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
Annotator.Plugin.Store = (function(_super) {
__extends(Store, _super);
Store.prototype.events = {
'annotationCreated': 'annotationCreated',
'annotationDeleted': 'annotationDeleted',
'annotationUpdated': 'annotationUpdated'
};
Store.prototype.options = {
annotationData: {},
emulateHTTP: false,
loadFromSearch: false,
prefix: '/store',
urls: {
create: '/annotations',
read: '/annotations/:id',
update: '/annotations/:id',
destroy: '/annotations/:id',
search: '/search'
}
};
function Store(element, options) {
this._onError = __bind(this._onError, this);
this._onLoadAnnotationsFromSearch = __bind(this._onLoadAnnotationsFromSearch, this);
this._onLoadAnnotations = __bind(this._onLoadAnnotations, this);
this._getAnnotations = __bind(this._getAnnotations, this);
Store.__super__.constructor.apply(this, arguments);
this.annotations = [];
}
Store.prototype.pluginInit = function() {
if (!Annotator.supported()) {
return;
}
if (this.annotator.plugins.Auth) {
return this.annotator.plugins.Auth.withToken(this._getAnnotations);
} else {
return this._getAnnotations();
}
};
Store.prototype._getAnnotations = function() {
if (this.options.loadFromSearch) {
return this.loadAnnotationsFromSearch(this.options.loadFromSearch);
} else {
return this.loadAnnotations();
}
};
Store.prototype.annotationCreated = function(annotation) {
var _this = this;
if (__indexOf.call(this.annotations, annotation) < 0) {
this.registerAnnotation(annotation);
return this._apiRequest('create', annotation, function(data) {
if (data.id == null) {
console.warn(Annotator._t("Warning: No ID returned from server for annotation "), annotation);
}
return _this.updateAnnotation(annotation, data);
});
} else {
return this.updateAnnotation(annotation, {});
}
};
Store.prototype.annotationUpdated = function(annotation) {
var _this = this;
if (__indexOf.call(this.annotations, annotation) >= 0) {
return this._apiRequest('update', annotation, (function(data) {
return _this.updateAnnotation(annotation, data);
}));
}
};
Store.prototype.annotationDeleted = function(annotation) {
var _this = this;
if (__indexOf.call(this.annotations, annotation) >= 0) {
return this._apiRequest('destroy', annotation, (function() {
return _this.unregisterAnnotation(annotation);
}));
}
};
Store.prototype.registerAnnotation = function(annotation) {
return this.annotations.push(annotation);
};
Store.prototype.unregisterAnnotation = function(annotation) {
return this.annotations.splice(this.annotations.indexOf(annotation), 1);
};
Store.prototype.updateAnnotation = function(annotation, data) {
if (__indexOf.call(this.annotations, annotation) < 0) {
console.error(Annotator._t("Trying to update unregistered annotation!"));
} else {
$.extend(annotation, data);
}
return $(annotation.highlights).data('annotation', annotation);
};
Store.prototype.loadAnnotations = function() {
return this._apiRequest('read', null, this._onLoadAnnotations);
};
Store.prototype._onLoadAnnotations = function(data) {
var a, annotation, annotationMap, newData, _i, _j, _len, _len1, _ref;
if (data == null) {
data = [];
}
annotationMap = {};
_ref = this.annotations;
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
a = _ref[_i];
annotationMap[a.id] = a;
}
newData = [];
for (_j = 0, _len1 = data.length; _j < _len1; _j++) {
a = data[_j];
if (annotationMap[a.id]) {
annotation = annotationMap[a.id];
this.updateAnnotation(annotation, a);
} else {
newData.push(a);
}
}
this.annotations = this.annotations.concat(newData);
return this.annotator.loadAnnotations(newData.slice());
};
Store.prototype.loadAnnotationsFromSearch = function(searchOptions) {
return this._apiRequest('search', searchOptions, this._onLoadAnnotationsFromSearch);
};
Store.prototype._onLoadAnnotationsFromSearch = function(data) {
if (data == null) {
data = {};
}
return this._onLoadAnnotations(data.rows || []);
};
Store.prototype.dumpAnnotations = function() {
var ann, _i, _len, _ref, _results;
_ref = this.annotations;
_results = [];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
ann = _ref[_i];
_results.push(JSON.parse(this._dataFor(ann)));
}
return _results;
};
Store.prototype._apiRequest = function(action, obj, onSuccess) {
var id, options, request, url;
id = obj && obj.id;
url = this._urlFor(action, id);
options = this._apiRequestOptions(action, obj, onSuccess);
request = $.ajax(url, options);
request._id = id;
request._action = action;
return request;
};
Store.prototype._apiRequestOptions = function(action, obj, onSuccess) {
var data, method, opts;
method = this._methodFor(action);
opts = {
type: method,
headers: this.element.data('annotator:headers'),
dataType: "json",
success: onSuccess || function() {},
error: this._onError
};
if (this.options.emulateHTTP && (method === 'PUT' || method === 'DELETE')) {
opts.headers = $.extend(opts.headers, {
'X-HTTP-Method-Override': method
});
opts.type = 'POST';
}
if (action === "search") {
opts = $.extend(opts, {
data: obj
});
return opts;
}
data = obj && this._dataFor(obj);
if (this.options.emulateJSON) {
opts.data = {
json: data
};
if (this.options.emulateHTTP) {
opts.data._method = method;
}
return opts;
}
opts = $.extend(opts, {
data: data,
contentType: "application/json; charset=utf-8"
});
return opts;
};
Store.prototype._urlFor = function(action, id) {
var url;
url = this.options.prefix != null ? this.options.prefix : '';
url += this.options.urls[action];
url = url.replace(/\/:id/, id != null ? '/' + id : '');
url = url.replace(/:id/, id != null ? id : '');
return url;
};
Store.prototype._methodFor = function(action) {
var table;
table = {
'create': 'POST',
'read': 'GET',
'update': 'PUT',
'destroy': 'DELETE',
'search': 'GET'
};
return table[action];
};
Store.prototype._dataFor = function(annotation) {
var data, field, k, saved, v, _i, _len, _ref;
saved = {};
_ref = ["highlights", "anchors"];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
field = _ref[_i];
if (annotation[field] != null) {
saved[field] = annotation[field];
delete annotation[field];
}
}
$.extend(annotation, this.options.annotationData);
data = JSON.stringify(annotation);
for (k in saved) {
v = saved[k];
annotation[k] = v;
}
return data;
};
Store.prototype._onError = function(xhr) {
var action, message;
action = xhr._action;
message = Annotator._t("Sorry we could not ") + action + Annotator._t(" this annotation");
if (xhr._action === 'search') {
message = Annotator._t("Sorry we could not search the store for annotations");
} else if (xhr._action === 'read' && !xhr._id) {
message = Annotator._t("Sorry we could not ") + action + Annotator._t(" the annotations from the store");
}
switch (xhr.status) {
case 401:
message = Annotator._t("Sorry you are not allowed to ") + action + Annotator._t(" this annotation");
break;
case 404:
message = Annotator._t("Sorry we could not connect to the annotations store");
break;
case 500:
message = Annotator._t("Sorry something went wrong with the annotation store");
}
Annotator.showNotification(message, Annotator.Notification.ERROR);
return console.error(Annotator._t("API request failed:") + (" '" + xhr.status + "'"));
};
return Store;
})(Annotator.Plugin);
}).call(this);
......@@ -37,7 +37,6 @@ module.exports = function(config) {
'h/static/scripts/vendor/annotator.js',
'h/static/scripts/vendor/annotator.auth.js',
'h/static/scripts/vendor/annotator.document.js',
'h/static/scripts/vendor/annotator.store.js',
'h/static/scripts/plugin/bridge.js',
'h/static/scripts/plugin/discovery.js',
'h/static/scripts/plugin/threading.js',
......
......@@ -43,9 +43,11 @@ describe 'h', ->
describe 'auth service', ->
$http = null
auth = null
beforeEach inject (_auth_) ->
beforeEach inject (_$http_, _auth_) ->
$http = _$http_
auth = _auth_
it 'watches the identity service for identity change events', ->
......@@ -56,20 +58,36 @@ describe 'h', ->
onready()
assert.isNull(auth.user)
it 'sets auth.user at login', ->
{onlogin} = fakeIdentity.watch.args[0][0]
onlogin('test-assertion')
fakeToken = { userId: 'acct:hey@joe'}
userSetter = fakeAnnotator.plugins.Auth.withToken.args[0][0]
userSetter(fakeToken)
assert.equal(auth.user, 'acct:hey@joe')
describe 'at login', ->
beforeEach ->
{onlogin} = fakeIdentity.watch.args[0][0]
onlogin('test-assertion')
fakeToken = { userId: 'acct:hey@joe'}
userSetter = fakeAnnotator.plugins.Auth.withToken.args[0][0]
userSetter(fakeToken)
it 'destroys the plugin at logout and sets auth.user to null', ->
{onlogout} = fakeIdentity.watch.args[0][0]
auth.user = 'acct:hey@joe'
authPlugin = fakeAnnotator.plugins.Auth
onlogout()
it 'sets auth.user', ->
assert.equal(auth.user, 'acct:hey@joe')
assert.called(authPlugin.destroy)
assert.equal(auth.user, null)
it 'sets the token header as a default header', ->
token = $http.defaults.headers.common['X-Annotator-Auth-Token']
assert.equal(token, 'test-assertion')
describe 'at logout', ->
authPlugin = null
beforeEach ->
{onlogout} = fakeIdentity.watch.args[0][0]
auth.user = 'acct:hey@joe'
authPlugin = fakeAnnotator.plugins.Auth
onlogout()
it 'destroys the plugin', ->
assert.called(authPlugin.destroy)
it 'sets auth.user to null', ->
assert.equal(auth.user, null)
it 'unsets the token header', ->
token = $http.defaults.headers.common['X-Annotator-Auth-Token']
assert.isUndefined(token)
assert = chai.assert
sinon.assert.expose assert, prefix: null
fakeStore =
SearchResource:
get: sinon.spy()
describe 'h', ->
$scope = null
fakeAuth = null
......@@ -75,6 +80,7 @@ describe 'h', ->
$scope.search = {}
annotationViewer = $controller 'AnnotationViewerController',
$scope: $scope
store: fakeStore
it 'sets the isEmbedded property to false', ->
assert.isFalse($scope.isEmbedded)
......@@ -11,6 +11,7 @@ describe 'h.directives.annotation', ->
createController = null
flash = null
fakeAuth = null
fakeStore = null
fakeUser = null
beforeEach module('h')
......@@ -21,6 +22,7 @@ describe 'h.directives.annotation', ->
user: 'acct:bill@localhost'
$provide.value 'auth', fakeAuth
$provide.value 'store', fakeStore
return
beforeEach inject (_$compile_, $controller, _$document_, $rootScope, _$timeout_) ->
......@@ -29,7 +31,11 @@ describe 'h.directives.annotation', ->
$timeout = _$timeout_
$scope = $rootScope.$new()
$scope.annotationGet = (locals) -> annotation
annotator = {plugins: {}, publish: sandbox.spy()}
annotator = {
createAnnotation: sandbox.spy (data) -> data
plugins: {},
publish: sandbox.spy()
}
annotation =
id: 'deadbeef'
document:
......@@ -48,6 +54,30 @@ describe 'h.directives.annotation', ->
afterEach ->
sandbox.restore()
describe 'when the annotation is a highlight', ->
beforeEach ->
annotator.tool = 'highlight'
annotation.$create = sinon.stub().returns
then: angular.noop
catch: angular.noop
finally: angular.noop
it 'persists upon login', ->
delete annotation.id
delete annotation.user
controller = createController()
$scope.$digest()
assert.notCalled annotation.$create
annotation.user = 'acct:ted@wyldstallyns.com'
$scope.$digest()
assert.calledOnce annotation.$create
it 'is private', ->
delete annotation.id
controller = createController()
$scope.$digest()
assert controller.isPrivate()
describe '#reply', ->
controller = null
container = null
......@@ -72,22 +102,22 @@ describe 'h.directives.annotation', ->
it 'creates a new reply with the proper uri and references', ->
controller.reply()
match = sinon.match {references: [annotation.id], uri: annotation.uri}
assert.calledWith(annotator.publish, 'beforeAnnotationCreated', match)
assert.calledWith(annotator.createAnnotation, match)
it 'adds the world readable principal if the parent is public', ->
annotation.permissions.read.push('group:__world__')
controller.reply()
newAnnotation = annotator.publish.lastCall.args[1]
newAnnotation = annotator.createAnnotation.lastCall.args[0]
assert.include(newAnnotation.permissions.read, 'group:__world__')
it 'does not add the world readable principal if the parent is private', ->
controller.reply()
newAnnotation = annotator.publish.lastCall.args[1]
newAnnotation = annotator.createAnnotation.lastCall.args[0]
assert.notInclude(newAnnotation.permissions.read, 'group:__world__')
it 'fills the other permissions too', ->
controller.reply()
newAnnotation = annotator.publish.lastCall.args[1]
newAnnotation = annotator.createAnnotation.lastCall.args[0]
assert.equal(newAnnotation.permissions.update[0], 'acct:bill@localhost')
assert.equal(newAnnotation.permissions.delete[0], 'acct:bill@localhost')
assert.equal(newAnnotation.permissions.admin[0], 'acct:bill@localhost')
......
......@@ -17,7 +17,6 @@ describe 'h.directives.privacy', ->
describe 'memory fallback', ->
fakeAuth = null
fakeWindow = null
sandbox = null
beforeEach module ($provide) ->
......@@ -27,24 +26,23 @@ describe 'h.directives.privacy', ->
user: 'acct:angry.joe@texas.com'
}
fakeWindow = {
localStorage: undefined
}
$provide.value 'auth', fakeAuth
$provide.value '$window', fakeWindow
return
afterEach ->
sandbox.restore()
describe 'has memory fallback', ->
$window = null
$scope2 = null
beforeEach inject (_$compile_, _$rootScope_) ->
beforeEach inject (_$compile_, _$rootScope_, _$window_) ->
$compile = _$compile_
$scope = _$rootScope_.$new()
$scope2 = _$rootScope_.$new()
$window = _$window_
$window.localStorage = null
it 'stores the default visibility level when it changes', ->
$scope.permissions = {read: ['acct:user@example.com']}
......
assert = chai.assert
sinon.assert.expose assert, prefix: null
describe 'store', ->
$httpBackend = null
sandbox = null
store = null
fakeDocument = null
beforeEach module('h')
beforeEach module ($provide) ->
sandbox = sinon.sandbox.create()
link = document.createElement("link")
link.rel = 'service'
link.type = 'application/annotatorsvc+json'
link.href = 'http://example.com/api'
fakeDocument = {
find: sandbox.stub().returns($(link))
}
$provide.value '$document', fakeDocument
return
afterEach ->
sandbox.restore()
beforeEach inject ($q, _$httpBackend_, _store_) ->
$httpBackend = _$httpBackend_
store = _store_
$httpBackend.expectGET('http://example.com/api').respond
links:
annotation:
create: {
method: 'POST'
url: 'http://example.com/api/annotations'
}
delete: {}
read: {}
update: {}
search:
url: 'http://0.0.0.0:5000/api/search'
beware_dragons:
url: 'http://0.0.0.0:5000/api/roar'
$httpBackend.flush()
it 'reads the operations from the backend', ->
assert.isFunction(store.AnnotationResource, 'expected store.AnnotationResource to be a function')
assert.isFunction(store.BewareDragonsResource, 'expected store.BewareDragonsResource to be a function')
assert.isFunction(store.SearchResource, 'expected store.SearchResource to be a function')
it 'saves a new annotation', ->
annotation = { id: 'test'}
annotation = new store.AnnotationResource(annotation)
saved = {}
annotation.$create().then ->
assert.isNotNull(saved.id)
$httpBackend.expectPOST('http://example.com/api/annotations', annotation).respond ->
saved.id = annotation.id
return [201, {}, {}]
$httpBackend.flush()
......@@ -97,6 +97,7 @@ describe 'streamer', ->
msg = fakeSock.send.getCall(0).args[0]
data = JSON.parse(msg)
assert.equal(data.messageType, 'client_id')
assert.equal(typeof data.value, 'string')
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment