View
@@ -11,9 +11,45 @@
from annotator import document
def is_file(uri):
"""Return True if the given uri is a local file uri."""
return uri and uri.lower().startswith("file:///")
def _format_document_link(href, title, link_text, hostname):
"""Return a document link for the given components.
Helper function for the .document_link property below.
The given href, title, link_text and hostname are assumed to be already
safely escaped. The returned string will be a Markup object so that
it can be rendered in Jinja2 templates without further escaped occurring.
"""
if hostname and hostname in link_text:
hostname = ""
def truncate(content, length=50):
"""Truncate the given string to at most length chars."""
if len(content) <= length:
return content
else:
return content[:length] + jinja2.Markup("&hellip;")
hostname = truncate(hostname)
link_text = truncate(link_text)
if href and hostname:
link = ('<a href="{href}" title="{title}">{link_text}</a> '
'({hostname})'.format(href=href, title=title,
link_text=link_text, hostname=hostname))
elif hostname and not href:
link = ('<a title="{title}">{link_text}</a> ({hostname})'.format(
title=title, link_text=link_text, hostname=hostname))
elif href and not hostname:
link = '<a href="{href}" title="{title}">{link_text}</a>'.format(
href=href, title=title, link_text=link_text)
else:
link = '<a title="{title}">{link_text}</a>'.format(
title=title, link_text=link_text)
return jinja2.Markup(link)
class Annotation(annotation.Annotation):
@@ -165,29 +201,174 @@ class Annotation(annotation.Annotation):
def get_analysis(cls):
return cls.__analysis__
@property
def uri(self):
"""Return this annotation's URI or an empty string.
The uri is escaped and safe to be rendered.
The uri is a Markup object so it won't be double-escaped.
"""
uri_ = self.get("uri")
if uri_:
# Convert non-string URIs into strings.
# If the URI is already a unicode string this will do nothing.
# We're assuming that URI cannot be a byte string.
uri_ = unicode(uri_)
return jinja2.escape(uri_)
else:
return ""
@property
def filename(self):
"""Return the filename of this annotation's document, or "".
If the annotated URI is a file:// URI then return the filename part
of it, otherwise return "".
The filename is escaped and safe to be rendered.
If it contains escaped characters then the filename will be a
Markup object so it won't be double-escaped.
"""
if self.uri.lower().startswith("file:///"):
# self.uri is already escaped so we don't need to escape it again
# here.
return self.uri.split("/")[-1]
else:
return ""
@property
def title(self):
"""A title for this annotation."""
"""Return a title for this annotation.
Return the annotated document's title or if the document has no title
then return its filename (if it's a file:// URI) or its URI for
non-file URIs.
The title is escaped and safe to be rendered.
If it contains escaped characters then the title will be a
Markup object, so that it won't be double-escaped.
"""
document_ = self.get("document")
if document_:
return document_.get("title", "")
try:
title = document_["title"]
except (KeyError, TypeError):
# Sometimes document_ has no "title" key or isn't a dict at
# all.
title = ""
if title:
# Convert non-string titles into strings.
# We're assuming that title cannot be a byte string.
title = unicode(title)
return jinja2.escape(title)
if self.filename:
# self.filename is already escaped so we don't need to escape
# it again here, but we do want to unquote it for readability.
return urllib2.unquote(self.filename)
else:
return ""
# self.uri is already escaped so we don't need to escape
# it again here, but we do want to unquote it for readability.
return urllib2.unquote(self.uri)
@property
def filename(self):
if is_file(self.uri):
return self.uri.split("/")[-1] or ""
def hostname_or_filename(self):
"""Return the hostname of this annotation's document.
Returns the hostname part of the annotated document's URI, e.g.
"www.example.com" for "http://www.example.com/example.html".
If the URI is a file:// URI then return the filename part of it
instead.
The returned hostname or filename is escaped and safe to be rendered.
If it contains escaped characters the returned value will be a Markup
object so that it doesn't get double-escaped.
"""
if self.filename:
# self.filename is already escaped, doesn't need to be escaped
# again here.
return self.filename
else:
# self.uri is already escaped, doesn't need to be escaped again.
hostname = urlparse.urlparse(self.uri).hostname
# urlparse()'s .hostname is sometimes None.
hostname = hostname or ""
return hostname
@property
def href(self):
"""Return an href for this annotation's document, or "".
Returns a value suitable for use as the value of the href attribute in
an <a> element in an HTML document.
Returns an empty string if the annotation doesn't have a document with
an http(s):// URI.
The href is escaped and safe to be rendered.
If it contains escaped characters the returned value will be a
Markup object so that it doesn't get double-escaped.
"""
uri = self.uri # self.uri is already escaped.
if (uri.lower().startswith("http://") or
uri.lower().startswith("https://")):
return uri
else:
return ""
@property
def link_text(self):
"""Return some link text for this annotation's document.
Return a text representation of this annotation's document suitable
for use as the link text in a link like <a ...>{link_text}</a>.
Returns the document's title if it has one, or failing that uses part
of the annotated URI if the annotation has one.
The link text is escaped and safe for rendering.
If it contains escaped characters the returned value will be a
Markup object so it doesn't get double-escaped.
"""
# self.title is already escaped.
title = self.title
# Sometimes self.title is the annotated document's URI (if the document
# has no title). In those cases we want to remove the http(s):// from
# the front and unquote it for link text.
lower = title.lower()
if lower.startswith("http://") or lower.startswith("https://"):
parts = urlparse.urlparse(title)
return urllib2.unquote(parts.netloc + parts.path)
else:
return title
@property
def document_link(self):
"""Return a link to this annotation's document.
Returns HTML strings like:
<a href="{href}" title="{title}">{link_text}</a> ({domain})
<a href="{href}" title="{title}">{link_text}</a> ({hostname})
where:
@@ -199,87 +380,25 @@ def document_link(self):
not the full path.
- {link_text} is the same as {title}, but truncated with &hellip; if
it's too long
- {domain} is the domain name of the document's uri without
- {hostname} is the hostname name of the document's uri without
the scheme (http(s)://) and www parts, e.g. "example.com".
If it's a local file:// uri then the filename is used as the domain.
If the domain is too long it is truncated with &hellip;.
If it's a local file:// uri then the filename is used as the
hostname.
If the hostname is too long it is truncated with &hellip;.
The ({domain}) part will be missing if it wouldn't be any different
The ({hostname}) part will be missing if it wouldn't be any different
from the {link_text} part.
The href="{href}" will be missing if there's no http(s) uri to link to
for this annotation's document.
User-supplied values are escaped so the string is safe for raw
rendering (the returned string is actually a jinja2.Markup object and
rendering (the returned string is actually a Markup object and
won't be escaped by Jinja2 when rendering).
"""
uri = jinja2.escape(self.uri) or ""
if (uri.lower().startswith("http://") or
uri.lower().startswith("https://")):
href = uri
else:
href = ""
if self.title:
title = jinja2.escape(self.title)
link_text = title
if is_file(uri):
domain = jinja2.escape(self.filename)
else:
domain = urlparse.urlparse(uri).hostname
else:
if is_file(uri):
title = urllib2.unquote(jinja2.escape(self.filename))
link_text = title
domain = ""
else:
title = urllib2.unquote(uri)
parts = urlparse.urlparse(uri)
link_text = urllib2.unquote(parts.netloc + parts.path)
domain = ""
if domain == title:
domain = ""
def truncate(content, length=50):
"""Truncate the given string to at most length chars."""
if len(content) <= length:
return content
else:
return content[:length] + jinja2.Markup("&hellip;")
if link_text:
link_text = truncate(link_text)
if domain:
domain = truncate(domain)
assert title, "The title should never be empty"
assert link_text, "The link text should never be empty"
if href and domain:
link = ('<a href="{href}" title="{title}">{link_text}</a> '
'({domain})'.format(href=href, title=title,
link_text=link_text, domain=domain))
elif domain and not href:
link = ('<a title="{title}">{link_text}</a> ({domain})'.format(
title=title, link_text=link_text, domain=domain))
elif href and not domain:
link = '<a href="{href}" title="{title}">{link_text}</a>'.format(
href=href, title=title, link_text=link_text)
elif (not href) and (not domain):
link = '<a title="{title}">{link_text}</a>'.format(
title=title, link_text=link_text)
else:
assert False, "We should never get here"
return jinja2.Markup(link)
@property
def uri(self):
"""This annotation's URI or an empty string."""
return self.get("uri", "")
return _format_document_link(
self.href, self.title, self.link_text, self.hostname_or_filename)
@property
def description(self):
View
@@ -110,22 +110,22 @@ def __init__(self, request, private=True):
self.private = private
def __call__(self, params):
groups = list(self.request.effective_principals)
principals = list(self.request.effective_principals)
# We always want annotations with 'group:__world__' in their read
# permissions to show up in the search results, but 'group:__world__'
# is not in effective_principals for unauthenticated requests.
#
# FIXME: If public annotations used 'system.Everyone'
# instead of 'group:__world__' we wouldn't have to do this.
if 'group:__world__' not in groups:
groups.insert(0, 'group:__world__')
if 'group:__world__' not in principals:
principals.insert(0, 'group:__world__')
if not self.private:
groups = [g for g in groups
if not g == self.request.authenticated_userid]
principals = [p for p in principals
if not p == self.request.authenticated_userid]
return {'terms': {'permissions.read': groups}}
return {'terms': {'permissions.read': principals}}
class GroupFilter(object):
View

