Unverified Commit 64cb045a authored by Hannah Stepanek's avatar Hannah Stepanek Committed by GitHub

Merge pull request #1211 from hypothesis/move-load-anns-to-service

Move load annotations to service
parents 11ab71ea 3c9115cd
'use strict'; 'use strict';
const SearchClient = require('../search-client');
const events = require('../events'); const events = require('../events');
const isThirdPartyService = require('../util/is-third-party-service'); const isThirdPartyService = require('../util/is-third-party-service');
const tabs = require('../tabs'); const tabs = require('../tabs');
/**
* Returns the group ID of the first annotation in `results` whose
* ID is `annId`.
*/
function getGroupID(annId, results) {
const annot = results.find(function(annot) {
return annot.id === annId;
});
if (!annot) {
return null;
}
return annot.group;
}
// @ngInject // @ngInject
function SidebarContentController( function SidebarContentController(
$scope, $scope,
analytics, analytics,
annotations,
store, store,
annotationMapper,
api,
features,
frameSync, frameSync,
groups,
rootThread, rootThread,
settings, settings,
streamer, streamer
streamFilter
) { ) {
const self = this; const self = this;
...@@ -90,61 +71,7 @@ function SidebarContentController( ...@@ -90,61 +71,7 @@ function SidebarContentController(
} }
} }
const searchClients = []; this.isLoading = () => {
function _resetAnnotations() {
annotationMapper.unloadAnnotations(store.savedAnnotations());
}
function _loadAnnotationsFor(uris, group) {
const searchClient = new SearchClient(api.search, {
// If no group is specified, we are fetching annotations from
// all groups in order to find out which group contains the selected
// annotation, therefore we need to load all chunks before processing
// the results
incremental: !!group,
});
searchClients.push(searchClient);
searchClient.on('results', function(results) {
if (store.hasSelectedAnnotations()) {
// Focus the group containing the selected annotation and filter
// annotations to those from this group
let groupID = getGroupID(store.getFirstSelectedAnnotationId(), results);
if (!groupID) {
// If the selected annotation is not available, fall back to
// loading annotations for the currently focused group
groupID = groups.focused().id;
}
results = results.filter(function(result) {
return result.group === groupID;
});
groups.focus(groupID);
}
if (results.length) {
annotationMapper.loadAnnotations(results);
}
});
searchClient.on('end', function() {
// Remove client from list of active search clients.
//
// $evalAsync is required here because search results are emitted
// asynchronously. A better solution would be that the loading state is
// tracked as part of the app state.
$scope.$evalAsync(function() {
searchClients.splice(searchClients.indexOf(searchClient), 1);
});
store.frames().forEach(function(frame) {
if (0 <= uris.indexOf(frame.uri)) {
store.updateFrameAnnotationFetchStatus(frame.uri, true);
}
});
});
searchClient.get({ uri: uris, group: group });
}
this.isLoading = function() {
if ( if (
!store.frames().some(function(frame) { !store.frames().some(function(frame) {
return frame.uri; return frame.uri;
...@@ -153,47 +80,9 @@ function SidebarContentController( ...@@ -153,47 +80,9 @@ function SidebarContentController(
// The document's URL isn't known so the document must still be loading. // The document's URL isn't known so the document must still be loading.
return true; return true;
} }
return store.isFetchingAnnotations();
if (searchClients.length > 0) {
// We're still waiting for annotation search results from the API.
return true;
}
return false;
}; };
/**
* Load annotations for all URLs associated with `frames`.
*/
function loadAnnotations() {
_resetAnnotations();
searchClients.forEach(function(client) {
client.cancel();
});
// If there is no selection, load annotations only for the focused group.
//
// If there is a selection, we load annotations for all groups, find out
// which group the first selected annotation is in and then filter the
// results on the client by that group.
//
// In the common case where the total number of annotations on
// a page that are visible to the user is not greater than
// the batch size, this saves an extra roundtrip to the server
// to fetch the selected annotation in order to determine which group
// it is in before fetching the remaining annotations.
const group = store.hasSelectedAnnotations() ? null : groups.focused().id;
const searchUris = store.searchUris();
if (searchUris.length > 0) {
_loadAnnotationsFor(searchUris, group);
streamFilter.resetFilter().addClause('/uri', 'one_of', searchUris);
streamer.setConfig('filter', { filter: streamFilter.getFilter() });
}
}
$scope.$on('sidebarOpened', function() { $scope.$on('sidebarOpened', function() {
analytics.track(analytics.events.SIDEBAR_OPENED); analytics.track(analytics.events.SIDEBAR_OPENED);
...@@ -233,27 +122,25 @@ function SidebarContentController( ...@@ -233,27 +122,25 @@ function SidebarContentController(
// Re-fetch annotations when focused group, logged-in user or connected frames // Re-fetch annotations when focused group, logged-in user or connected frames
// change. // change.
$scope.$watch( $scope.$watch(
() => [groups.focused(), store.profile().userid, ...store.searchUris()], () => [
([currentGroup], [prevGroup]) => { store.focusedGroupId(),
if (!currentGroup) { store.profile().userid,
// When switching accounts, groups are cleared and so the focused group ...store.searchUris(),
],
([currentGroupId], [prevGroupId]) => {
if (!currentGroupId) {
// When switching accounts, groups are cleared and so the focused group id
// will be null for a brief period of time. // will be null for a brief period of time.
store.clearSelectedAnnotations(); store.clearSelectedAnnotations();
return; return;
} }
if (!prevGroup || currentGroup.id !== prevGroup.id) { if (!prevGroupId || currentGroupId !== prevGroupId) {
// The focused group may be changed during loading annotations as a result
// of switching to the group containing a direct-linked annotation.
//
// In that case, we don't want to trigger reloading annotations again.
if (this.isLoading()) {
return;
}
store.clearSelectedAnnotations(); store.clearSelectedAnnotations();
} }
loadAnnotations(); const searchUris = store.searchUris();
annotations.load(searchUris, currentGroupId);
}, },
true true
); );
...@@ -277,7 +164,7 @@ function SidebarContentController( ...@@ -277,7 +164,7 @@ function SidebarContentController(
this.scrollTo = scrollToAnnotation; this.scrollTo = scrollToAnnotation;
this.selectedGroupUnavailable = function() { this.selectedGroupUnavailable = function() {
return !this.isLoading() && store.getState().directLinkedGroupFetchFailed; return store.getState().directLinkedGroupFetchFailed;
}; };
this.selectedAnnotationUnavailable = function() { this.selectedAnnotationUnavailable = function() {
...@@ -310,7 +197,9 @@ function SidebarContentController( ...@@ -310,7 +197,9 @@ function SidebarContentController(
// selection is available to the user, show the CTA. // selection is available to the user, show the CTA.
const selectedID = store.getFirstSelectedAnnotationId(); const selectedID = store.getFirstSelectedAnnotationId();
return ( return (
!this.isLoading() && !!selectedID && store.annotationExists(selectedID) !store.isFetchingAnnotations() &&
!!selectedID &&
store.annotationExists(selectedID)
); );
}; };
} }
......
...@@ -5,32 +5,6 @@ const EventEmitter = require('tiny-emitter'); ...@@ -5,32 +5,6 @@ const EventEmitter = require('tiny-emitter');
const events = require('../../events'); const events = require('../../events');
const sidebarContent = require('../sidebar-content'); const sidebarContent = require('../sidebar-content');
const util = require('../../directive/test/util');
let searchClients;
class FakeSearchClient extends EventEmitter {
constructor(searchFn, opts) {
super();
assert.ok(searchFn);
searchClients.push(this);
this.cancel = sinon.stub();
this.incremental = !!opts.incremental;
this.get = sinon.spy(function(query) {
assert.ok(query.uri);
for (let i = 0; i < query.uri.length; i++) {
const uri = query.uri[i];
this.emit('results', [{ id: uri + '123', group: '__world__' }]);
this.emit('results', [{ id: uri + '456', group: 'private-group' }]);
}
this.emit('end');
});
}
}
class FakeRootThread extends EventEmitter { class FakeRootThread extends EventEmitter {
constructor() { constructor() {
...@@ -47,16 +21,11 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -47,16 +21,11 @@ describe('sidebar.components.sidebar-content', function() {
let store; let store;
let ctrl; let ctrl;
let fakeAnalytics; let fakeAnalytics;
let fakeAnnotationMapper; let fakeAnnotations;
let fakeDrafts;
let fakeFeatures;
let fakeFrameSync; let fakeFrameSync;
let fakeGroups;
let fakeRootThread; let fakeRootThread;
let fakeSettings; let fakeSettings;
let fakeApi;
let fakeStreamer; let fakeStreamer;
let fakeStreamFilter;
let sandbox; let sandbox;
before(function() { before(function() {
...@@ -70,7 +39,6 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -70,7 +39,6 @@ describe('sidebar.components.sidebar-content', function() {
beforeEach(() => { beforeEach(() => {
angular.mock.module(function($provide) { angular.mock.module(function($provide) {
searchClients = [];
sandbox = sinon.sandbox.create(); sandbox = sinon.sandbox.create();
fakeAnalytics = { fakeAnalytics = {
...@@ -78,65 +46,32 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -78,65 +46,32 @@ describe('sidebar.components.sidebar-content', function() {
events: {}, events: {},
}; };
fakeAnnotationMapper = {
loadAnnotations: sandbox.stub(),
unloadAnnotations: sandbox.stub(),
};
fakeFrameSync = { fakeFrameSync = {
focusAnnotations: sinon.stub(), focusAnnotations: sinon.stub(),
scrollToAnnotation: sinon.stub(), scrollToAnnotation: sinon.stub(),
}; };
fakeDrafts = {
unsaved: sandbox.stub().returns([]),
};
fakeFeatures = {
flagEnabled: sandbox.stub().returns(true),
};
fakeStreamer = { fakeStreamer = {
setConfig: sandbox.stub(), setConfig: sandbox.stub(),
connect: sandbox.stub(), connect: sandbox.stub(),
reconnect: sandbox.stub(), reconnect: sandbox.stub(),
}; };
fakeStreamFilter = { fakeAnnotations = {
resetFilter: sandbox.stub().returnsThis(), load: sinon.stub(),
addClause: sandbox.stub().returnsThis(),
getFilter: sandbox.stub().returns({}),
};
fakeGroups = {
focused: sinon.stub().returns({ id: 'foo' }),
focus: sinon.stub(),
}; };
fakeRootThread = new FakeRootThread(); fakeRootThread = new FakeRootThread();
fakeSettings = {}; fakeSettings = {};
fakeApi = {
search: sinon.stub(),
};
$provide.value('analytics', fakeAnalytics); $provide.value('analytics', fakeAnalytics);
$provide.value('annotationMapper', fakeAnnotationMapper);
$provide.value('api', fakeApi);
$provide.value('drafts', fakeDrafts);
$provide.value('features', fakeFeatures);
$provide.value('frameSync', fakeFrameSync); $provide.value('frameSync', fakeFrameSync);
$provide.value('rootThread', fakeRootThread); $provide.value('rootThread', fakeRootThread);
$provide.value('streamer', fakeStreamer); $provide.value('streamer', fakeStreamer);
$provide.value('streamFilter', fakeStreamFilter); $provide.value('annotations', fakeAnnotations);
$provide.value('groups', fakeGroups);
$provide.value('settings', fakeSettings); $provide.value('settings', fakeSettings);
}); });
sidebarContent.$imports.$mock({
'../search-client': FakeSearchClient,
});
}); });
afterEach(() => { afterEach(() => {
...@@ -149,25 +84,17 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -149,25 +84,17 @@ describe('sidebar.components.sidebar-content', function() {
}); });
} }
function createSidebarContent(
{ userid } = { userid: 'acct:person@example.com' }
) {
return util.createDirective(document, 'sidebarContent', {
auth: {
status: userid ? 'logged-in' : 'logged-out',
userid: userid,
},
search: sinon.stub().returns({ query: sinon.stub() }),
onLogin: sinon.stub(),
});
}
const makeSidebarContentController = () => { const makeSidebarContentController = () => {
angular.mock.inject(function($componentController, _store_, _$rootScope_) { angular.mock.inject(function($componentController, _store_, _$rootScope_) {
$rootScope = _$rootScope_; $rootScope = _$rootScope_;
$scope = $rootScope.$new(); $scope = $rootScope.$new();
store = _store_; store = _store_;
store.updateFrameAnnotationFetchStatus = sinon.stub(); store.updateFrameAnnotationFetchStatus = sinon.stub();
store.clearGroups();
store.loadGroups([{ id: 'group-id' }]);
store.focusGroup('group-id');
ctrl = $componentController( ctrl = $componentController(
'sidebarContent', 'sidebarContent',
{ $scope: $scope }, { $scope: $scope },
...@@ -186,6 +113,23 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -186,6 +113,23 @@ describe('sidebar.components.sidebar-content', function() {
return sandbox.restore(); return sandbox.restore();
}); });
describe('isLoading', () => {
it("returns true if the document's url isn't known", () => {
assert.isTrue(ctrl.isLoading());
});
it('returns true if annotations are still being fetched', () => {
setFrames([{ uri: 'http://www.example.com' }]);
store.annotationFetchStarted('tag:foo');
assert.isTrue(ctrl.isLoading());
});
it('returns false if annotations have been fetched', () => {
setFrames([{ uri: 'http://www.example.com' }]);
assert.isFalse(ctrl.isLoading());
});
});
describe('showSelectedTabs', () => { describe('showSelectedTabs', () => {
beforeEach(() => { beforeEach(() => {
setFrames([{ uri: 'http://www.example.com' }]); setFrames([{ uri: 'http://www.example.com' }]);
...@@ -214,211 +158,10 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -214,211 +158,10 @@ describe('sidebar.components.sidebar-content', function() {
}); });
}); });
describe('#loadAnnotations', function() {
it('unloads any existing annotations', function() {
// When new clients connect, all existing annotations should be unloaded
// before reloading annotations for each currently-connected client
store.addAnnotations([{ id: '123' }]);
const uri1 = 'http://example.com/page-a';
let frames = [{ uri: uri1 }];
setFrames(frames);
$scope.$digest();
fakeAnnotationMapper.unloadAnnotations = sandbox.spy();
const uri2 = 'http://example.com/page-b';
frames = frames.concat({ uri: uri2 });
setFrames(frames);
$scope.$digest();
assert.calledWith(
fakeAnnotationMapper.unloadAnnotations,
store.getState().annotations
);
});
it('loads all annotations for a frame', function() {
const uri = 'http://example.com';
setFrames([{ uri: uri }]);
$scope.$digest();
const loadSpy = fakeAnnotationMapper.loadAnnotations;
assert.calledWith(loadSpy, [sinon.match({ id: uri + '123' })]);
assert.calledWith(loadSpy, [sinon.match({ id: uri + '456' })]);
});
it('loads all annotations for a frame with multiple urls', function() {
const uri = 'http://example.com/test.pdf';
const fingerprint = 'urn:x-pdf:fingerprint';
setFrames([
{
uri: uri,
metadata: {
documentFingerprint: 'fingerprint',
link: [
{
href: fingerprint,
},
{
href: uri,
},
],
},
},
]);
$scope.$digest();
const loadSpy = fakeAnnotationMapper.loadAnnotations;
assert.calledWith(loadSpy, [sinon.match({ id: uri + '123' })]);
assert.calledWith(loadSpy, [sinon.match({ id: fingerprint + '123' })]);
assert.calledWith(loadSpy, [sinon.match({ id: uri + '456' })]);
assert.calledWith(loadSpy, [sinon.match({ id: fingerprint + '456' })]);
});
it('loads all annotations for all frames', function() {
const uris = ['http://example.com', 'http://foobar.com'];
setFrames(
uris.map(function(uri) {
return { uri: uri };
})
);
$scope.$digest();
const loadSpy = fakeAnnotationMapper.loadAnnotations;
assert.calledWith(loadSpy, [sinon.match({ id: uris[0] + '123' })]);
assert.calledWith(loadSpy, [sinon.match({ id: uris[0] + '456' })]);
assert.calledWith(loadSpy, [sinon.match({ id: uris[1] + '123' })]);
assert.calledWith(loadSpy, [sinon.match({ id: uris[1] + '456' })]);
});
it('updates annotation fetch status for all frames', function() {
const frameUris = ['http://example.com', 'http://foobar.com'];
setFrames(
frameUris.map(function(frameUri) {
return { uri: frameUri };
})
);
$scope.$digest();
const updateSpy = store.updateFrameAnnotationFetchStatus;
assert.isTrue(updateSpy.calledWith(frameUris[0], true));
assert.isTrue(updateSpy.calledWith(frameUris[1], true));
});
context('when there is a direct-linked group error', () => {
beforeEach(() => {
setFrames([{ uri: 'http://www.example.com' }]);
fakeSettings.group = 'group-id';
store.setDirectLinkedGroupFetchFailed();
$scope.$digest();
});
[null, 'acct:person@example.com'].forEach(userid => {
it('displays same group error message regardless of login state', () => {
const element = createSidebarContent({ userid });
const sidebarContentError = element.find('.sidebar-content-error');
const errorMessage = sidebarContentError.attr(
'logged-in-error-message'
);
assert.equal(errorMessage, "'This group is not available.'");
});
});
it('selectedGroupUnavailable returns true', () => {
assert.isTrue(ctrl.selectedGroupUnavailable());
});
});
context('when there is a direct-linked group selection', () => {
beforeEach(() => {
setFrames([{ uri: 'http://www.example.com' }]);
fakeSettings.group = 'group-id';
store.loadGroups([{ id: fakeSettings.group }]);
store.focusGroup(fakeSettings.group);
fakeGroups.focused.returns({ id: fakeSettings.group });
$scope.$digest();
});
it('selectedGroupUnavailable returns false', () => {
assert.isFalse(ctrl.selectedGroupUnavailable());
});
it('fetches annotations for the direct-linked group', () => {
assert.calledWith(searchClients[0].get, {
uri: ['http://www.example.com'],
group: 'group-id',
});
});
});
context('when there is a direct-linked annotation selection', function() {
const uri = 'http://example.com';
const id = uri + '123';
beforeEach(function() {
setFrames([{ uri: uri }]);
store.selectAnnotations([id]);
$scope.$digest();
});
it("switches to the selected annotation's group", function() {
assert.calledWith(fakeGroups.focus, '__world__');
assert.calledOnce(fakeAnnotationMapper.loadAnnotations);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
{ id: uri + '123', group: '__world__' },
]);
});
it('fetches annotations for all groups', function() {
assert.calledWith(searchClients[0].get, { uri: [uri], group: null });
});
it('loads annotations in one batch', function() {
assert.notOk(searchClients[0].incremental);
});
});
context('when there is no selection', function() {
const uri = 'http://example.com';
beforeEach(function() {
setFrames([{ uri: uri }]);
fakeGroups.focused.returns({ id: 'a-group' });
$scope.$digest();
});
it('fetches annotations for the current group', function() {
assert.calledWith(searchClients[0].get, {
uri: [uri],
group: 'a-group',
});
});
it('loads annotations in batches', function() {
assert.ok(searchClients[0].incremental);
});
});
context('when the selected annotation is not available', function() {
const uri = 'http://example.com';
const id = uri + 'does-not-exist';
beforeEach(function() {
setFrames([{ uri: uri }]);
store.selectAnnotations([id]);
fakeGroups.focused.returns({ id: 'private-group' });
$scope.$digest();
});
it('loads annotations from the focused group instead', function() {
assert.calledWith(fakeGroups.focus, 'private-group');
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
{ group: 'private-group', id: 'http://example.com456' },
]);
});
});
});
function connectFrameAndPerformInitialFetch() { function connectFrameAndPerformInitialFetch() {
setFrames([{ uri: 'https://a-page.com' }]); setFrames([{ uri: 'https://a-page.com' }]);
$scope.$digest(); $scope.$digest();
fakeAnnotationMapper.loadAnnotations.reset(); fakeAnnotations.load.reset();
} }
context('when the search URIs of connected frames change', () => { context('when the search URIs of connected frames change', () => {
...@@ -429,7 +172,11 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -429,7 +172,11 @@ describe('sidebar.components.sidebar-content', function() {
$scope.$digest(); $scope.$digest();
assert.called(fakeAnnotationMapper.loadAnnotations); assert.calledWith(
fakeAnnotations.load,
['https://a-page.com', 'https://new-frame.com'],
'group-id'
);
}); });
}); });
...@@ -444,7 +191,11 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -444,7 +191,11 @@ describe('sidebar.components.sidebar-content', function() {
store.updateSession(newProfile); store.updateSession(newProfile);
$scope.$digest(); $scope.$digest();
assert.called(fakeAnnotationMapper.loadAnnotations); assert.calledWith(
fakeAnnotations.load,
['https://a-page.com'],
'group-id'
);
}); });
it('does not reload annotations if the user ID is the same', () => { it('does not reload annotations if the user ID is the same', () => {
...@@ -457,7 +208,7 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -457,7 +208,7 @@ describe('sidebar.components.sidebar-content', function() {
store.updateSession(newProfile); store.updateSession(newProfile);
$scope.$digest(); $scope.$digest();
assert.notCalled(fakeAnnotationMapper.loadAnnotations); assert.notCalled(fakeAnnotations.load);
}); });
}); });
...@@ -486,33 +237,30 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -486,33 +237,30 @@ describe('sidebar.components.sidebar-content', function() {
// annotations loaded. // annotations loaded.
store.addAnnotations([{ id: '123' }]); store.addAnnotations([{ id: '123' }]);
store.addAnnotations = sinon.stub(); store.addAnnotations = sinon.stub();
fakeDrafts.unsaved.returns([{ id: uri + '123' }, { id: uri + '456' }]);
setFrames([{ uri: uri }]); setFrames([{ uri: uri }]);
$scope.$digest(); $scope.$digest();
fakeAnnotations.load = sinon.stub();
}); });
function changeGroup() {
fakeGroups.focused.returns({ id: 'different-group' });
$scope.$digest();
}
it('should load annotations for the new group', () => { it('should load annotations for the new group', () => {
const loadSpy = fakeAnnotationMapper.loadAnnotations; store.loadGroups([{ id: 'different-group' }]);
store.focusGroup('different-group');
changeGroup();
assert.calledWith(fakeAnnotationMapper.unloadAnnotations, [
sinon.match({ id: '123' }),
]);
$scope.$digest(); $scope.$digest();
assert.calledWith(loadSpy, [sinon.match({ id: uri + '123' })]);
assert.calledWith(loadSpy, [sinon.match({ id: uri + '456' })]); assert.calledWith(
fakeAnnotations.load,
['http://example.com'],
'different-group'
);
}); });
it('should clear the selection', () => { it('should clear the selection', () => {
store.selectAnnotations(['123']); store.selectAnnotations(['123']);
store.loadGroups([{ id: 'different-group' }]);
store.focusGroup('different-group');
changeGroup(); $scope.$digest();
assert.isFalse(store.hasSelectedAnnotations()); assert.isFalse(store.hasSelectedAnnotations());
}); });
...@@ -562,10 +310,9 @@ describe('sidebar.components.sidebar-content', function() { ...@@ -562,10 +310,9 @@ describe('sidebar.components.sidebar-content', function() {
}); });
it("doesn't show a message if the document isn't loaded yet", function() { it("doesn't show a message if the document isn't loaded yet", function() {
// No search requests have been sent yet.
searchClients = [];
// There is a selection but the selected annotation isn't available. // There is a selection but the selected annotation isn't available.
store.selectAnnotations(['missing']); store.selectAnnotations(['missing']);
store.annotationFetchStarted();
$scope.$digest(); $scope.$digest();
assert.isFalse(ctrl.selectedAnnotationUnavailable()); assert.isFalse(ctrl.selectedAnnotationUnavailable());
......
...@@ -207,6 +207,7 @@ function startAngularApp(config) { ...@@ -207,6 +207,7 @@ function startAngularApp(config) {
.service('analytics', require('./services/analytics')) .service('analytics', require('./services/analytics'))
.service('annotationMapper', require('./services/annotation-mapper')) .service('annotationMapper', require('./services/annotation-mapper'))
.service('annotations', require('./services/annotations'))
.service('api', require('./services/api')) .service('api', require('./services/api'))
.service('apiRoutes', require('./services/api-routes')) .service('apiRoutes', require('./services/api-routes'))
.service('auth', require('./services/oauth-auth')) .service('auth', require('./services/oauth-auth'))
......
'use strict';
const SearchClient = require('../search-client');
// @ngInject
function annotations(annotationMapper, api, store, streamer, streamFilter) {
let searchClient = null;
/**
* Load annotations for all URIs and groupId.
*
* @param {string[]} uris
* @param {string} groupId
*/
function load(uris, groupId) {
annotationMapper.unloadAnnotations(store.savedAnnotations());
// Cancel previously running search client.
if (searchClient) {
searchClient.cancel();
}
if (uris.length > 0) {
searchAndLoad(uris, groupId);
streamFilter.resetFilter().addClause('/uri', 'one_of', uris);
streamer.setConfig('filter', { filter: streamFilter.getFilter() });
}
}
function searchAndLoad(uris, groupId) {
searchClient = new SearchClient(api.search, {
incremental: true,
});
searchClient.on('results', results => {
if (results.length) {
annotationMapper.loadAnnotations(results);
}
});
searchClient.on('error', error => {
console.error(error);
});
searchClient.on('end', () => {
// Remove client as it's no longer active.
searchClient = null;
store.frames().forEach(function(frame) {
if (0 <= uris.indexOf(frame.uri)) {
store.updateFrameAnnotationFetchStatus(frame.uri, true);
}
});
store.annotationFetchFinished();
});
store.annotationFetchStarted();
searchClient.get({ uri: uris, group: groupId });
}
return {
load,
};
}
module.exports = annotations;
'use strict';
const annotations = require('../annotations');
const EventEmitter = require('tiny-emitter');
let searchClients;
let longRunningSearchClient = false;
class FakeSearchClient extends EventEmitter {
constructor(searchFn, opts) {
super();
assert.ok(searchFn);
searchClients.push(this);
this.cancel = sinon.stub();
this.incremental = !!opts.incremental;
this.get = sinon.spy(query => {
assert.ok(query.uri);
for (let i = 0; i < query.uri.length; i++) {
const uri = query.uri[i];
this.emit('results', [{ id: uri + '123', group: '__world__' }]);
this.emit('results', [{ id: uri + '456', group: 'private-group' }]);
}
if (!longRunningSearchClient) {
this.emit('end');
}
});
}
}
describe('annotations', () => {
let fakeStore;
let fakeApi;
let fakeAnnotationMapper;
let fakeStreamer;
let fakeStreamFilter;
let fakeUris;
let fakeGroupId;
beforeEach(() => {
sinon.stub(console, 'error');
searchClients = [];
longRunningSearchClient = false;
fakeAnnotationMapper = {
loadAnnotations: sinon.stub(),
unloadAnnotations: sinon.stub(),
};
fakeApi = {
search: sinon.stub(),
};
fakeStore = {
getState: sinon.stub(),
frames: sinon.stub(),
searchUris: sinon.stub(),
savedAnnotations: sinon.stub(),
hasSelectedAnnotations: sinon.stub(),
updateFrameAnnotationFetchStatus: sinon.stub(),
annotationFetchStarted: sinon.stub(),
annotationFetchFinished: sinon.stub(),
};
fakeStreamer = {
setConfig: sinon.stub(),
connect: sinon.stub(),
reconnect: sinon.stub(),
};
fakeStreamFilter = {
resetFilter: sinon.stub().returns({
addClause: sinon.stub(),
}),
getFilter: sinon.stub().returns({}),
};
fakeUris = ['http://example.com'];
fakeGroupId = 'group-id';
annotations.$imports.$mock({
'../search-client': FakeSearchClient,
});
});
afterEach(() => {
console.error.restore();
annotations.$imports.$restore();
});
function service() {
fakeStore.frames.returns(
fakeUris.map(uri => {
return { uri: uri };
})
);
return annotations(
fakeAnnotationMapper,
fakeApi,
fakeStore,
fakeStreamer,
fakeStreamFilter
);
}
describe('load', () => {
it('unloads any existing annotations', () => {
// When new clients connect, all existing annotations should be unloaded
// before reloading annotations for each currently-connected client.
fakeStore.savedAnnotations.returns([
{ id: fakeUris[0] + '123' },
{ id: fakeUris[0] + '456' },
]);
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.calledWith(fakeAnnotationMapper.unloadAnnotations, [
sinon.match({ id: fakeUris[0] + '123' }),
sinon.match({ id: fakeUris[0] + '456' }),
]);
});
it('loads all annotations for a URI', () => {
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: fakeUris[0] + '123' }),
]);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: fakeUris[0] + '456' }),
]);
});
it('loads all annotations for a frame with multiple URIs', () => {
const uri = 'http://example.com/test.pdf';
const fingerprint = 'urn:x-pdf:fingerprint';
fakeUris = [uri, fingerprint];
const svc = service();
// Override the default frames set by the service call above.
fakeStore.frames.returns([
{
uri: uri,
metadata: {
documentFingerprint: 'fingerprint',
link: [
{
href: fingerprint,
},
{
href: uri,
},
],
},
},
]);
svc.load(fakeUris, fakeGroupId);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: uri + '123' }),
]);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: fingerprint + '123' }),
]);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: uri + '456' }),
]);
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: fingerprint + '456' }),
]);
});
it('loads all annotations for all URIs', () => {
fakeUris = ['http://example.com', 'http://foobar.com'];
const svc = service();
svc.load(fakeUris, fakeGroupId);
[
fakeUris[0] + '123',
fakeUris[0] + '456',
fakeUris[1] + '123',
fakeUris[1] + '456',
].forEach(uri => {
assert.calledWith(fakeAnnotationMapper.loadAnnotations, [
sinon.match({ id: uri }),
]);
});
});
it('updates annotation fetch status for all frames', () => {
fakeUris = ['http://example.com', 'http://foobar.com'];
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.calledWith(
fakeStore.updateFrameAnnotationFetchStatus,
fakeUris[0],
true
);
assert.calledWith(
fakeStore.updateFrameAnnotationFetchStatus,
fakeUris[1],
true
);
});
it('fetches annotations for the specified group', () => {
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.calledWith(searchClients[0].get, {
uri: fakeUris,
group: fakeGroupId,
});
});
it('loads annotations in batches', () => {
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.ok(searchClients[0].incremental);
});
it("cancels previously search client if it's still running", () => {
const svc = service();
// Issue a long running load annotations request.
longRunningSearchClient = true;
svc.load(fakeUris, fakeGroupId);
// Issue another load annotations request while the
// previous annotation load is still running.
svc.load(fakeUris, fakeGroupId);
assert.calledOnce(searchClients[0].cancel);
});
it('does not load annotations if URIs list is empty', () => {
fakeUris = [];
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.notCalled(fakeAnnotationMapper.loadAnnotations);
});
it('calls annotationFetchStarted when it starts searching for annotations', () => {
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.calledOnce(fakeStore.annotationFetchStarted);
});
it('calls annotationFetchFinished when all annotations have been found', () => {
const svc = service();
svc.load(fakeUris, fakeGroupId);
assert.calledOnce(fakeStore.annotationFetchFinished);
});
it('logs an error to the console if the search client runs into an error', () => {
const svc = service();
const error = new Error('search for annotations failed');
svc.load(fakeUris, fakeGroupId);
searchClients[0].emit('error', error);
assert.calledWith(console.error, error);
});
});
});
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