Large diffs are not rendered by default.

Oops, something went wrong.
View
File renamed without changes.
View
@@ -18,7 +18,7 @@
<div class="group-form-footer">
<a href="" class="group-form-footer__explain-link js-group-info-link">Tell me more about groups</a>
<div class="group-form-footer__explain-text js-group-info-text is-hidden">
{% include "about-groups.html" %}
{% include "about-groups.html.jinja2" %}
<p class="group-form-footer__heading">How to use groups</p>
<ol class="group-form-footer__list">
<li>Choose a name for your group</li>
View
@@ -28,7 +28,7 @@
</div>
<div class="group-form-footer">
<div class="group-form-footer__explain-text">
{% include "about-groups.html" %}
{% include "about-groups.html.jinja2" %}
<p class="group-form-footer__heading">You can</p>
<ol class="group-form-footer__list">
View
@@ -350,8 +350,8 @@ def test_read_if_already_a_member_returns_response(Group, renderers):
@read_fixtures
def test_read_calls_search(Group, search, renderers):
"""It should call search() to get the annotations."""
def test_read_calls_search_correctly(Group, search, renderers):
"""It should call search() with the right args to get the annotations."""
request = _mock_request(matchdict=_matchdict())
g = Group.get_by_pubid.return_value = mock.Mock(slug=mock.sentinel.slug)
user = request.authenticated_user = mock.Mock()
@@ -364,28 +364,6 @@ def test_read_calls_search(Group, search, renderers):
request, private=False, params={"group": g.pubid, "limit": 1000})
@read_fixtures
def test_read_calls_normalize(Group, search, renderers, uri):
"""It shold call normalize() with each of the URIs from search()."""
request = _mock_request(matchdict=_matchdict())
g = Group.get_by_pubid.return_value = mock.Mock(slug=mock.sentinel.slug)
user = request.authenticated_user = mock.Mock()
user.groups = [g] # The user is a member of the group.
renderers.render_to_response.return_value = mock.sentinel.response
search.search.return_value = {
"rows": [
mock.Mock(uri="uri_1"),
mock.Mock(uri="uri_2"),
mock.Mock(uri="uri_3"),
]
}
views.read(request)
assert uri.normalize.call_args_list == [
mock.call("uri_1"), mock.call("uri_2"), mock.call("uri_3")]
@read_fixtures
def test_read_returns_document_links(Group, search, renderers, uri):
"""It should return the list of document links."""
@@ -432,7 +410,7 @@ def test_read_duplicate_documents_are_removed(Group, search, renderers, uri):
search.search.return_value = {"rows": annotations}
def normalize(uri):
# All three annotations' URIs normalize to the same URI.
# All annotations' URIs normalize to the same URI.
return "normalized"
uri.normalize.side_effect = normalize
View
@@ -48,10 +48,11 @@ module.exports = class AppController
# Reload the view when the focused group changes or the
# list of groups that the user is a member of changes
reloadEvents = [events.SESSION_CHANGED, events.GROUP_FOCUSED];
reloadEvents = [events.USER_CHANGED, events.GROUP_FOCUSED];
reloadEvents.forEach((eventName) ->
$scope.$on(eventName, (event) ->
$route.reload()
$scope.$on(eventName, (event, data) ->
if !data || !data.initialLoad
$route.reload()
)
);
View
@@ -7,6 +7,9 @@ require('angular-jwt')
streamer = require('./streamer')
resolve =
# Ensure that we have feature flags available before we load the main
# view as features such as groups affect which annotations are loaded
featuresLoaded: ['features', (features) -> features.fetch()]
# Ensure that we have available a) the current authenticated userid, and b)
# the list of user groups.
sessionState: ['session', (session) -> session.load().$promise]
View
@@ -156,6 +156,14 @@ AnnotationController = [
this.hasContent = ->
@annotation.text?.length > 0 || @annotation.tags?.length > 0
###*
# @returns {boolean} True if this annotation has quotes
###
this.hasQuotes = ->
@annotation.target.some (target) ->
target.selector && target.selector.some (selector) ->
selector.type == 'TextQuoteSelector'
###*
# @ngdoc method
# @name annotation.AnnotationController#authorize
@@ -455,6 +463,8 @@ module.exports = [
require: ['annotation', '?^thread', '?^threadFilter', '?^deepCount']
scope:
annotationGet: '&annotation'
# indicates whether this is the last reply in a thread
isLastReply: '='
replyCount: '@annotationReplyCount'
replyCountClick: '&annotationReplyCountClick'
showReplyCount: '@annotationShowReplyCount'
View
@@ -1,7 +1,9 @@
'use strict';
var events = require('../events');
// @ngInject
function GroupListController($scope, $window) {
function GroupListController($scope, $window, groups) {
$scope.expandedGroupId = undefined;
// show the share link for the specified group or clear it if
@@ -19,13 +21,17 @@ function GroupListController($scope, $window) {
};
$scope.leaveGroup = function (groupId) {
var groupName = $scope.groups.get(groupId).name;
var groupName = groups.get(groupId).name;
var message = 'Are you sure you want to leave the group "' +
groupName + '"?';
if ($window.confirm(message)) {
$scope.groups.leave(groupId);
groups.leave(groupId);
}
}
$scope.focusGroup = function (groupId) {
groups.focus(groupId);
}
}
/**
View
@@ -278,6 +278,22 @@ describe 'annotation', ->
controller.annotation.tags = [{text: 'foo'}]
assert.ok(controller.hasContent())
describe '#hasQuotes', ->
beforeEach ->
createDirective()
it 'returns false if the annotation has no quotes', ->
controller.annotation.target = [{}]
assert.isFalse(controller.hasQuotes())
it 'returns true if the annotation has quotes', ->
controller.annotation.target = [{
selector: [{
type: 'TextQuoteSelector'
}]
}]
assert.isTrue(controller.hasQuotes())
describe '#render', ->
beforeEach ->
View
@@ -1,5 +1,6 @@
'use strict';
var events = require('../../events');
var groupList = require('../group-list');
var util = require('./util');
@@ -8,8 +9,16 @@ describe('GroupListController', function () {
var $scope;
beforeEach(function () {
$scope = {};
controller = new groupList.Controller($scope);
$scope = {
$on: sinon.stub(),
$apply: sinon.stub(),
};
var fakeWindow = {};
var fakeGroups = {
all: sinon.stub(),
focused: sinon.stub(),
};
controller = new groupList.Controller($scope, fakeWindow, fakeGroups);
});
it('toggles share links', function () {
@@ -44,19 +53,12 @@ function isElementHidden(element) {
}
describe('groupList', function () {
var $rootScope;
var $window;
var GROUP_LINK = 'https://hypothes.is/groups/hdevs';
var groups = [{
id: 'public',
public: true
},{
id: 'h-devs',
name: 'Hypothesis Developers',
url: GROUP_LINK
}];
var groups;
var fakeGroups;
before(function() {
@@ -72,9 +74,19 @@ describe('groupList', function () {
angular.mock.module('h.templates');
});
beforeEach(angular.mock.inject(function (_$window_) {
beforeEach(angular.mock.inject(function (_$rootScope_, _$window_) {
$rootScope = _$rootScope_;
$window = _$window_;
groups = [{
id: 'public',
public: true
},{
id: 'h-devs',
name: 'Hypothesis Developers',
url: GROUP_LINK
}];
fakeGroups = {
all: function () {
return groups;
@@ -87,6 +99,7 @@ describe('groupList', function () {
},
leave: sinon.stub(),
focus: sinon.stub(),
focused: sinon.stub(),
};
}));
View
@@ -2,10 +2,13 @@
* This module defines the set of global events that are dispatched
* on $rootScope
*/
module.exports = {
/** Broadcast when the currently selected group changes */
GROUP_FOCUSED: 'groupFocused',
/** Broadcast when the list of groups changes */
GROUPS_CHANGED: 'groupsChanged',
/** Broadcast when the signed-in user changes */
USER_CHANGED: 'userChanged',
/** Broadcast when the session state is updated.
* This event is NOT broadcast after the initial session load.
*/
View
@@ -21,58 +21,66 @@
*/
'use strict';
var retry = require('retry');
var assign = require('core-js/modules/$.assign');
var events = require('./events');
var CACHE_TTL = 5 * 60 * 1000; // 5 minutes
function features ($document, $http, $log) {
// @ngInject
function features ($document, $http, $log, $rootScope) {
var cache = null;
var operation = null;
var featuresUrl = new URL('/app/features', $document.prop('baseURI')).href;
var fetchOperation;
function fetch() {
// Short-circuit if a fetch is already in progress...
if (operation) {
return;
}
operation = retry.operation({retries: 10, randomize: true});
function success(data) {
cache = [Date.now(), data];
operation = null;
}
$rootScope.$on(events.USER_CHANGED, function () {
cache = null;
});
function failure(data, status) {
if (!operation.retry('failed to load - remote status was ' + status)) {
// All retries have failed, and we will now stop polling the endpoint.
$log.error('features service:', operation.mainError());
}
function fetch() {
if (fetchOperation) {
// fetch already in progress
return fetchOperation;
}
operation.attempt(function () {
$http.get(featuresUrl)
.success(success)
.error(failure);
fetchOperation = $http.get(featuresUrl).then(function (response) {
cache = {
updated: Date.now(),
flags: response.data,
};
}).catch(function (err) {
// if for any reason fetching features fails, we behave as
// if all flags are turned off
$log.warn('failed to fetch feature data', err);
cache = assign({}, cache, {
updated: Date.now(),
});
}).finally(function () {
fetchOperation = null;
});
return fetchOperation;
}
function flagEnabled(name) {
// Trigger a fetch if the cache is more than CACHE_TTL milliseconds old.
// We don't wait for the fetch to complete, so it's not this call that
// will see new data.
if (cache === null || (Date.now() - cache[0]) > CACHE_TTL) {
if (!cache || (Date.now() - cache.updated) > CACHE_TTL) {
fetch();
}
if (cache === null) {
if (!cache || !cache.flags) {
// a fetch is either in progress or fetching the feature flags
// failed
return false;
}
var flags = cache[1];
if (!flags.hasOwnProperty(name)) {
if (!cache.flags.hasOwnProperty(name)) {
$log.warn('features service: looked up unknown feature:', name);
return false;
}
return flags[name];
return cache.flags[name];
}
return {
@@ -81,4 +89,4 @@ function features ($document, $http, $log) {
};
}
module.exports = ['$document', '$http', '$log', features];
module.exports = features;
View
@@ -84,7 +84,7 @@ function groups(localStorage, session, $rootScope, features, $http) {
}
// reset the focused group if the user leaves it
$rootScope.$on(events.SESSION_CHANGED, function () {
$rootScope.$on(events.GROUPS_CHANGED, function () {
if (focusedGroup) {
focusedGroup = get(focusedGroup.id);
if (!focusedGroup) {
View
@@ -116,6 +116,9 @@ function session($document, $http, $resource, $rootScope, flash) {
resource.update = function (model) {
var isInitialLoad = !resource.state.csrf;
var userChanged = model.userid !== resource.state.userid;
var groupsChanged = !angular.equals(model.groups, resource.state.groups);
// Copy the model data (including the CSRF token) into `resource.state`.
angular.copy(model, resource.state);
@@ -128,8 +131,19 @@ function session($document, $http, $resource, $rootScope, flash) {
lastLoad = {$promise: Promise.resolve(model), $resolved: true};
lastLoadTime = Date.now();
if (!isInitialLoad) {
$rootScope.$broadcast(events.SESSION_CHANGED);
$rootScope.$broadcast(events.SESSION_CHANGED, {
initialLoad: isInitialLoad,
});
if (userChanged) {
$rootScope.$broadcast(events.USER_CHANGED, {
initialLoad: isInitialLoad,
});
}
if (groupsChanged) {
$rootScope.$broadcast(events.GROUPS_CHANGED, {
initialLoad: isInitialLoad,
});
}
// Return the model
View
@@ -24,6 +24,56 @@ function stripInternalProperties(obj) {
return result;
}
function forEachSorted(obj, iterator, context) {
var keys = Object.keys(obj).sort();
for (var i = 0; i < keys.length; i++) {
iterator.call(context, obj[keys[i]], keys[i]);
}
return keys;
}
function serializeValue(v) {
if (angular.isObject(v)) {
return angular.isDate(v) ? v.toISOString() : angular.toJson(v);
}
return v;
}
function encodeUriQuery(val) {
return encodeURIComponent(val).replace(/%20/g, '+');
}
// Serialize an object containing parameters into a form suitable for a query
// string.
//
// This is an almost identical copy of the default Angular parameter serializer
// ($httpParamSerializer), with one important change. In Angular 1.4.x
// semicolons are not encoded in query parameter values. This is a problem for
// us as URIs around the web may well contain semicolons, which our backend will
// then proceed to parse as a delimiter in the query string. To avoid this
// problem we use a very conservative encoder, found above.
function serializeParams(params) {
if (!params) return '';
var parts = [];
forEachSorted(params, function(value, key) {
if (value === null || typeof value === 'undefined') return;
if (angular.isArray(value)) {
angular.forEach(value, function(v, k) {
parts.push(encodeUriQuery(key) + '=' + encodeUriQuery(serializeValue(v)));
});
} else {
parts.push(encodeUriQuery(key) + '=' + encodeUriQuery(serializeValue(value)));
}
});
return parts.join('&');
}
/**
* @ngdoc factory
* @name store
@@ -41,6 +91,7 @@ function stripInternalProperties(obj) {
function store($http, $resource, settings) {
var instance = {};
var defaultOptions = {
paramSerializer: serializeParams,
transformRequest: prependTransform(
$http.defaults.transformRequest,
stripInternalProperties
@@ -54,9 +105,15 @@ function store($http, $resource, settings) {
.then(function (response) {
var links = response.data.links;
instance.SearchResource = $resource(links.search.url, {}, defaultOptions);
// N.B. in both cases below we explicitly override the default `get`
// action because there is no way to provide defaultOptions to the default
// action.
instance.SearchResource = $resource(links.search.url, {}, {
get: angular.extend({url: links.search.url}, defaultOptions),
});
instance.AnnotationResource = $resource(links.annotation.read.url, {}, {
get: angular.extend(links.annotation.read, defaultOptions),
create: angular.extend(links.annotation.create, defaultOptions),
update: angular.extend(links.annotation.update, defaultOptions),
delete: angular.extend(links.annotation.delete, defaultOptions),
View
@@ -151,8 +151,14 @@ describe 'AppController', ->
$scope.$broadcast(events.GROUP_FOCUSED)
assert.calledOnce(fakeRoute.reload)
it 'reloads the view when the session state changes', ->
it 'does not reload the view when the logged-in user changes on first load', ->
createController()
fakeRoute.reload = sinon.spy()
$scope.$broadcast(events.SESSION_CHANGED)
$scope.$broadcast(events.USER_CHANGED, {initialLoad: true})
assert.notCalled(fakeRoute.reload)
it 'reloads the view when the logged-in user changes after first load', ->
createController()
fakeRoute.reload = sinon.spy()
$scope.$broadcast(events.USER_CHANGED, {initialLoad: false})
assert.calledOnce(fakeRoute.reload)
View
@@ -1,9 +1,12 @@
"use strict";
'use strict';
var mock = angular.mock;
var events = require('../events');
describe('h:features', function () {
var $httpBackend;
var $rootScope;
var features;
var sandbox;
@@ -26,6 +29,7 @@ describe('h:features', function () {
beforeEach(mock.inject(function ($injector) {
$httpBackend = $injector.get('$httpBackend');
$rootScope = $injector.get('$rootScope');
features = $injector.get('features');
}));
@@ -41,71 +45,101 @@ describe('h:features', function () {
return handler;
}
it('fetch should retrieve features data', function () {
defaultHandler();
features.fetch();
$httpBackend.flush();
});
it('fetch should not explode for errors fetching features data', function () {
defaultHandler().respond(500, "ASPLODE!");
features.fetch();
$httpBackend.flush();
});
it('fetch should only send one request at a time', function () {
defaultHandler();
features.fetch();
features.fetch();
$httpBackend.flush();
});
it('flagEnabled should retrieve features data', function () {
defaultHandler();
features.flagEnabled('foo');
$httpBackend.flush();
});
it('flagEnabled should return false initially', function () {
defaultHandler();
var result = features.flagEnabled('foo');
$httpBackend.flush();
assert.isFalse(result);
describe('fetch', function() {
it('should retrieve features data', function () {
defaultHandler();
features.fetch();
$httpBackend.flush();
assert.equal(features.flagEnabled('foo'), true);
});
it('should return a promise', function () {
defaultHandler();
features.fetch().then(function () {
assert.equal(features.flagEnabled('foo'), true);
});
$httpBackend.flush();
});
it('should not explode for errors fetching features data', function () {
defaultHandler().respond(500, "ASPLODE!");
var handler = sinon.stub();
features.fetch().then(handler);
$httpBackend.flush();
assert.calledOnce(handler);
});
it('should only send one request at a time', function () {
defaultHandler();
features.fetch();
features.fetch();
$httpBackend.flush();
});
});
it('flagEnabled should return flag values when data is loaded', function () {
defaultHandler();
features.fetch();
$httpBackend.flush();
var foo = features.flagEnabled('foo');
assert.isTrue(foo);
var bar = features.flagEnabled('bar');
assert.isFalse(bar);
});
it('flagEnabled should return false for unknown flags', function () {
defaultHandler();
features.fetch();
$httpBackend.flush();
var baz = features.flagEnabled('baz');
assert.isFalse(baz);
});
it('flagEnabled should trigger a new fetch after cache expiry', function () {
var clock = sandbox.useFakeTimers();
defaultHandler();
features.flagEnabled('foo');
$httpBackend.flush();
clock.tick(301 * 1000);
defaultHandler();
features.flagEnabled('foo');
$httpBackend.flush();
describe('flagEnabled', function () {
it('should retrieve features data', function () {
defaultHandler();
features.flagEnabled('foo');
$httpBackend.flush();
});
it('should return false initially', function () {
defaultHandler();
var result = features.flagEnabled('foo');
$httpBackend.flush();
assert.isFalse(result);
});
it('should return flag values when data is loaded', function () {
defaultHandler();
features.fetch();
$httpBackend.flush();
var foo = features.flagEnabled('foo');
assert.isTrue(foo);
var bar = features.flagEnabled('bar');
assert.isFalse(bar);
});
it('should return false for unknown flags', function () {
defaultHandler();
features.fetch();
$httpBackend.flush();
var baz = features.flagEnabled('baz');
assert.isFalse(baz);
});
it('should trigger a new fetch after cache expiry', function () {
var clock = sandbox.useFakeTimers();
defaultHandler();
features.flagEnabled('foo');
$httpBackend.flush();
clock.tick(301 * 1000);
defaultHandler();
features.flagEnabled('foo');
$httpBackend.flush();
});
it('should clear the features data when the user changes', function () {
// fetch features and check that the flag is set
defaultHandler();
features.fetch();
$httpBackend.flush();
assert.isTrue(features.flagEnabled('foo'));
// simulate a change of logged-in user which should clear
// the features cache
$rootScope.$broadcast(events.USER_CHANGED, {});
defaultHandler();
assert.isFalse(features.flagEnabled('foo'));
$httpBackend.flush();
});
});
});
View
@@ -40,7 +40,7 @@ describe('groups', function() {
$broadcast: sandbox.stub(),
$on: function(event, callback) {
if (event === events.SESSION_CHANGED) {
if (event === events.GROUPS_CHANGED) {
this.eventCallbacks.push(callback);
}
}
View
@@ -158,27 +158,53 @@ describe('h:session', function () {
});
describe('#update()', function () {
it('broadcasts an event when the session is updated', function () {
it('broadcasts SESSION_CHANGED when the session changes', function () {
var sessionChangeCallback = sinon.stub();
// the initial load should not trigger a SESSION_CHANGED event
// the initial load should trigger a SESSION_CHANGED event
// with initialLoad set
$rootScope.$on(events.SESSION_CHANGED, sessionChangeCallback);
session.update({
groups: [{
id: 'groupid'
}],
csrf: 'dummytoken',
});
assert.calledWith(sessionChangeCallback, sinon.match({}),
{initialLoad: true});
// subsequent loads should trigger a SESSION_CHANGED event
assert.isFalse(sessionChangeCallback.called);
sessionChangeCallback.reset();
session.update({
groups: [{
id: 'groupid2'
}],
csrf: 'dummytoken'
});
assert.calledOnce(sessionChangeCallback);
assert.calledWith(sessionChangeCallback, sinon.match({}),
{initialLoad: false});
});
it('broadcasts GROUPS_CHANGED when the groups change', function () {
var groupChangeCallback = sinon.stub();
$rootScope.$on(events.GROUPS_CHANGED, groupChangeCallback);
session.update({
groups: [{
id: 'groupid'
}],
csrf: 'dummytoken'
});
assert.calledOnce(groupChangeCallback);
});
it('broadcasts USER_CHANGED when the user changes', function () {
var userChangeCallback = sinon.stub();
$rootScope.$on(events.USER_CHANGED, userChangeCallback);
session.update({
userid: 'fred',
csrf: 'dummytoken'
});
assert.calledOnce(userChangeCallback);
});
});
});
View
@@ -42,7 +42,7 @@ describe('store', function () {
update: {},
},
search: {
url: 'http://0.0.0.0:5000/api/search',
url: 'http://example.com/api/search',
},
},
});
@@ -83,4 +83,13 @@ describe('store', function () {
.respond(function () { return {id: 'test'}; });
$httpBackend.flush();
});
// Our backend service interprets semicolons as query param delimiters, so we
// must ensure to encode them in the query string.
it('encodes semicolons in query parameters', function () {
store.SearchResource.get({'uri': 'http://example.com/?foo=bar;baz=qux'});
$httpBackend.expectGET('http://example.com/api/search?uri=http%3A%2F%2Fexample.com%2F%3Ffoo%3Dbar%3Bbaz%3Dqux')
.respond(function () { return [200, {}, {}]; });
$httpBackend.flush();
});
});
View
@@ -1,7 +1,21 @@
@import "mixins/icons";
//ANNOTATION////////////////////////////////
//This is for everything that is formatted as an annotation.
//ANNOTATION CARD////////////////////////////////
// the padding at the left-edge of the annotation card
// and its container
$annotation-card-left-padding: 10px;
.annotation-card {
background-color: white;
border-radius: 2px;
border: 1px solid $color-mercury;
padding-left: 12px;
padding-right: 12px;
padding-top: 12px;
padding-bottom: 15px;
}
.annotation {
font-family: $sans-font-family;
font-weight: 300;
@@ -18,26 +32,53 @@
}
.annotation-timestamp {
float: right;
font-size: .8em;
line-height: 1;
margin-top: (1 / (1 - .8)) * .1em; // scale up .1em offset to align baseline
color: $text-color;
font-size: $body1-font-size;
color: $color-gray;
&:hover { color: $link-color-hover; }
&:focus { outline: 0; }
}
.annotation-section,
.annotation-quote-list,
.annotation-header,
.annotation-footer {
@include pie-clearfix;
margin: .8em 0;
}
.annotation-header { margin-top: 0 }
.annotation-footer { margin-bottom: 0 }
.annotation-header {
display: flex;
flex-direction: row;
align-items: flex-start;
margin-bottom: 5px;
}
.annotation-header__share-info {
color: $color-gray;
}
.annotation-header__group {
color: $color-gray;
font-size: $body1-font-size;
}
.annotation-header__group-name {
display: inline-block;
margin-left: 5px;
}
// the footer at the bottom of an annotation displaying
// the annotation actions and reply counts
.annotation-footer {
margin-bottom: 0;
}
.u-flex-spacer {
flex-grow: 1;
}
.annotation-quote-list {
margin-top: 14px;
margin-bottom: 14px;
.annotation-section {
.excerpt { max-height: 4.8em; }
.excerpt-control a {
font-style: italic;
@@ -61,7 +102,7 @@
.annotation-user {
color: $text-color;
font-weight: bold;
font-size: 1.1em;
font-size: $body1-font-size;
&:hover {
color: $link-color-hover;
cursor: pointer;
@@ -101,7 +142,7 @@
.annotation-citation-domain {
color: $gray-light;
font-size: .923em;
font-size: $body1-font-size;
}
.annotation-license {
View
@@ -24,6 +24,8 @@
border: $border;
color: $color-dove-gray;
padding-bottom: 45px;
padding-left: 5px;
padding-right: 5px;
margin-top: 75px;
&__form {
View
@@ -3,9 +3,10 @@
$base-font-size: 16px;
$base-line-height: 22px;
@import 'reset';
@import 'common';
@import "./mixins/icons";
@import './reset';
@import './common';
@import './elements';
@import './mixins/icons';
body {
background: #fff;
View
@@ -1,4 +1,4 @@
$thread-padding: 1em;
$thread-padding: $annotation-card-left-padding;
.stream-list {
& > * {
@@ -24,14 +24,16 @@ $thread-padding: 1em;
position: relative;
& > ul {
padding-left: $thread-padding + .15em;
padding-left: $thread-padding + 3px;
margin-left: -$thread-padding;
}
// nested threads for annotation replies
.thread {
border-left: 1px dotted $gray-light;
padding: 0;
padding-left: $thread-padding;
margin-top: 10px;
}
.threadexp {
@@ -51,11 +53,6 @@ $thread-padding: 1em;
}
}
.thread-message,
.thread-deleted {
margin: .5em 0;
}
.thread-load-more {
clear: both;
}
View
@@ -18,6 +18,7 @@ $color-gray: #818181;
$color-silver-chalice: #a6a6a6;
$color-silver: #bbb;
$color-alto: #dedede;
$color-mercury: #e2e2e2;
$color-seashell: #f1f1f1;
$color-wild-sand: #f5f5f5;
View
@@ -5,37 +5,39 @@
<div ng-if="vm.annotation.user">
<header class="annotation-header">
<!-- Deletion notice -->
<span ng-if="!vm.editing && vm.annotation.deleted">Annotation deleted.</span>
<div ng-if="!vm.editing && vm.annotation.deleted">Annotation deleted.</div>
<!-- User -->
<span ng-if="vm.annotation.user">
<a class="annotation-user"
target="_blank"
ng-href="{{vm.baseURI}}u/{{vm.annotation.user}}"
>{{vm.annotation.user | persona}}</a>
<span class="small" ng-if="vm.group() && vm.group().url">
to
<a target="_blank" href="{{vm.group().url}}">
<i class="h-icon-group" title="{{vm.group.name}}"></i>
{{vm.group().name}}
</a>
</span>
<i class="h-icon-border-color" ng-show="vm.isHighlight() && !vm.editing" title="This is a highlight. Click 'edit' to add a note or tag."></i>
<span ng-show="vm.isPrivate()"
title="This annotation is visible only to you.">
<i class="h-icon-lock"></i> Only Me
</span>
<span class="annotation-citation"
ng-bind-html="vm.document | documentTitle"
ng-if="!vm.isSidebar">
<span>
<a class="annotation-user"
target="_blank"
ng-href="{{vm.baseURI}}u/{{vm.annotation.user}}"
>{{vm.annotation.user | persona}}</a>
</span>
<span class="annotation-citation-domain"
ng-bind-html="vm.document | documentDomain"
ng-if="!vm.isSidebar">
<br>
<span class="annotation-header__share-info">
<a class="annotation-header__group"
target="_blank" ng-if="vm.group() && vm.group().url" href="{{vm.group().url}}">
<i class="h-icon-group"></i><span class="annotation-header__group-name">{{vm.group().name}}</span>
</a>
<span ng-show="vm.isPrivate()"
title="This annotation is visible only to you.">
<i class="h-icon-lock"></i><span class="annotation-header__group-name" ng-show="!vm.group().url">Only me</span>
</span>
<i class="h-icon-border-color" ng-show="vm.isHighlight() && !vm.editing" title="This is a highlight. Click 'edit' to add a note or tag."></i>
<span class="annotation-citation"
ng-bind-html="vm.document | documentTitle"
ng-if="!vm.isSidebar">
</span>
<span class="annotation-citation-domain"
ng-bind-html="vm.document | documentDomain"
ng-if="!vm.isSidebar">
</span>
</span>
</span>
<span class="u-flex-spacer"></span>
<span class="annotation-collapsed-replies">
<a class="reply-count small" href=""
@@ -54,8 +56,9 @@
</header>
<!-- Excerpts -->
<section class="annotation-section"
ng-repeat="target in vm.annotation.target track by $index">
<section class="annotation-quote-list"
ng-repeat="target in vm.annotation.target track by $index"
ng-if="vm.hasQuotes()">
<excerpt enabled="feature('truncate_annotations')">
<blockquote class="annotation-quote"
ng-bind-html="selector.exact"
View
@@ -26,7 +26,7 @@
<li class="dropdown-menu__row dropdown-menu__row--unpadded "
ng-repeat="group in groups.all()">
<div ng-class="{'group-item': true, selected: group.id == groups.focused().id}"
ng-click="groups.focus(group.id)">
ng-click="focusGroup(group.id)">
<!-- the group icon !-->
<div class="group-icon-container" ng-switch on="group.public">
<i class="h-icon-public" ng-switch-when="true"></i>
View
@@ -14,6 +14,7 @@
<article class="annotation thread-message {{vm.collapsed && 'collapsed'}}"
name="annotation"
annotation="vm.container.message"
is-last-reply="$last"
is-sidebar="{{isSidebar}}"
annotation-show-reply-count="{{vm.shouldShowNumReplies()}}"
annotation-reply-count="{{vm.numReplies()}}"
View
@@ -22,7 +22,7 @@
</sort-dropdown>
</li>
<li id="{{vm.id}}"
class="paper thread"
class="annotation-card thread"
ng-class="{'js-hover': hasFocus(child.message)}"
deep-count="count"
thread="child" thread-filter
View
@@ -44,7 +44,6 @@
"node-uuid": "^1.4.3",
"postcss": "^5.0.6",
"raf": "^3.1.0",
"retry": "^0.8.0",
"scroll-into-view": "^1.3.1",
"showdown": "^1.2.1",
"uglify-js": "^2.4.14",