diff --git a/.gitignore b/.gitignore index d3c5be96e..aafb19b2e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .DS_Store -node_modules -doc/_build +.deps/ +doc/_build/ +node_modules/ +pkg/ diff --git a/.jscsrc b/.jscsrc new file mode 100644 index 000000000..a6a3b7d97 --- /dev/null +++ b/.jscsrc @@ -0,0 +1,45 @@ +{ + "es3": true, + "excludeFiles": [ + "node_modules/**", + "lib/**", + "pkg/**", + "src/bootstrap.js" + ], + "requireCurlyBraces": true, + "requireSpaceAfterKeywords": true, + "requireSpaceBeforeBlockStatements": true, + "requireParenthesesAroundIIFE": true, + "requireSpacesInConditionalExpression": true, + "disallowPaddingNewlinesInBlocks": true, + "disallowSpacesInsideObjectBrackets": true, + "disallowSpacesInsideArrayBrackets": true, + "disallowSpacesInsideParentheses": true, + "disallowSpaceAfterObjectKeys": true, + "requireSpaceBeforeObjectValues": true, + "requireCommaBeforeLineBreak": true, + "requireOperatorBeforeLineBreak": true, + "disallowSpaceAfterPrefixUnaryOperators": true, + "disallowSpaceBeforePostfixUnaryOperators": true, + "requireSpaceBeforeBinaryOperators": true, + "requireSpaceAfterBinaryOperators": true, + "disallowImplicitTypeConversion": ["numeric", "boolean", "binary", "string"], + "requireCamelCaseOrUpperCaseIdentifiers": true, + "disallowMultipleLineStrings": true, + "disallowMixedSpacesAndTabs": true, + "disallowTrailingWhitespace": true, + "disallowTrailingComma": true, + "disallowKeywordsOnNewLine": ["else"], + "maximumLineLength": { + "value": 80, + "allowUrlComments": true + }, + "requireDotNotation": true, + "requireSpaceAfterLineComment": true, + "disallowNewlineBeforeBlockStatements": true, + "validateLineBreaks": "LF", + "validateIndentation": 4, + "validateParameterSeparator": ", ", + "safeContextKeyword": ["self"] +} + diff --git a/.jshintignore b/.jshintignore new file mode 100644 index 000000000..69b1244e7 --- /dev/null +++ b/.jshintignore @@ -0,0 +1,2 @@ +node_modules/ +src/bootstrap.js diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 000000000..96f0b28a0 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,13 @@ +{ + "eqeqeq": true, + "es3": true, + "freeze": true, + "latedef": "nofunc", + "maxcomplexity": 10, + "node": true, + "strict": true, + "undef": true, + "unused": true, + "predef": ["-Promise", "JSON"] +} + diff --git a/.travis.yml b/.travis.yml index cc4dba29d..06e879c47 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,36 @@ language: node_js -node_js: - - "0.8" - - "0.10" +node_js: '0.12' +sudo: false +addons: + sauce_connect: true +env: + global: + - secure: au7XtmQDR14Sbp/lvXkRWpW+WDtv1JnWkaxwnzsEAbE/49hPXvPi6LDMrexDPq4uc4ENHwgCJa/SR6yYBZmWSUS6TotxrRpSfi79V8AvB31lcQrHgtssOLcTJvOrSQPjWK+a9O9bdb/zS8s/5wnE9Y06A59vSHd482BR7FX9nCs= + - secure: nJzTL/VeaVaqWg+qOrDOVzOq/zr0KcVli2AmL6JYpNfSLDMk5ZFR8Qs2ZUXkerJY36KEzrZnYQ9SDMkhYtTUf6SpH3MoMDcs79wOMVBWKHdYX5jf1aIuE+75pNU15HK0vwtT1wV5uZurrkx46K/EKobjspIOy0Qs/jAQ8AVKuoE= + - COMMAND='npm run test' + matrix: + - BROWSER=PhantomJS PULL_REQUEST_SAFE=true + - BROWSER=SL_Chrome + - BROWSER=SL_Firefox + - BROWSER=SL_Safari + - BROWSER=SL_IE_8 + - BROWSER=SL_IE_9 + - BROWSER=SL_IE_10 + - BROWSER=SL_IE_11 +matrix: + allow_failures: + - env: BROWSER=SL_IE_8 + include: + - env: COMMAND='npm run lint' PULL_REQUEST_SAFE=true +script: +- '[ "${TRAVIS_PULL_REQUEST}" != "false" -a "${PULL_REQUEST_SAFE}" != "true" ] && true || ${COMMAND}' +cache: + directories: + - node_modules +notifications: + email: false + irc: + channels: + - chat.freenode.net#annotator + on_success: change + on_failure: change diff --git a/AUTHORS b/AUTHORS index c179f264b..8bb0baa15 100644 --- a/AUTHORS +++ b/AUTHORS @@ -19,3 +19,5 @@ Nick Stenning Ed Summers Deepak Thukral Kevin Tran +Benjamin Young +Fernano Aramendi diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 000000000..f8e6b248e --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,55 @@ +2.0.0-alpha.3 +============= + +Features +-------- + +- The ``authz``, ``identity``, and ``notification`` modules are now + exposed as public API on the ``annotator`` page global. + +- The ``notifier``, ``identityPolicy`` and ``authorizationPolicy`` are now + retrieved from component registry. It should now be possible to register + alternative implementations. + +- Performance of the highlighter should be slightly improved. + +- Showing the viewer with a mouse hover should be much faster when there are + many overlapping highlights. (#520) + +- The ``getGlobal()`` function of the ``util`` module has been removed and + Annotator should now work with Content Security Policy rules that prevent + ``eval`` of code. + +- The ``markdown`` extension has been upgraded to require and support version + 1.0 or greater of the Showdown library. + +Bug Fixes +--------- + +- Fix a bug in the ``ui.filter`` extension so that the ``filters`` option + now works as specified. + +- Make the highlighter work even when the global ``document`` symbol is not + ``window.document``. + +- Fix an issue with the editor where adding custom fields could result in + fields appearing more than once. (#533) + +- With the ``autoViewHighlights`` options of the ``viewer``, don't show the + viewer while the primary mouse button is pressed. Before, this prevention + applied to every button except the primary button, which was not the intended + behavior. + +Documentation +------------- + +- Fix some broken links. + +- Fix some example syntax. + +- Add example markup in the documentation for the ``document`` extension. + +2.0.0-alpha.2 (2015-04-24) +========================== + +- Started changelog. diff --git a/HACKING.markdown b/HACKING.markdown deleted file mode 100644 index 6dcaafcda..000000000 --- a/HACKING.markdown +++ /dev/null @@ -1,57 +0,0 @@ -Hacking on Annotator -==================== - -Quick setup for lazy people (on a Mac) --------------------------------------- - - $ ./tools/setup - -Slower introduction for industrious people (and those on Linux/Windows) ------------------------------------------------------------------------ - -If you wish to develop Annotator, you'll need to have a working installation of -[Node.js][node] (v0.8.x). Once installed (on most systems Node comes bundled -with [NPM][npm]) you should run the following to install Annotator's development -dependencies. - - $ npm install . - -The Annotator source is found in `src/`, and is written in CoffeeScript, which -is a little language that compiles to Javascript. See the [CoffeeScript -website][coffee] for more information. - -`dev.html` loads the raw development files from `lib/` and can be useful when -developing. - -The tests can be found in `test/spec/`. You can run the tests in your browser -(using `test/runner.html`), but while you're working it's probably easiest to -run the tests using `npm test` from the root of the repository. This will -require [PhantomJS][phantom] and the mocha runner: - - $ npm install -g phantomjs mocha-phantomjs - -For inline documentation we use [TomDoc][tom]. It's a Ruby specification but it -also works nicely with CoffeeScript. - -Tools ------ - -There are a number of useful development tools shipped in the `tools/` directory. - - $ ./tools/build # compiles src/*.coffee and test/*.coffee into lib/*.js - $ ./tools/watch # like the above, but automatically recompiles files when they change - $ ./tools/test # runs the test suite with PhantomJS - -Building the packaged version of Annotator involves running the appropriate -`make` task. For example: - - $ make # build everything - $ make bookmarklet # build the bookmarklet - $ make annotator plugins # build annotator and individual plugin files. - -[coffee]: http://coffeescript.org/ -[homebrew]: http://mxcl.github.com/homebrew/ -[node]: http://nodejs.org/ -[npm]: http://npmjs.org/ -[phantom]: http://www.phantomjs.org/ -[tom]: http://tomdoc.org/ diff --git a/HACKING.rst b/HACKING.rst new file mode 100644 index 000000000..d74e79417 --- /dev/null +++ b/HACKING.rst @@ -0,0 +1,34 @@ +Hacking on Annotator +==================== + +If you wish to develop Annotator, you'll need to have a working installation of +`Node.js `__ (>= v0.10.x). Once installed (on most systems +Node comes bundled with `NPM `__) you should run the +following to install Annotator's development dependencies:: + + $ npm install . + +The Annotator source is found in ``src/``. You can use the ``tools/serve`` +script while developing to serve bundle the source files. ``dev.html`` can be useful +when developing. + +The tests can be found in ``test/`` and can be run with:: + + $ npm test + + +Build +----- + +Building the packaged version of Annotator involves running the appropriate +``make`` task. To build everything, run:: + + $ make + +To build just the main Annotator bundle, run:: + + $ make pkg/annotator.min.js + +To build a standalone extension module, run:: + + $ make pkg/annotator.document.min.js diff --git a/Makefile b/Makefile index c6540f657..20cb3e868 100644 --- a/Makefile +++ b/Makefile @@ -1,39 +1,26 @@ -vpath %.coffee src:src/plugin +BROWSERIFY := node_modules/.bin/browserify +UGLIFYJS := node_modules/.bin/uglifyjs -ANNOTATOR_SRC := annotator.coffee -ANNOTATOR_PKG := pkg/annotator.js pkg/annotator.css +# Check that the user has run 'npm install' +ifeq ($(shell which $(BROWSERIFY) >/dev/null 2>&1; echo $$?), 1) +$(error The 'browserify' command was not found. Please ensure you have run 'npm install' before running make.) +endif -PLUGIN_SRC := $(wildcard src/plugin/*.coffee) -PLUGIN_SRC := $(patsubst src/plugin/%,%,$(PLUGIN_SRC)) -PLUGIN_PKG := $(patsubst %.coffee,pkg/annotator.%.js,$(PLUGIN_SRC)) +SRC := $(shell find src -type f -name '*.js') -FULL_SRC := $(ANNOTATOR_SRC) $(PLUGIN_SRC) -FULL_PKG := pkg/annotator-full.js pkg/annotator.css +all: annotator +annotator: pkg/annotator.min.js -BOOKMARKLET_PKG := pkg/annotator-bookmarklet.js pkg/annotator.css \ - pkg/bootstrap.js +pkg/%.min.js: pkg/%.js + @echo Writing $@ + @$(UGLIFYJS) --preamble "$$(tools/preamble)" $< >$@ -BUILD := ./tools/build -DEPS := ./tools/build -d - -DEPDIR := .deps -df = $(DEPDIR)/$(*F) - -PKGDIRS := pkg/lib pkg/lib/plugin - -all: annotator plugins annotator-full bookmarklet -default: all - -annotator: $(ANNOTATOR_PKG) -plugins: $(PLUGIN_PKG) -annotator-full: $(FULL_PKG) -bookmarklet: $(BOOKMARKLET_PKG) - -pkg: $(ANNOTATOR_PKG) $(PLUGIN_PKG) $(FULL_PKG) $(BOOKMARKLET_PKG) - cp package.json main.js index.js pkg/ - cp AUTHORS pkg/ - cp LICENSE* pkg/ - cp README* pkg/ +pkg/annotator.js: browser.js + @echo Writing $@ + @mkdir -p pkg/ .deps/ + @$(BROWSERIFY) -s annotator $< >$@ + @$(BROWSERIFY) --list $< | \ + sed 's#^#$@: #' >.deps/annotator.d clean: rm -rf .deps pkg @@ -47,23 +34,12 @@ develop: doc: cd doc && $(MAKE) html -pkg/annotator.css: css/annotator.css - $(BUILD) -c - -pkg/%.js pkg/annotator.%.js: %.coffee - -pkg/%.js pkg/annotator.%.js pkg/annotator-%.js: | $(DEPDIR) $(PKGDIRS) - $(eval $@_CMD := $(patsubst annotator.%.js,-p %.js,$(@F))) - $(eval $@_CMD := $(subst .js,,$($@_CMD))) - $(BUILD) $($@_CMD) - @$(DEPS) $($@_CMD) \ - | sed -n 's/^\(.*\)/pkg\/$(@F): \1/p' \ - | sort | uniq > $(df).d +apidoc: $(patsubst src/%.js,doc/api/%.rst,$(SRC)) -$(DEPDIR) $(PKGDIRS): - @mkdir -p $@ +doc/api/%.rst: src/%.js + @mkdir -p $(@D) + tools/apidoc $< $@ --include $(DEPDIR)/*.d +-include .deps/*.d -.PHONY: all annotator plugins annotator-full bookmarklet clean test develop \ - pkg doc +.PHONY: all annotator clean test develop doc diff --git a/README.markdown b/README.markdown deleted file mode 100644 index 7e9ec76f8..000000000 --- a/README.markdown +++ /dev/null @@ -1,136 +0,0 @@ -Annotator -========= - -[![Build Status](https://secure.travis-ci.org/okfn/annotator.png)](http://travis-ci.org/okfn/annotator) - -Annotator is a web annotation system. Loaded into a webpage, it provides the -user with tools to annotate text (and other elements) in the page. For a simple -demonstration, visit the [demo page][dp] or [download a tagged release of -Annotator][dl] and open `demo.html`. - -[dp]: http://okfn.github.com/annotator/demo/ -[dl]: https://github.com/okfn/annotator/downloads - -The Annotator project also has a simple but powerful plugin architecture. While -the core annotator code does the bare minimum, it is easily extended with -plugins that perform such tasks as: - -- serialization: the `Store` plugin saves all your annotations to a REST API - backend (see [Storage wiki page][storage] for more and a link to a reference - implementation) -- authentication and authorization: the `Auth` and `Permissions` plugins allow - you to decouple the storage of your annotations from the website on which the - annotation happens. In practice, this means that users could edit pages across - the web, with all their annotations being saved to one server. -- prettification: the `Markdown` plugin renders all annotation text as - [Markdown][md] -- tagging: the `Tags` plugin allows you to tag individual annotations - -[md]: http://daringfireball.net/projects/markdown/ -[storage]: https://github.com/okfn/annotator/wiki/Storage - -Usage ------ - -To use Annotator, it's easiest to [download a packaged release][dl]. The most -important files in these packages are `annotator.min.js` (or -`annotator-full.min.js`), which contains the core Annotator code, and -`annotator.min.css`, which contains all the CSS and embedded images for the -annotator. - -Annotator requires [jQuery][$]. As of Annotator v1.2.7, jQuery v1.9 is assumed. -If you require an older version of jQuery, or are using an older version of -Annotator and require the new jQuery, you can use the [jQuery Migrate Plugin][$m]. -The quickest way to get going with Annotator is to include the following in the -`` of your document (paths relative to the root of the unzipped download): - - - - - -Or, with migrate: - - - - - - -[$]: http://jquery.com/ -[$m]: http://plugins.jquery.com/migrate/ - -You can then initialize Annotator for the whole document by including the -following at the end of the `` tag: - - - -See `demo.html` for an example how to load and interact with plugins. - -Writing Plugins ---------------- - -As mentioned, Annotator has a simple but powerful plugin architecture. In order -to write your own plugin, you need only add your plugin to the Annotator.Plugin -object, ensuring that the first argument to the constructor is a DOM Element, -and the second is an "options" object. Below is a simple Hello World plugin: - - Annotator.Plugin.HelloWorld = (function() { - - function HelloWorld(element, options) { - this.element = element; - this.options = options; - console.log("Hello World!"); - } - - HelloWorld.prototype.pluginInit = function() { - console.log("Initialized with annotator: ", this.annotator); - }; - - return HelloWorld; - })(); - -Other than the constructor, the only "special" method is `pluginInit`, which is -called after the Annotator has constructed the plugin, and set -`pluginInstance.annotator` to itself. In order to load this plugin into an -existing annotator, you would call `addPlugin("HelloWorld")`. For example: - - $(document.body).annotator() - .annotator('addPlugin', 'HelloWorld') - -Look at the existing plugins to get a feel for how they work. The Markdown -plugin is a good place to start. - -Useful events are triggered on the Annotator `element` (passed to the -constructor of the plugin): - -Callback name | Description ----------------------------------------------- | ----------- -`annotationsLoaded(annotations)` | called when annotations are loaded into the DOM. Provides an array of all annotations. -`beforeAnnotationCreated(annotation)` | called immediately before an annotation is created. If you need to modify the annotation before it is saved to the server by the Store plugin, use this event. -`annotationCreated(annotation)` | called when the annotation is created. Used by the Store plugin to save new annotations. -`beforeAnnotationUpdated(annotation)` | as above, but just before an existing annotation is saved. -`annotationUpdated(annotation)` | as above, but for an existing annotation which has just been edited. -`annotationDeleted(annotation)` | called when the user deletes an annotation. -`annotationEditorShown(editor, annotation)` | called when the annotation editor is presented to the user. Allows a plugin to add extra form fields. See the Tags plugin for an example of its use. -`annotationEditorHidden(editor)` | called when the annotation editor is hidden, both when submitted and when editing is cancelled. -`annotationEditorSubmit(editor, annotation)` | called when the annotation editor is submitted. -`annotationViewerShown(viewer, annotations)` | called when the annotation viewer is displayed provides the annotations being displayed -`annotationViewerTextField(field, annotation)` | called when the text field displaying the annotation in the viewer is created - -Development ------------ - -See [HACKING.markdown](./HACKING.markdown) - -Community ---------- - -The Annotator project has a [mailing list][dev] for developer discussion and -community members can sometimes be found in the `#annotator` channel on -[Freenode IRC][irc]. - -[dev]: http://lists.okfn.org/mailman/listinfo/annotator-dev -[irc]: http://freenode.net/ - - diff --git a/README.rst b/README.rst new file mode 100644 index 000000000..3ea08e21c --- /dev/null +++ b/README.rst @@ -0,0 +1,94 @@ +Annotator +========= + +|Build Status| |Version on NPM| |IRC Channel| + +|Build Matrix| + +Annotator is a JavaScript library for building annotation applications in +browsers. It provides a set of interoperable tools for annotating content in +webpages. For a simple demonstration, visit the `Annotator home page`_ or +download a tagged release of Annotator from `the releases page`_ and open +``demo.html``. + +.. _Annotator home page: http://annotatorjs.org/ +.. _the releases page: https://github.com/openannotation/annotator/releases + +Components within Annotator provide: + +- user interface: components to create, edit, and display annotations in a + browser. +- persistence: storage components help you save your annotations to a remote + server. +- authorization and identity: integrate Annotator with your application's login + and permissions systems. + +.. _Dublin Core tags: http://dublincore.org/ +.. _Facebook Open Graph: https://developers.facebook.com/docs/opengraph + + +Usage +----- + +See Installing_ and `Configuring and using Annotator`_ from the documentation_. + +.. _Installing: http://docs.annotatorjs.org/en/latest/installing.html +.. _Configuring and using Annotator: http://docs.annotatorjs.org/en/latest/usage.html +.. _documentation: http://docs.annotatorjs.org/en/latest/ + + +Writing a module +---------------- + +See `Module development`_. + +.. _Module development: http://docs.annotatorjs.org/en/latest/module-development.html + + +Development +----------- + +See `HACKING.rst <./HACKING.rst>`__. + + +Reporting a bug +--------------- + +Please report bugs using the `GitHub issue tracker`_. Please be sure to use the +search facility to see if anyone else has reported the same bug -- don't submit +duplicates. + +Please endeavour to follow `good practice for reporting bugs`_ when you submit +an issue. + +Lastly, if you need support or have a question about Annotator, please **do not +use the issue tracker**. Instead, you are encouraged to email the `mailing +list`_. + +.. _GitHub issue tracker: https://github.com/openannotation/annotator/issues +.. _good practice for reporting bugs: http://www.chiark.greenend.org.uk/~sgtatham/bugs.html + + +Community +--------- + +The Annotator project has a `mailing list`_, ``annotator-dev``, which you're +encouraged to use for any questions and discussions. It is archived for easy +browsing and search at `gmane.comp.web.annotator`_. We can also be found in +|IRC|_. + +.. _mailing list: https://lists.okfn.org/mailman/listinfo/annotator-dev +.. _gmane.comp.web.annotator: http://dir.gmane.org/gmane.comp.web.annotator +.. |IRC| replace:: the ``#annotator`` channel on Freenode +.. _IRC: https://webchat.freenode.net/?channels=#annotator + + +.. |Build Status| image:: https://secure.travis-ci.org/openannotation/annotator.svg?branch=master + :target: http://travis-ci.org/openannotation/annotator +.. |Version on NPM| image:: http://img.shields.io/npm/v/annotator.svg + :target: https://www.npmjs.org/package/annotator +.. |Build Matrix| image:: https://saucelabs.com/browser-matrix/hypothesisannotator.svg + :target: https://saucelabs.com/u/hypothesisannotator +.. |IRC Channel| image:: https://img.shields.io/badge/IRC-%23annotator-blue.svg + :target: https://www.irccloud.com/invite?channel=%23annotator&hostname=irc.freenode.net&port=6697&ssl=1 + :alt: #hypothes.is IRC channel diff --git a/browser.js b/browser.js new file mode 100644 index 000000000..3c51427d8 --- /dev/null +++ b/browser.js @@ -0,0 +1,42 @@ +"use strict"; + +// Inject Annotator CSS +var insertCss = require('insert-css'); +var css = require('./css/annotator.css'); +insertCss(css); + +var app = require('./src/app'); +var util = require('./src/util'); + +// Core annotator components +exports.App = app.App; + +// Access to libraries (for browser installations) +exports.authz = require('./src/authz'); +exports.identity = require('./src/identity'); +exports.notification = require('./src/notification'); +exports.storage = require('./src/storage'); +exports.ui = require('./src/ui'); +exports.util = util; + +// Ext namespace (for core-provided extension modules) +exports.ext = {}; + +// If wicked-good-xpath is available, install it. This will not overwrite any +// native XPath functionality. +var wgxpath = global.wgxpath; +if (typeof wgxpath !== "undefined" && + wgxpath !== null && + typeof wgxpath.install === "function") { + wgxpath.install(); +} + +// Store a reference to the current annotator object, if one exists. +var _annotator = global.annotator; + +// Restores the Annotator property on the global object to it's +// previous value and returns the Annotator. +exports.noConflict = function noConflict() { + global.annotator = _annotator; + return this; +}; diff --git a/src/bootstrap.js b/contrib/bookmarklet/bookmarklet.js similarity index 80% rename from src/bootstrap.js rename to contrib/bookmarklet/bookmarklet.js index d0c3956b2..fd2d904e2 100644 --- a/src/bootstrap.js +++ b/contrib/bookmarklet/bookmarklet.js @@ -25,8 +25,6 @@ repository (see _config.example.json_). The options are as follows: ### externals - - `jQuery`: A URL to a hosted version of jQuery. This will default to the latest - minor version of 1.7 hosted on Google's CDN. - `source`: The generated Annotator Javascript source code (see Development) - `styles`: The generated Annotator CSS source code (see Development) @@ -55,14 +53,17 @@ If this is set to `true` the [Tags plugin][#wiki-tags] will be loaded. var body = document.body, head = document.getElementsByTagName('head')[0], - globals = ['Annotator', '$', 'jQuery'], + globals = ['Annotator'], isLoaded = {}, bookmarklet = {}, - notification, namespace, jQuery; + notification, namespace; while (globals.length) { namespace = globals.shift(); - isLoaded[namespace] = window.hasOwnProperty(namespace); + // window.hasOwnProperty doesn't exist in older IE, so we use + // Object.prototype.hasOwnProperty which does exist. + // https://github.com/openannotation/annotator/issues/420 + isLoaded[namespace] = Object.prototype.hasOwnProperty.call(window, namespace); } notification = (function () { @@ -97,7 +98,6 @@ If this is set to `true` the [Tags plugin][#wiki-tags] will be loaded. // Apply newer styles for modern browsers. element.style.position = 'fixed'; - element.style.backgroundColor = 'rgba(0, 0, 0, 0.9)'; element.onclick = function () { this.parentNode.removeChild(this); }; @@ -189,51 +189,55 @@ If this is set to `true` the [Tags plugin][#wiki-tags] will be loaded. return value; }, - loadjQuery: function () { - var script = document.createElement('script'), - fallback = 'https://ajax.googleapis.com/ajax/libs/jquery/1.9/jquery.js', - timer; - - timer = setTimeout(function () { - notification.error('Sorry, we were unable to load jQuery which is required by the annotator'); - }, this.config('timeout', 30000)); - - script.src = this.config('externals.jQuery', fallback); - script.onload = function () { - // Reassign our local copy of jQuery. - jQuery = window.jQuery; - - clearTimeout(timer); - body.removeChild(script); - bookmarklet.load(function () { - // Once the Annotator has been loaded we can finally remove jQuery. - window.jQuery.noConflict(true); - bookmarklet.setup(); - }); - }; - - body.appendChild(script); + _injectElement: function (where, el) { + if (where == 'head') { + head.appendChild(el); + } else { + body.appendChild(el); + } }, load: function (callback) { var annotatorSource = this.config('externals.source', 'http://assets.annotateit.org/bookmarklet/annotator-bookmarklet.min.js'), annotatorStyles = this.config('externals.styles', 'http://assets.annotateit.org/bookmarklet/annotator.min.css'); - head.appendChild(jQuery('', { - rel: 'stylesheet', - href: annotatorStyles - })[0]); + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = annotatorStyles; + + var script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = annotatorSource; + script._loaded = false; + + var scriptLoaded = function () { + if(script._loaded !== true) { + script._loaded = true; + callback(); + } + }; + + script.onload = scriptLoaded; + script.onreadystatechange = function() { + if ( this.readyState === "loaded" ) { + scriptLoaded(); + } + }; + + setTimeout(function () { + if (!script._loaded) { + notification.error('Sorry, we\'re unable to load Annotator at the moment...'); + } + }, 30000); - jQuery.ajaxSetup({timeout: this.config('timeout', 30000)}); - jQuery.getScript(annotatorSource, callback) - .error(function () { - notification.error('Sorry, we\'re unable to load Annotator at the moment...'); - }); + this._injectElement('head', link); + this._injectElement('body', script); }, authOptions: function () { return { - tokenUrl: this.config('auth.tokenUrl', 'http://annotateit.org/api/token') + tokenUrl: this.config('auth.tokenUrl', 'http://annotateit.org/api/token'), + autoFetch: this.config('auth.autoFetch', true) }; }, @@ -251,7 +255,9 @@ If this is set to `true` the [Tags plugin][#wiki-tags] will be loaded. }, setup: function () { - var annotator = new window.Annotator(options.target || body), namespace; + var annotator = new window.Annotator(options.target || body), + jQuery = window.Annotator.Util.$, + namespace; annotator .addPlugin('Unsupported') @@ -276,7 +282,11 @@ If this is set to `true` the [Tags plugin][#wiki-tags] will be loaded. // were not there before. for (namespace in isLoaded) { if (isLoaded.hasOwnProperty(namespace) && !isLoaded[namespace]) { - delete window[namespace]; + try { + delete window[namespace]; + } catch(e) { + window[namespace] = undefined; + } } } @@ -294,7 +304,9 @@ If this is set to `true` the [Tags plugin][#wiki-tags] will be loaded. ); } else { notification.show('Loading Annotator into page'); - this.loadjQuery(); + bookmarklet.load(function () { + bookmarklet.setup(); + }); } } }; @@ -309,10 +321,5 @@ If this is set to `true` the [Tags plugin][#wiki-tags] will be loaded. // Load the bookmarklet. if (!options.test) { bookmarklet.init(); - } else { - // jQuery is included here for testing individual methods. It is overridden - // with a local copy later in the script. When checking for an external copy - // always check window.jQuery. - jQuery = window.jQuery; } }(window._annotatorConfig, window, window.document)); diff --git a/contrib/bookmarklet/bookmarklet_spec.js b/contrib/bookmarklet/bookmarklet_spec.js new file mode 100644 index 000000000..77955cca8 --- /dev/null +++ b/contrib/bookmarklet/bookmarklet_spec.js @@ -0,0 +1,241 @@ +var assert = require('assertive-chai').assert; + +var Annotator = require('annotator'), + $ = require('../../src/util').$; + +require('../../src/bootstrap'); + +window._annotatorConfig = { + test: true, + target: "#fixtures", + externals: { + source: "../notexist/annotator.js", + styles: "../notexist/annotator.css" + }, + auth: { + autoFetch: false + }, + tags: true, + store: { + prefix: "" + }, + annotateItPermissions: { + showViewPermissionsCheckbox: true, + showEditPermissionsCheckbox: true, + user: { + id: "Aron", + name: "Aron" + }, + permissions: { + read: ["Aron"], + update: ["Aron"], + "delete": ["Aron"], + admin: ["Aron"] + } + } +}; + + +describe("bookmarklet", function () { + var bookmarklet = null; + + beforeEach(function () { + window.Annotator = Annotator; + bookmarklet = window._annotator.bookmarklet; + + // Prevent Notifications from being fired + sinon.stub(bookmarklet.notification, "show"); + sinon.stub(bookmarklet.notification, "message"); + sinon.stub(bookmarklet.notification, "hide"); + sinon.stub(bookmarklet.notification, "remove"); + + sinon.spy(bookmarklet, "config"); + + sinon.stub(bookmarklet, "_injectElement", function (where, el) { + if (typeof el.onload == "function") { + el.onload.call(); + } + }); + }); + + afterEach(function () { + var error; + try { + delete window.Annotator; + } catch (_error) { + error = _error; + window.Annotator = void 0; + } + bookmarklet.notification.show.restore(); + bookmarklet.notification.message.restore(); + bookmarklet.notification.hide.restore(); + bookmarklet.notification.remove.restore(); + bookmarklet.config.restore(); + bookmarklet._injectElement.restore(); + $(".annotator-bm-status, .annotator-notice").remove(); + }); + + describe("init()", function () { + beforeEach(function () { + sinon.spy(bookmarklet, "load"); + }); + + afterEach(function () { + bookmarklet.load.restore(); + }); + + it("should display a notification telling the user the page is loading", function () { + bookmarklet.init(); + assert(bookmarklet.notification.show.called); + }); + + it("should display a notification if the bookmarklet has loaded", function () { + window._annotator.instance = {}; + window._annotator.Annotator = { + showNotification: sinon.spy() + }; + bookmarklet.init(); + assert(window._annotator.Annotator.showNotification.called); + }); + }); + + describe("load()", function () { + it("should append the stylesheet to the head", function (done) { + bookmarklet.load(function () { + assert(bookmarklet._injectElement.calledWith('head')); + done(); + }); + }); + + it("should append the script to the body", function (done) { + bookmarklet.load(function () { + assert(bookmarklet._injectElement.calledWith('body')); + done(); + }); + }); + }); + + describe("setup()", function () { + function hasPlugin(instance, name) { + return name in instance.plugins; + } + + beforeEach(function () { + bookmarklet.setup(); + }); + + it("should export useful values to window._annotator", function () { + assert.isFunction(window._annotator.Annotator); + assert.isObject(window._annotator.instance); + assert.isFunction(window._annotator.jQuery); + assert.isObject(window._annotator.element); + }); + + it("should add the plugins to the annotator instance", function () { + var instance = window._annotator.instance; + assert(hasPlugin(instance, 'Auth')); + assert(hasPlugin(instance, 'Store')); + assert(hasPlugin(instance, 'AnnotateItPermissions')); + assert(hasPlugin(instance, 'Unsupported')); + }); + + it("should add the tags plugin if options.tags is true", function () { + var instance = window._annotator.instance; + assert(hasPlugin(instance, 'Tags')); + }); + + it("should display a loaded notification", function () { + assert(bookmarklet.notification.message.called); + }); + }); + + describe("annotateItPermissionsOptions()", function () { + it("should return an object literal", function () { + assert.isObject(bookmarklet.annotateItPermissionsOptions()); + }); + + it("should retrieve user and permissions from config", function () { + bookmarklet.annotateItPermissionsOptions(); + assert(bookmarklet.config.calledWith("annotateItPermissions")); + }); + }); + + describe("storeOptions()", function () { + it("should return an object literal", function () { + assert.isObject(bookmarklet.storeOptions()); + }); + + it("should retrieve store prefix from config", function () { + bookmarklet.storeOptions(); + assert(bookmarklet.config.calledWith("store.prefix")); + }); + + it("should have set a uri property", function () { + var uri = bookmarklet.storeOptions().annotationData.uri; + assert(uri); + }); + }); +}); + +describe("bookmarklet.notification", function () { + var bookmarklet, notification; + bookmarklet = null; + notification = void 0; + beforeEach(function () { + bookmarklet = window._annotator.bookmarklet; + notification = bookmarklet.notification; + sinon.spy(bookmarklet.notification, "show"); + sinon.spy(bookmarklet.notification, "message"); + sinon.spy(bookmarklet.notification, "hide"); + return sinon.spy(bookmarklet.notification, "remove"); + }); + afterEach(function () { + bookmarklet.notification.show.restore(); + bookmarklet.notification.message.restore(); + bookmarklet.notification.hide.restore(); + return bookmarklet.notification.remove.restore(); + }); + it("should have an Element property", function () { + return assert.isObject(notification.element); + }); + describe("show", function () { + it("should set the top style of the element", function () { + notification.show(); + return assert.equal(parseInt(notification.element.style.top, 10), "0"); + }); + return it("should call notification.message", function () { + notification.show("hello", "red"); + return assert(notification.message.calledWith("hello", "red")); + }); + }); + describe("hide", function () { + return it("should set the top style of the element", function () { + notification.hide(); + return assert.notEqual(notification.element.style.top, "0px"); + }); + }); + describe("message", function () { + it("should set the innerHTML of the element", function () { + notification.message("hello"); + return assert.equal(notification.element.innerHTML, "hello"); + }); + return it("should set the bottomBorderColor of the element", function () { + var current; + current = notification.element.style.borderBottomColor; + notification.message("hello", "#fff"); + return assert.notEqual(notification.element.style.borderBottomColor, current); + }); + }); + describe("append", function () { + return it("should append the element to the document.body", function () { + notification.append(); + return assert.equal(notification.element.parentNode, document.body); + }); + }); + return describe("remove", function () { + return it("should remove the element from the document.body", function () { + notification.remove(); + return assert.isNull(notification.element.parentElement); + }); + }); +}); diff --git a/css/annotator.css b/css/annotator.css index 5b7d03e5b..522974681 100644 --- a/css/annotator.css +++ b/css/annotator.css @@ -25,18 +25,18 @@ -------------------------------------------------------------------- */ .annotator-adder { - background-image: url(http://github.com/img/annotator-icon-sprite.png); + background-image: url(http://github.com/img/annotator-icon-sprite.png?embed); background-repeat: no-repeat; } .annotator-resize, -.annotator-widget::after, -.annotator-editor a::after, +.annotator-widget:after, +.annotator-editor a:after, .annotator-viewer .annotator-controls button, .annotator-viewer .annotator-controls a, -.annotator-filter .annotator-filter-navigation button::after, +.annotator-filter .annotator-filter-navigation button:after, .annotator-filter .annotator-filter-property .annotator-filter-clear { - background-image: url(http://github.com/img/annotator-glyph-sprite.png); + background-image: url(http://github.com/img/annotator-glyph-sprite.png?embed); background-repeat: no-repeat; } @@ -44,11 +44,15 @@ -------------------------------------------------------------------- */ .annotator-hl { + background: #FFFF0A; background: rgba(255, 255, 10, 0.3); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#4DFFFF0A, endColorstr=#4DFFFF0A)"; /* 0.3 == 4D in MS filters */ } .annotator-hl-temporary { + background: #007CFF; background: rgba(0, 124, 255, 0.3); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#4D007CFF, endColorstr=#4D007CFF)"; /* 0.3 == 4D in MS filters */ } /* Annotator Wrapper @@ -59,7 +63,7 @@ } /* NB: If you change the list of classes for which z-index is set, - you should update Annotator._setupDynamicStyle() */ + you should update setupDynamicStyle() in annotator.ui.main */ .annotator-adder, .annotator-outer, .annotator-notice { @@ -130,7 +134,9 @@ bottom: 15px; left: -18px; min-width: 265px; + background-color: #FBFBFB; background-color: rgba(251, 251, 251, 0.98); + border: 1px solid #7A7A7A; border: 1px solid rgba(122, 122, 122, 0.6); -webkit-border-radius: 5px; -moz-border-radius: 5px; @@ -162,7 +168,7 @@ list-style: none; } -.annotator-widget::after { +.annotator-widget:after { content: ""; display: block; width: 18px; @@ -173,12 +179,12 @@ left: 8px; } -.annotator-invert-x .annotator-widget::after { +.annotator-invert-x .annotator-widget:after { left: auto; right: 8px; } -.annotator-invert-y .annotator-widget::after { +.annotator-invert-y .annotator-widget:after { background-position: 0 -15px; bottom: auto; top: -9px; @@ -192,6 +198,7 @@ } .annotator-viewer .annotator-item { + border-top: 2px solid #7A7A7A; border-top: 2px solid rgba(122, 122, 122, 0.2); } @@ -201,6 +208,7 @@ .annotator-editor .annotator-item, .annotator-viewer div { + border-top: 1px solid #858585; border-top: 1px solid rgba(133, 133, 133, 0.11); } @@ -493,7 +501,7 @@ border-radius: 5px; } -.annotator-editor a::after { +.annotator-editor a:after { position: absolute; top: 50%; left: 5px; @@ -545,8 +553,8 @@ text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.42); } -.annotator-editor a:hover::after, -.annotator-editor a:focus::after { +.annotator-editor a:hover:after, +.annotator-editor a:focus:after { margin-top: -8px; background-position: 0 -105px; } @@ -585,18 +593,18 @@ ); } -.annotator-editor a.annotator-save::after { +.annotator-editor a.annotator-save:after { background-position: 0 -120px; } -.annotator-editor a.annotator-save:hover::after, -.annotator-editor a.annotator-save:focus::after, -.annotator-editor a.annotator-save.annotator-focus::after { +.annotator-editor a.annotator-save:hover:after, +.annotator-editor a.annotator-save:focus:after, +.annotator-editor a.annotator-save.annotator-focus:after { margin-top: -8px; background-position: 0 -135px; } -.annotator-editor .annotator-widget::after { +.annotator-editor .annotator-widget:after { background-position: 0 -30px; } @@ -604,7 +612,7 @@ background-color: #f2f2f2; } -.annotator-editor.annotator-invert-y .annotator-widget::after { +.annotator-editor.annotator-invert-y .annotator-widget:after { background-position: 0 -45px; height: 11px; } @@ -639,7 +647,6 @@ .annotator-notice { color: #fff; - position: absolute; position: fixed; top: -54px; left: 0; @@ -656,10 +663,6 @@ transition: top 0.4s ease-out; } -.ie6 .annotator-notice { - position: absolute; -} - .annotator-notice-success { border-color: #3665f9; } @@ -680,7 +683,7 @@ top: 0; } -/* Annotator Tags Plugin +/* Annotator Tags -------------------------------------------------------------------- */ .annotator-tags { @@ -700,7 +703,7 @@ border-radius: 8px; } -/* Annotator Filter Plugin +/* Annotator Filter -------------------------------------------------------------------- */ .annotator-filter { @@ -845,7 +848,7 @@ color: transparent; } -.annotator-filter .annotator-filter-navigation button::after { +.annotator-filter .annotator-filter-navigation button:after { position: absolute; top: 8px; left: 8px; @@ -856,7 +859,7 @@ background-position: 0 -210px; } -.annotator-filter .annotator-filter-navigation button:hover::after { +.annotator-filter .annotator-filter-navigation button:hover:after { background-position: 0 -225px; } @@ -868,18 +871,20 @@ border-left: none; } -.annotator-filter .annotator-filter-navigation .annotator-filter-next::after { +.annotator-filter .annotator-filter-navigation .annotator-filter-next:after { left: auto; right: 7px; background-position: 0 -240px; } -.annotator-filter .annotator-filter-navigation .annotator-filter-next:hover::after { +.annotator-filter .annotator-filter-navigation .annotator-filter-next:hover:after { background-position: 0 -255px; } .annotator-hl-active { + background: #FFFF0A; background: rgba(255, 255, 10, 0.8); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#CCFFFF0A, endColorstr=#CCFFFF0A)"; /* 0.8 == CC in MS filters */ } .annotator-hl-filtered { diff --git a/demo.html b/demo.html index c9ecf6332..79acf464c 100644 --- a/demo.html +++ b/demo.html @@ -2,96 +2,127 @@ - JS annotation test - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + Annotator demo +
-

Javascript annotation service test

+

Annotator demonstration

-
-

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

- -

Header Level 2

- -
    -
  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. -
  3. Aliquam tincidunt mauris eu risus.
  4. -
- -

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

- -

Header Level 3

- -
    -
  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • -
  • Aliquam tincidunt mauris eu risus.
  • -
- -

-      #header h1 a {
-        display: block;
-        width: 300px;
-        height: 80px;
-      }
-      
-
+
+

+ He was an old man who fished alone in a skiff in the Gulf Stream and he + had gone eighty-four days now without taking a fish. In the first forty + days a boy had been with him. But after forty days without a fish the + boy's parents had told him that the old man was now definitely and + finally salao, which is the worst form of unlucky, and the boy + had gone at their orders in another boat which caught three good fish + the first week. It made the boy sad to see the old man come in each day + with his skiff empty and he always went down to help him carry either + the coiled lines or the gaff and harpoon and the sail that was furled + around the mast. The sail was patched with flour sacks and, furled, it + looked like the flag of permanent defeat. +

+

+ The old man was thin and gaunt with deep wrinkles in the back of his + neck. The brown blotches of the benevolent skin cancer the sun brings + from its reflection on the tropic sea were on his cheeks. The blotches + ran well down the sides of his face and his hands had the deep-creased + scars from handling heavy fish on the cords. But none of these scars + were fresh. They were as old as erosions in a fishless desert. +

+

+ Everything about him was old except his eyes and they were the same + color as the sea and were cheerful and undefeated. +

+

+ "Santiago," the boy said to him as they climbed the bank from + where the skiff was hauled up. "I could go with you again. We've + made some money." +

+

The old man had taught the boy to fish and the boy loved him.

+

+ "No," the old man said. "You're with a lucky boat. Stay + with them." +

+

+ "But remember how you went eighty-seven days without fish and then + we caught big ones every day for three weeks." +

+

+ "I remember," the old man said. "I know you did not leave + me because you doubted." +

+

+ "It was papa made me leave. I am a boy and I must obey him." +

+

+ "I know," the old man said. "It is quite normal." +

+

"He hasn't much faith."

+

+ "No," the old man said. "But we have. Haven't we?" +

+

+ "Yes," the boy said. "Can I offer you a beer on the + Terrace and then we'll take the stuff home." +

+

+ "Why not?" the old man said. "Between fishermen." +

+

+ They sat on the Terrace and many of the fishermen made fun of the old + man and he was not angry. Others, of the older fishermen, looked at him + and were sad. But they did not show it and they spoke politely about the + current and the depths they had drifted their lines at and the steady + good weather and of what they had seen. The successful fishermen of that + day were already in and had butchered their marlin out and carried them + laid full length across two planks, with two men staggering at the end + of each plank, to the fish house where they waited for the ice truck to + carry them to the market in Havana. Those who had caught sharks had + taken them to the shark factory on the other side of the cove where they + were hoisted on a block and tackle, their livers removed, their fins cut + off and their hides skinned out and their flesh cut into strips for + salting. +

+

+ When the wind was in the east a smell came across the harbour from the + shark factory; but today there was only the faint edge of the odour + because the wind had backed into the north and then dropped off and it + was pleasant and sunny on the Terrace. +

+
+ + - - diff --git a/dev.html b/dev.html index a2f37ba1b..85eef2899 100644 --- a/dev.html +++ b/dev.html @@ -2,29 +2,53 @@ - JS annotation test - - - - - - - - - - - - - - + Annotator development + -
-

Javascript annotation service test

-
- -
+

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

Header Level 2

@@ -36,6 +60,10 @@

Header Level 2

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

+

Here's an interesting one. This is absolutely positioned content. If we can't get the adder positioning right on this one then we're screwed.

+ +

And the same but with position: fixed; Make the window smaller and scroll to make sure that adder/viewer/editor positioning is still correct.

+

Header Level 3

    @@ -82,32 +110,18 @@

    Header Level 3

    -
- - + - devAnnotator = new Annotator(elem, {store: devStore}) - .addPlugin('Auth', { - tokenUrl: 'http://localhost:4000/api/token' - }) - .addPlugin('Unsupported') - .addPlugin('AnnotateItPermissions'); - - devAnnotator.plugins.Auth.withToken(function (tok) { - console.log(tok); - }) - - }(jQuery)); + diff --git a/dev.xhtml b/dev.xhtml deleted file mode 100644 index d5859507c..000000000 --- a/dev.xhtml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - JS annotation test - - - - - - - - - - - - - - - - -
-

Javascript annotation service test

-
- -
-

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.

- -

Header Level 2

- -
    -
  1. Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  2. -
  3. Aliquam tincidunt mauris eu risus.
  4. -
- -

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est.

- -

Header Level 3

- -
    -
  • Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
  • -
  • Aliquam tincidunt mauris eu risus.
  • -
- -

-      #header h1 a {
-        display: block;
-        width: 300px;
-        height: 80px;
-      }
-      
-
- - - - diff --git a/doc/annotation-format.rst b/doc/annotation-format.rst deleted file mode 100644 index df87de066..000000000 --- a/doc/annotation-format.rst +++ /dev/null @@ -1,44 +0,0 @@ -Annotation format -================= - -An annotation is a JSON document that contains a number of fields -describing the position and content of an annotation within a specified -document: - -.. code:: json - - { - "id": "39fc339cf058bd22176771b3e3187329", # unique id (added by backend) - "annotator_schema_version": "v1.0", # schema version: default v1.0 - "created": "2011-05-24T18:52:08.036814", # created datetime in iso8601 format (added by backend) - "updated": "2011-05-26T12:17:05.012544", # updated datetime in iso8601 format (added by backend) - "text": "A note I wrote", # content of annotation - "quote": "the text that was annotated", # the annotated text (added by frontend) - "uri": "http://example.com/", # URI of annotated document (added by frontend) - "ranges": [ # list of ranges covered by annotation (usually only one entry) - { - "start": "/p[69]/span/span", # (relative) XPath to start element - "end": "/p[70]/span/span", # (relative) XPath to end element - "startOffset": 0, # character offset within start element - "endOffset": 120 # character offset within end element - } - ], - "user": "alice", # user id of annotation owner (can also be an object with an 'id' property) - "consumer": "annotateit", # consumer key of backend - "tags": [ "review", "error" ], # list of tags (from Tags plugin) - "permissions": { # annotation permissions (from Permissions/AnnotateItPermissions plugin) - "read": ["group:__world__"], - "admin": [], - "update": [], - "delete": [] - } - } - -Note that this annotation includes some info stored by plugins (notably -the :doc:`plugins/permissions` and :doc:`plugins/tags`). - -This basic schema is **completely extensible**. It can be added to by -plugins, and any fields added by the frontend should be preserved by -backend implementations. For example, the :doc:`plugins/store` (which adds -persistence of annotations) allow you to specify arbitrary additional -fields using the ``annotationData`` attribute. diff --git a/doc/api/app.rst b/doc/api/app.rst new file mode 100644 index 000000000..2d9b09293 --- /dev/null +++ b/doc/api/app.rst @@ -0,0 +1,83 @@ +.. default-domain: js + +annotator package +================= + +.. class:: annotator.App() + + App is the coordination point for all annotation functionality. App instances + manage the configuration of a particular annotation application, and are the + starting point for most deployments of Annotator. + + +.. function:: annotator.App.prototype.include(module[, options]) + + Include an extension module. If an `options` object is supplied, it will be + passed to the module at initialisation. + + If the returned module instance has a `configure` function, this will be + called with the application registry as a parameter. + + :param Object module: + :param Object options: + :returns: Itself. + :rtype: App + + +.. function:: annotator.App.prototype.start() + + Tell the app that configuration is complete. This binds the various + components passed to the registry to their canonical names so they can be + used by the rest of the application. + + Runs the 'start' module hook. + + :returns: A promise, resolved when all module 'start' hooks have completed. + :rtype: Promise + + +.. function:: annotator.App.prototype.destroy() + + Destroy the App. Unbinds all event handlers and runs the 'destroy' module + hook. + + :returns: A promise, resolved when destroyed. + :rtype: Promise + + +.. function:: annotator.App.prototype.runHook(name[, args]) + + Run the named module hook and return a promise of the results of all the hook + functions. You won't usually need to run this yourself unless you are + extending the base functionality of App. + + Optionally accepts an array of argument (`args`) to pass to each hook + function. + + :returns: A promise, resolved when all hooks are complete. + :rtype: Promise + + +.. function:: annotator.App.extend(object) + + Create a new object that inherits from the App class. + + For example, here we create a ``CustomApp`` that will include the + hypothetical ``mymodules.foo.bar`` module depending on the options object + passed into the constructor:: + + var CustomApp = annotator.App.extend({ + constructor: function (options) { + App.apply(this); + if (options.foo === 'bar') { + this.include(mymodules.foo.bar); + } + } + }); + + var app = new CustomApp({foo: 'bar'}); + + :returns: The subclass constructor. + :rtype: Function + + diff --git a/doc/api/authz.rst b/doc/api/authz.rst new file mode 100644 index 000000000..8db5605d7 --- /dev/null +++ b/doc/api/authz.rst @@ -0,0 +1,52 @@ +.. default-domain: js + +annotator.authz package +======================= + +.. function:: annotator.authz.acl() + + A module that configures and registers an instance of + :class:`annotator.identity.AclAuthzPolicy`. + + +.. class:: annotator.authz.AclAuthzPolicy() + + An authorization policy that permits actions based on access control lists. + + +.. function:: annotator.authz.AclAuthzPolicy.prototype.permits(action, \ + context, identity) + + Determines whether the user identified by `identity` is permitted to + perform the specified action in the given context. + + If the context has a "permissions" object property, then actions will + be permitted if either of the following are true: + + a) permissions[action] is undefined or null, + b) permissions[action] is an Array containing the authorized userid + for the given identity. + + If the context has no permissions associated with it then all actions + will be permitted. + + If the annotation has a "user" property, then actions will be permitted + only if `identity` matches this "user" property. + + If the annotation has neither a "permissions" property nor a "user" + property, then all actions will be permitted. + + :param String action: The action to perform. + :param context: The permissions context for the authorization check. + :param identity: The identity whose authorization is being checked. + + :returns Boolean: Whether the action is permitted in this context for this + identity. + + +.. function:: annotator.authz.AclAuthzPolicy.prototype.authorizedUserId( \ + identity) + + Returns the authorized userid for the user identified by `identity`. + + diff --git a/doc/api/identity.rst b/doc/api/identity.rst new file mode 100644 index 000000000..31df88439 --- /dev/null +++ b/doc/api/identity.rst @@ -0,0 +1,33 @@ +.. default-domain: js + +annotator.identity package +========================== + +.. function:: annotator.identity.simple() + + A module that configures and registers an instance of + :class:`annotator.identity.SimpleIdentityPolicy`. + + +.. class:: annotator.identity.SimpleIdentityPolicy + + A simple identity policy that considers the identity to be an opaque + identifier. + + +.. data:: annotator.identity.SimpleIdentityPolicy.identity + + Default identity. Defaults to `null`, which disables identity-related + functionality. + + This is not part of the identity policy public interface, but provides a + simple way for you to set a fixed current user:: + + app.ident.identity = 'bob'; + + +.. function:: annotator.identity.SimpleIdentityPolicy.prototype.who() + + Returns the current user identity. + + diff --git a/doc/api/index.rst b/doc/api/index.rst new file mode 100644 index 000000000..2711f7cc3 --- /dev/null +++ b/doc/api/index.rst @@ -0,0 +1,13 @@ +API documentation +================= + +.. toctree:: + :maxdepth: 2 + + app + registry + storage + authz + identity + notification + ui diff --git a/doc/api/notification.js b/doc/api/notification.js new file mode 100644 index 000000000..c8b220c6a --- /dev/null +++ b/doc/api/notification.js @@ -0,0 +1,19 @@ +.. default-domain: js + +annotator.notifier package +========================== + +.. function:: annotator.notifier.banner(message[, severity=notification.INFO]) + + Creates a user-visible banner notification that can be used to display + information, warnings and errors to the user. + + :param String message: The notice message text. + :param severity: + The severity of the notice (one of `notification.INFO`, + `notification.SUCCESS`, or `notification.ERROR`) + + :returns: + An object with a `close` method which can be used to close the banner. + + diff --git a/doc/api/notification.rst b/doc/api/notification.rst new file mode 100644 index 000000000..adec15d4f --- /dev/null +++ b/doc/api/notification.rst @@ -0,0 +1,19 @@ +.. default-domain: js + +annotator.notifier package +========================== + +.. function:: annotator.notifier.banner(message[, severity=notification.INFO]) + + Creates a user-visible banner notification that can be used to display + information, warnings and errors to the user. + + :param String message: The notice message text. + :param severity: + The severity of the notice (one of `notification.INFO`, + `notification.SUCCESS`, or `notification.ERROR`) + + :returns: + An object with a `close` method that can be used to close the banner. + + diff --git a/doc/api/registry.rst b/doc/api/registry.rst new file mode 100644 index 000000000..26e224122 --- /dev/null +++ b/doc/api/registry.rst @@ -0,0 +1,60 @@ +.. default-domain: js + +annotator.registry package +========================== + +.. class:: annotator.registry.Registry() + + `Registry` is an application registry. It serves as a place to register and + find shared components in a running :class:`annotator.App`. + + You won't usually create your own `Registry` -- one will be created for you + by the :class:`~annotator.App`. If you are writing an Annotator module, you + can use the registry to provide or override a component of the Annotator + application. + + For example, if you are writing a module that overrides the "storage" + component, you will use the registry in your module's `configure` function to + register your component:: + + function myStorage () { + return { + configure: function (registry) { + registry.registerUtility(this, 'storage'); + }, + ... + }; + } + + +.. function:: annotator.registry.Registry.prototype.registerUtility(component, iface) + + Register component `component` as an implementer of interface `iface`. + + :param component: The component to register. + :param string iface: The name of the interface. + + +.. function:: annotator.registry.Registry.prototype.getUtility(iface) + + Get component implementing interface `iface`. + + :param string iface: The name of the interface. + :returns: Component matching `iface`. + :throws LookupError: If no component is found for interface `iface`. + + +.. function:: annotator.registry.Registry.prototype.queryUtility(iface) + + Get component implementing interface `iface`. Returns `null` if no matching + component is found. + + :param string iface: The name of the interface. + :returns: Component matching `iface`, if found; `null` otherwise. + + +.. class:: annotator.registry.LookupError(iface) + + The error thrown when a registry component lookup fails. + + diff --git a/doc/api/storage.rst b/doc/api/storage.rst new file mode 100644 index 000000000..54efc6924 --- /dev/null +++ b/doc/api/storage.rst @@ -0,0 +1,271 @@ +.. default-domain: js + +annotator.storage package +========================= + +.. function:: annotator.storage.debug() + + A storage component that can be used to print details of the annotation + persistence processes to the console when developing other parts of + Annotator. + + Use as an extension module:: + + app.include(annotator.storage.debug); + + +.. function:: annotator.storage.noop() + + A no-op storage component. It swallows all calls and does the bare minimum + needed. Needless to say, it does not provide any real persistence. + + Use as a extension module:: + + app.include(annotator.storage.noop); + + +.. function:: annotator.storage.http([options]) + + A module which configures an instance of + :class:`annotator.storage.HttpStorage` as the storage component. + + :param Object options: + Configuration options. For available options see + :attr:`~annotator.storage.HttpStorage.options`. + + +.. class:: annotator.storage.HttpStorage([options]) + + HttpStorage is a storage component that talks to a remote JSON + HTTP API + that should be relatively easy to implement with any web application + framework. + + :param Object options: See :attr:`~annotator.storage.HttpStorage.options`. + + +.. function:: annotator.storage.HttpStorage.prototype.create(annotation) + + Create an annotation. + + **Examples**:: + + store.create({text: "my new annotation comment"}) + // => Results in an HTTP POST request to the server containing the + // annotation as serialised JSON. + + :param Object annotation: An annotation. + :returns: The request object. + :rtype: Promise + + +.. function:: annotator.storage.HttpStorage.prototype.update(annotation) + + Update an annotation. + + **Examples**:: + + store.update({id: "blah", text: "updated annotation comment"}) + // => Results in an HTTP PUT request to the server containing the + // annotation as serialised JSON. + + :param Object annotation: An annotation. Must contain an `id`. + :returns: The request object. + :rtype: Promise + + +.. function:: annotator.storage.HttpStorage.prototype.delete(annotation) + + Delete an annotation. + + **Examples**:: + + store.delete({id: "blah"}) + // => Results in an HTTP DELETE request to the server. + + :param Object annotation: An annotation. Must contain an `id`. + :returns: The request object. + :rtype: Promise + + +.. function:: annotator.storage.HttpStorage.prototype.query(queryObj) + + Searches for annotations matching the specified query. + + :param Object queryObj: An object describing the query. + :returns: + A promise, resolves to an object containing query `results` and `meta`. + :rtype: Promise + + +.. function:: annotator.storage.HttpStorage.prototype.setHeader(name, value) + + Set a custom HTTP header to be sent with every request. + + **Examples**:: + + store.setHeader('X-My-Custom-Header', 'MyCustomValue') + + :param string name: The header name. + :param string value: The header value. + + +.. attribute:: annotator.storage.HttpStorage.options + + Available configuration options for HttpStorage. See below. + + +.. attribute:: annotator.storage.HttpStorage.options.emulateHTTP + + Should the storage emulate HTTP methods like PUT and DELETE for + interaction with legacy web servers? Setting this to `true` will fake + HTTP `PUT` and `DELETE` requests with an HTTP `POST`, and will set the + request header `X-HTTP-Method-Override` with the name of the desired + method. + + **Default**: ``false`` + + +.. attribute:: annotator.storage.HttpStorage.options.emulateJSON + + Should the storage emulate JSON POST/PUT payloads by sending its requests + as application/x-www-form-urlencoded with a single key, "json" + + **Default**: ``false`` + + +.. attribute:: annotator.storage.HttpStorage.options.headers + + A set of custom headers that will be sent with every request. See also + the setHeader method. + + **Default**: ``{}`` + + +.. attribute:: annotator.storage.HttpStorage.options.onError + + Callback, called if a remote request throws an error. + + +.. attribute:: annotator.storage.HttpStorage.options.prefix + + This is the API endpoint. If the server supports Cross Origin Resource + Sharing (CORS) a full URL can be used here. + + **Default**: ``'/store'`` + + +.. attribute:: annotator.storage.HttpStorage.options.urls + + The server URLs for each available action. These URLs can be anything but + must respond to the appropriate HTTP method. The URLs are Level 1 URI + Templates as defined in RFC6570: + + http://tools.ietf.org/html/rfc6570#section-1.2 + + **Default**:: + + { + create: '/annotations', + update: '/annotations/{id}', + destroy: '/annotations/{id}', + search: '/search' + } + + +.. class:: annotator.storage.StorageAdapter(store, runHook) + + StorageAdapter wraps a concrete implementation of the Storage interface, and + ensures that the appropriate hooks are fired when annotations are created, + updated, deleted, etc. + + :param store: The Store implementation which manages persistence + :param Function runHook: A function which can be used to run lifecycle hooks + + +.. function:: annotator.storage.StorageAdapter.prototype.create(obj) + + Creates and returns a new annotation object. + + Runs the 'beforeAnnotationCreated' hook to allow the new annotation to be + initialized or its creation prevented. + + Runs the 'annotationCreated' hook when the new annotation has been created + by the store. + + **Examples**: + + :: + + registry.on('beforeAnnotationCreated', function (annotation) { + annotation.myProperty = 'This is a custom property'; + }); + registry.create({}); // Resolves to {myProperty: "This is a…"} + + + :param Object annotation: An object from which to create an annotation. + :returns Promise: Resolves to annotation object when stored. + + +.. function:: annotator.storage.StorageAdapter.prototype.update(obj) + + Updates an annotation. + + Runs the 'beforeAnnotationUpdated' hook to allow an annotation to be + modified before being passed to the store, or for an update to be prevented. + + Runs the 'annotationUpdated' hook when the annotation has been updated by + the store. + + **Examples**: + + :: + + annotation = {tags: 'apples oranges pears'}; + registry.on('beforeAnnotationUpdated', function (annotation) { + // validate or modify a property. + annotation.tags = annotation.tags.split(' ') + }); + registry.update(annotation) + // => Resolves to {tags: ["apples", "oranges", "pears"]} + + :param Object annotation: An annotation object to update. + :returns Promise: Resolves to annotation object when stored. + + +.. function:: annotator.storage.StorageAdapter.prototype.delete(obj) + + Deletes the annotation. + + Runs the 'beforeAnnotationDeleted' hook to allow an annotation to be + modified before being passed to the store, or for the a deletion to be + prevented. + + Runs the 'annotationDeleted' hook when the annotation has been deleted by + the store. + + :param Object annotation: An annotation object to delete. + :returns Promise: Resolves to annotation object when deleted. + + +.. function:: annotator.storage.StorageAdapter.prototype.query(query) + + Queries the store + + :param Object query: + A query. This may be interpreted differently by different stores. + + :returns Promise: Resolves to the store return value. + + +.. function:: annotator.storage.StorageAdapter.prototype.load(query) + + Load and draw annotations from a given query. + + Runs the 'load' hook to allow modules to respond to annotations being loaded. + + :param Object query: + A query. This may be interpreted differently by different stores. + + :returns Promise: Resolves when loading is complete. + + diff --git a/doc/api/ui.rst b/doc/api/ui.rst new file mode 100644 index 000000000..6fa6dad2f --- /dev/null +++ b/doc/api/ui.rst @@ -0,0 +1,13 @@ +.. default-domain: js + +annotator.ui package +==================== + +.. include:: ui/main.rst + :start-line: 5 + +.. include:: ui/markdown.rst + :start-line: 5 + +.. include:: ui/tags.rst + :start-line: 5 diff --git a/doc/api/ui/main.rst b/doc/api/ui/main.rst new file mode 100644 index 000000000..e21bb0bae --- /dev/null +++ b/doc/api/ui/main.rst @@ -0,0 +1,33 @@ +.. default-domain: js + +annotator.ui package +==================== + +.. function:: annotator.ui.main([options]) + + A module that provides a default user interface for Annotator that allows + users to create annotations by selecting text within (a part of) the + document. + + Example:: + + app.include(annotator.ui.main); + + :param Object options: + + .. attribute:: options.element + + A DOM element to which event listeners are bound. Defaults to + ``document.body``, allowing annotation of the whole document. + + .. attribute:: options.editorExtensions + + An array of editor extensions. See the + :class:`~annotator.ui.editor.Editor` documentation for details of editor + extensions. + + .. attribute:: options.viewerExtensions + + An array of viewer extensions. See the + :class:`~annotator.ui.viewer.Viewer` documentation for details of viewer + extensions. diff --git a/doc/api/ui/markdown.rst b/doc/api/ui/markdown.rst new file mode 100644 index 000000000..9329ec1ca --- /dev/null +++ b/doc/api/ui/markdown.rst @@ -0,0 +1,28 @@ +.. default-domain: js + +annotator.ui.markdown package +============================= + +.. function:: annotator.ui.markdown.render(annotation) + + Render an annotation to HTML, converting annotation text from Markdown if + Showdown is available in the page. + + :returns: Rendered HTML. + :rtype: String + + +.. function:: annotator.ui.markdown.viewerExtension(viewer) + + An extension for the :class:`~annotator.ui.viewer.Viewer`. Allows the viewer + to interpret annotation text as `Markdown`_ and uses the `Showdown`_ library + if present in the page to render annotations with Markdown text as HTML. + + .. _Markdown: https://daringfireball.net/projects/markdown/ + .. _Showdown: https://github.com/showdownjs/showdown + + **Usage**:: + + app.include(annotator.ui.main, { + viewerExtensions: [annotator.ui.markdown.viewerExtension] + }); diff --git a/doc/api/ui/tags.rst b/doc/api/ui/tags.rst new file mode 100644 index 000000000..5307725c9 --- /dev/null +++ b/doc/api/ui/tags.rst @@ -0,0 +1,28 @@ +.. default-domain: js + +annotator.ui.tags package +========================= + +.. function:: annotator.ui.tags.viewerExtension(viewer) + + An extension for the :class:`~annotator.ui.viewer.Viewer` that displays any + tags stored as an array of strings in the annotation's ``tags`` property. + + **Usage**:: + + app.include(annotator.ui.main, { + viewerExtensions: [annotator.ui.tags.viewerExtension] + }) + + +.. function:: annotator.ui.tags.editorExtension(editor) + + An extension for the :class:`~annotator.ui.editor.Editor` that allows + editing a set of space-delimited tags, retrieved from and saved to the + annotation's ``tags`` property. + + **Usage**:: + + app.include(annotator.ui.main, { + editorExtensions: [annotator.ui.tags.editorExtension] + }) diff --git a/doc/authentication.rst b/doc/authentication.rst deleted file mode 100644 index 94df29da7..000000000 --- a/doc/authentication.rst +++ /dev/null @@ -1,152 +0,0 @@ -Authentication -============== - -What's the authentication system for? -------------------------------------- - -The simplest way to explain the role of the authentication system is by -example. Consider the following: - -1. Alice builds a website with documents which need annotating, DocLand. - -2. Alice registers DocLand with AnnotateIt, and receives a "consumer - key/secret" pair. - -3. Alice's users (Bob is one of them) login to her DocLand, and receive - an authentication token, which is a cryptographic combination of - (among other things) their unique user ID at DocLand, and DocLand's - "consumer secret". - -4. Bob's browser sends requests to AnnotateIt to save annotations, and - these include the authentication token as part of the payload. - -5. AnnotateIt can verify the Bob is a real user from DocLand, and thus - stores his annotation. - -So why go to all this trouble? Well, the point is really to save **you** -trouble. By implementing this authentication system (which shares key -ideas with the industry standard OAuth) you can provide your users with -the ability to annotate documents on your website without needing to -worry about implementing your own Annotator backend. You can use -`AnnotateIt `__ to provide the backend: all you -have to do is implement a token generator on your website (described -below). - -This is the simple explanation, but if you're in need of more technical -details, keep reading. - -Technical overview ------------------- - -How do we authorise users' browsers to create annotations on a -Consumer's behalf? There are three (and a half) entities involved: - -1. The Service Provider (SP; AnnotateIt in the above example) -2. The Consumer (C; DocLand) -3. The User (U; Bob), and the User Agent (UA; Bob's browser) - -Annotations are stored by the SP, which provides an API that the -Annotator's "Store" plugin understands. - -Text to be annotated, and configuration of the clientside Annotator, is -provided by the Consumer. - -Users will typically register with the Consumer -- we make no -assumptions about your user registration/authentication process other -than that it exists -- and the UA will, when visiting appropriate -sections of C's site, request an ``authToken`` from C. Typically, an -``authToken`` will only be provided if U is currently logged into C's -site. - -Technical specification ------------------------ - -It's unlikely you'll need to understand all of the following to get up -and running using AnnotateIt -- you can probably just copy and paste the -Python example given below -- but it's worth reading what follows if -you're doing anything unusual (such as giving out tokens to -unauthenticated users). - -The Annotator ``authToken`` is a type of `JSON Web -Token `__. -This document won't describe the details of the JWT specification, other -than to say that the token payload is signed by the consumer secret with -the HMAC-SHA256 algorithm, allowing the backend to verify that the -contents of the token haven't been interfered with while travelling from -the consumer. Numerous language implementations exist already -(`PyJWT `__, -`jwt `__ for Ruby, -`php-jwt `__, -`JWT-CodeIgniter `__...). - -The required contents of the token payload are: - -+-------------------+----------------------------------------------------------------------------------+------------------------------------------+ -| key | description | example | -+===================+==================================================================================+==========================================+ -| ``consumerKey`` | the consumer key issued by the backend store | ``"602368a0e905492fae87697edad14c3a"`` | -+-------------------+----------------------------------------------------------------------------------+------------------------------------------+ -| ``userId`` | the consumer's **unique** identifier for the user to whom the token was issued | ``"alice"`` | -+-------------------+----------------------------------------------------------------------------------+------------------------------------------+ -| ``issuedAt`` | the ISO8601 time at which the token was issued | ``"2012-03-23T10:51:18Z"`` | -+-------------------+----------------------------------------------------------------------------------+------------------------------------------+ -| ``ttl`` | the number of seconds after ``issuedAt`` for which the token is valid | ``86400`` | -+-------------------+----------------------------------------------------------------------------------+------------------------------------------+ - -You may wish the payload to contain other information (e.g. ``userRole`` -or ``userGroups``) and arbitrary additional keys may be added to the -token. This will only be useful if the Annotator client and the SP pay -attention to these keys. - -Lastly, note that the Annotator frontend does **not** verify the -authenticity of the tokens it receives. Only the SP is required to -verify authenticity of auth tokens before authorizing a request from the -Annotator frontend. - -For reference, here's a Python implementation of a token generator, -suitable for dropping straight into your -`Flask `__ or -`Django `__ project: - -.. code:: python - - import datetime - import jwt - - # Replace these with your details - CONSUMER_KEY = 'yourconsumerkey' - CONSUMER_SECRET = 'yourconsumersecret' - - # Only change this if you're sure you know what you're doing - CONSUMER_TTL = 86400 - - def generate_token(user_id): - return jwt.encode({ - 'consumerKey': CONSUMER_KEY, - 'userId': user_id, - 'issuedAt': _now().isoformat() + 'Z', - 'ttl': CONSUMER_TTL - }, CONSUMER_SECRET) - - def _now(): - return datetime.datetime.utcnow().replace(microsecond=0) - -Now all you need to do is expose an endpoint in your web application -that returns the token to logged-in users (say, -http://example.com/api/token), and you can set up the Annotator like so: - -.. code:: javascript - - $(body).annotator() - .annotator('setupPlugins', {tokenUrl: 'http://example.com/api/token'}); - -Colophon --------- - -Original planning documents at: - -- http://lists.okfn.org/pipermail/okfn-help/2010-December/000977.html - -Rehashed in Feb 2012: - -- http://lists.okfn.org/pipermail/annotator-dev/2012-January/000188.html diff --git a/doc/changes.rst b/doc/changes.rst new file mode 100644 index 000000000..5b40fcca9 --- /dev/null +++ b/doc/changes.rst @@ -0,0 +1,9 @@ +Annotator Change History +~~~~~~~~~~~~~~~~~~~~~~~~ + +All notable changes to this project are documented here. This project +endeavours to adhere to `Semantic Versioning`_. + +.. _Semantic Versioning: http://semver.org/ + +.. include:: ../CHANGES.rst diff --git a/doc/conf.py b/doc/conf.py index 79ea8886a..f7945ee97 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,19 +1,8 @@ # -*- coding: utf-8 -*- # -# Annotator documentation build configuration file, created by -# sphinx-quickstart on Wed Jan 22 17:02:43 2014. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# Annotator documentation build configuration file import json -import sys import os # By default, we do not want to use the RTD theme @@ -27,32 +16,12 @@ # Now we know for sure we do not have it pass -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.todo', -] -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +# -- General configuration # The suffix of source filenames. source_suffix = '.rst' -# The encoding of source files. -#source_encoding = 'utf-8-sig' - # The master toctree document. master_doc = 'index' @@ -71,205 +40,51 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# The default language to highlight source code in. This should be a valid +# Pygments lexer name. +highlight_language = 'javascript' -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False +# -- Sphinx extension configuration -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.extlinks', + 'sphinx.ext.todo', + 'sphinxcontrib.httpdomain', +] -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# A dictionary of external sites, mapping unique short alias names to a base +# URL and a prefix. +extlinks = { + 'gh': ('https://github.com/openannotation/annotator/%s', ''), + 'issue': ('https://github.com/openannotation/annotator/issues/%s', + 'issue '), +} -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# If this is True, todo and todolist produce output, else they produce nothing. +todo_include_todos = os.environ.get('SPHINX_TODOS') is not None -# -- Options for HTML output ---------------------------------------------- +# -- Options for HTML output # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'sphinx_rtd_theme' if sphinx_rtd_theme else 'default' -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - # Add any paths that contain custom themes here, relative to this directory. if sphinx_rtd_theme: html_theme_path = [ sphinx_rtd_theme.get_html_theme_path() ] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Output file base name for HTML help builder. -htmlhelp_basename = 'Annotatordoc' - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - ('index', 'Annotator.tex', u'Annotator Documentation', - u'The Annotator project contributors', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'annotator', u'Annotator Documentation', - [u'The Annotator project contributors'], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ('index', 'Annotator', u'Annotator Documentation', - u'The Annotator project contributors', 'Annotator', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False diff --git a/doc/getting-started.rst b/doc/getting-started.rst deleted file mode 100644 index d569f4a43..000000000 --- a/doc/getting-started.rst +++ /dev/null @@ -1,169 +0,0 @@ -Getting started with Annotator -============================== - -The Annotator libraries ------------------------ - -To get the Annotator up and running on your website you'll need to -either link to a hosted version or deploy the Annotator source files -yourself. Details of both are provided below. - -.. note:: - - If you are using Wordpress there is also a `Annotator Wordpress - plugin `__ - which will take care of installing and integrating Annotator for you. - -Hosted Annotator Library -~~~~~~~~~~~~~~~~~~~~~~~~ - -For each Annotator release, we make available the following assets: - -:: - - http://assets.annotateit.org/annotator/{version}/annotator-full.min.js - http://assets.annotateit.org/annotator/{version}/annotator.min.js - http://assets.annotateit.org/annotator/{version}/annotator.{pluginname}.min.js - http://assets.annotateit.org/annotator/{version}/annotator.min.css - -Use ``annotator-full.min.js`` if you want to include both the core and -all plugins in a single file. Use ``annotator.min.js`` if you need only -the core. You can add individual plugins by including the relevant -:samp:`annotator.{pluginname}.min.js` files. - -For example, a full version of the Annotator can be loaded with the -following code: - -.. code:: html - - - - -Deploy the Annotator Locally -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To do this visit the `download -area `__ and grab the latest -version. This contains the Annotator source code as well as the plugins -developed as part of the Annotator project. - -Including Annotator on your webpage -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You need to link the Annotator Javascript and CSS into the page. - -.. note:: Annotator requires jQuery 1.6 or greater. - -.. code:: html - - - - - -Setting up Annotator --------------------- - -Setting up Annotator requires only a single line of code. Use jQuery to -select the element that you would like to annotate eg. -``
...
`` and call the ``.annotator()`` method on -it: - -.. code:: javascript - - jQuery(function ($) { - $('#content').annotator(); - }); - -Annotator will now be loaded on the ``#content`` element. Select some -text to see it in action. - -Options -------- - -You can optionally specify options: - -``readOnly`` - True to allow viewing annotations, but not creating or editing them. - Defaults to ``false``. - -.. code:: javascript - - jQuery(function ($) { - $('#content').annotator({ - readOnly: true - }); - }); - -Setting up the default plugins ------------------------------- - -We include a special setup function in the ``annotator-full.min.js`` -file that installs all the default plugins for you automatically. To run -it just add a call to ``.annotator("setupPlugins")``. - -.. code:: javascript - - jQuery(function ($) { - $('#content').annotator() - .annotator('setupPlugins'); - }); - -This will set up the following: - -1. The :doc:`Tags `, :doc:`Filter ` & - :doc:`Unsupported ` plugins. -2. The :doc:`Auth `, :doc:`Permissions ` and - :doc:`Store ` plugins, for interaction with the `AnnotateIt - store `__. -3. If the `Showdown `__ library has - been included on the page the :doc:`plugins/markdown` will also - be loaded. - -You can further customise the plugins by providing an object containing -options for individual plugins. Or to disable a plugin set it's -attribute to ``false``. - -.. code:: javascript - - jQuery(function ($) { - // Customise the default plugin options with the third argument. - $('#content').annotator() - .annotator('setupPlugins', {}, { - // Disable the tags plugin - Tags: false, - // Filter plugin options - Filter: { - addAnnotationFilter: false, // Turn off default annotation filter - filters: [{label: 'Quote', property: 'quote'}] // Add a quote filter - } - }); - }); - -Adding more plugins -------------------- - -To add a plugin first make sure that you're loading the script into the -page. Then call ``.annotator('addPlugin', 'PluginName')`` to load the -plugin. Options can also be passed to the plugin as additional -parameters after the plugin name. - -Here we add the tags plugin to the page: - -.. code:: javascript - - jQuery(function ($) { - $('#content').annotator() - .annotator('addPlugin', 'Tags'); - }); - -For more information on available plugins check the navigation to the right of -this article. Or to create your own check the :doc:`creating a plugin section -`. - -Saving annotations ------------------- - -In order to keep your annotations around longer than a single page view -you'll need to set up a store on your server or use an external service -like `AnnotateIt `__. For more information on -storing annotations check out the :doc:`Store Plugin ` on the wiki. diff --git a/doc/glossary.rst b/doc/glossary.rst new file mode 100644 index 000000000..517a6e5f7 --- /dev/null +++ b/doc/glossary.rst @@ -0,0 +1,33 @@ +.. _glossary: + +Glossary +======== + +.. glossary:: + :sorted: + + application + An application is an instance of :class:`annotator.App`. It is the primary + object that coordinates annotation activities. It can be extended by + passing a :term:`module` reference to its + :func:`~annotator.App.prototype.include` method. Typically, you will + create at least one application when using Annotator. See the API + documentation for :class:`annotator.App` for details on construction and + methods. + + hook + A function that handles work delegated to a :term:`module` by the + :term:`application`. A hook function can return a value or a + :term:`Promise`. The arguments to hook functions can vary. See + :ref:`module-hooks` for a description of the core hooks provided by + Annotator. + + module + A module extends the functionality of an :term:`application`, primarily + through :term:`hook` functions. See the section :doc:`module-development` + for details about writing modules. + + Promise + An object used for deferred and asynchronous computations. + See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise + for more information. diff --git a/doc/hacking/plugin-development.rst b/doc/hacking/plugin-development.rst deleted file mode 100644 index 3105de3e8..000000000 --- a/doc/hacking/plugin-development.rst +++ /dev/null @@ -1,246 +0,0 @@ -Plugin development -================== - -Getting Started ---------------- - -Building a plugin is very simple. Simply attach a function that creates -your plugin to the ``Annotator.Plugin`` namespace. The function will -receive the following arguments. - -``element`` - The DOM element that is currently being annotated. - -Additional arguments (such as options) can be passed in by the user when -the plugin is added to the Annotator. These will be passed in after the -``element``. - -.. code:: javascript - - Annotator.Plugin.HelloWorld = function (element) { - var myPlugin = {}; - // Create your plugin here. Then return it. - return myPlugin; - }; - -Using Your Plugin -~~~~~~~~~~~~~~~~~ - -Adding your plugin to the annotator is the same as for all supported -plugins. Simply call "addPlugin" on the annotator and pass in the name -of the plugin and any options. For example: - -.. code:: javascript - - // Setup the annotator on the page. - var content = $('#content').annotator(); - - // Add your plugin. - content.annotator('addPlugin', 'HelloWorld' /*, any other options */); - -Setup -~~~~~ - -When the annotator creates your plugin it will take the following steps. - -1. Call your Plugin function passing in the annotated element plus any - additional arguments. (The Annotator calls the function with ``new`` - allowing you to use a constructor function if you wish). -2. Attaches the current instance of the Annotator to the ``.annotator`` - property of the plugin. -3. Calls ``.pluginInit()`` if the method exists on your plugin. - -pluginInit() -~~~~~~~~~~~~ - -If your plugin has a ``pluginInit()`` method it will be called after the -annotator has been attached to your plugin. You can use it to set up the -plugin. - -In this example we add a field to the viewer that contains the text -provided when the plugin was added. - -.. code:: javascript - - Annotator.Plugin.Message = function (element, message) { - var plugin = {}; - - plugin.pluginInit = function () { - this.annotator.viewer.addField({ - load: function (field, annotation) { - field.innerHTML = message; - } - }) - }; - - return plugin; - } - -Usage: - -.. code:: javascript - - // Setup the annotator on the page. - var content = $('#content').annotator(); - - // Add your plugin to the annotator and display the message "Hello World" - // in the viewer. - content.annotator('addPlugin', 'Message', 'Hello World'); - -Extending Annotator.Plugin --------------------------- - -All supported Annotator plugins use a base "class" that has some useful -features such as event handling. To use this you simply need to extend -the ``Annotator.Plugin`` function. - -.. code:: javascript - - // This is now a constructor and needs to be called with `new`. - Annotator.Plugin.MyPlugin = function (element, options) { - - // Call the Annotator.Plugin constructor this sets up the .element and - // .options properties. - Annotator.Plugin.apply(this, arguments); - - // Set up the rest of your plugin. - }; - - // Set the plugin prototype. This gives us all of the Annotator.Plugin methods. - Annotator.Plugin.MyPlugin.prototype = new Annotator.Plugin(); - - // Now add your own custom methods. - Annotator.Plugin.MyPlugin.prototype.pluginInit = function () { - // Do something here. - }; - -If you're using jQuery you can make this process a lot neater. - -.. code:: javascript - - Annotator.Plugin.MyPlugin = function (element, options) { - // Same as before. - }; - - jQuery.extend(Annotator.Plugin.MyPlugin.prototype, new Annotator.Plugin(), { - events: {}, - options: { - // Any default options. - } - pluginInit: function () { - - }, - myCustomMethod: function () { - - } - }); - -Annotator.Plugin API --------------------- - -The Annotator.Plugin provides the following methods and properties. - -element -~~~~~~~ - -This is the DOM element currently being annotated wrapped in a jQuery -wrapper. - -options -~~~~~~~ - -This is the options object, you can set default options when you create -the object and they will be overridden by those provided when the plugin -is created. - -events -~~~~~~ - -These can be either DOM events to be listened for within the -``.element`` or custom events defined by you. Custom events will not -receive the ``event`` property that is passed to DOM event listeners. -These are bound when the plugin is instantiated. - -publish(name, parameters) -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Publish a custom event to all subscribers. - -- ``name``: The event name. -- ``parameters``: An array of parameters to pass to the subscriber. - -subscribe(name, callback) -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Subscribe to a custom event. This can be used to subscribe to your own -events or those broadcast by the annotator and other plugins. - -- ``name``: The event name. -- ``callback``: A callback to be fired when the event is published. The - callback will receive any arguments sent when the event is published. - -unsubscribe(name, callback) -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Unsubscribe from an event. - -- ``name``: The event name. -- ``callback``: The callback to be unsubscribed. - -Annotator Events ----------------- - -The annotator fires the following events at key points in its operation. -You can subscribe to them using the ``.subscribe()`` method. This can be -called on either the ``.annotator`` object or if you're extending -``Annotator.Plugin`` the plugin instance itself. The events are as -follows: - -``beforeAnnotationCreated(annotation)`` - called immediately before an annotation is created. If you need to modify - the annotation before it is saved use this event. -``annotationCreated(annotation)`` - called when the annotation is created use this to store the annotations. -``beforeAnnotationUpdated(annotation)`` - as above, but just before an existing annotation is saved. -``annotationUpdated(annotation)`` - as above, but for an existing annotation which has just been edited. -``annotationDeleted(annotation)`` - called when the user deletes an annotation. -``annotationEditorShown(editor, annotation)`` - called when the annotation editor is presented to the user. -``annotationEditorHidden(editor)`` - called when the annotation editor is hidden, both when submitted and when - editing is cancelled. -``annotationEditorSubmit(editor, annotation)`` - called when the annotation editor is submitted. -``annotationViewerShown(viewer, annotations)`` - called when the annotation viewer is shown and provides the annotations - being displayed. -``annotationViewerTextField(field, annotation)`` - called when the text field displaying the annotation comment in the viewer - is created. - -Example -~~~~~~~ - -A plugin that logs annotation activity to the console. - -.. code:: javascript - - Annotator.Plugin.StoreLogger = function (element) { - return { - pluginInit: function () { - this.annotator - .subscribe("annotationCreated", function (annotation) { - console.info("The annotation: %o has just been created!", annotation) - }) - .subscribe("annotationUpdated", function (annotation) { - console.info("The annotation: %o has just been updated!", annotation) - }) - .subscribe("annotationDeleted", function (annotation) { - console.info("The annotation: %o has just been deleted!", annotation) - }); - } - } - }; diff --git a/doc/index.rst b/doc/index.rst index 5d96b32b2..d81238467 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,25 +1,64 @@ -Welcome to Annotator's documentation! -===================================== +======================= +Annotator Documentation +======================= + +.. warning:: Beware: rapidly changing documentation! + + This is the bleeding-edge documentation for Annotator that will be changing + rapidly as we home in on Annotator v2.0. Information here may be inaccurate, + prone to change, and otherwise unreliable. You may well want to consult `the + stable documentation`_ instead. + +.. _the stable documentation: http://docs.annotatorjs.org/en/v1.2.x/ .. highlight:: js -Contents: +Welcome to the documentation for Annotator, an open-source JavaScript library +for building annotation systems on the web. At its simplest, Annotator +enables textual annotations of any web page. You can deploy it using just a +few lines of code. + +Annotator is also a library of composable tools for capturing and manipulating +DOM selections; storing, persisting and retrieving annotations; and creating +user interfaces for annotation. You may use few or many of these components +together to build your own custom annotation-based :term:`application`. + +Continue reading to learn about installing and deploying Annotator: + .. toctree:: :maxdepth: 2 - getting-started - annotation-format - authentication - storage + installing + usage + upgrading + modules/index + module-development + internationalization - plugins/index - hacking/plugin-development + roadmap + api/index + + +.. todolist:: -Indices and tables +Change History +============== + +.. toctree:: + :maxdepth: 1 + + changes + +Glossary and Index ================== +* :ref:`glossary` * :ref:`genindex` -* :ref:`modindex` -* :ref:`search` + + +.. toctree:: + :hidden: + + glossary diff --git a/doc/installing.rst b/doc/installing.rst new file mode 100644 index 000000000..3fe162f95 --- /dev/null +++ b/doc/installing.rst @@ -0,0 +1,33 @@ +Installing +========== + +Annotator is a JavaScript library, and there are two main approaches to using +it. You can either use the standalone packaged files, or you can install it from +the npm_ package repository and integrate the source code into your own +browserify_ or webpack_ toolchain. + +.. _npm: https://www.npmjs.com/ +.. _browserify: http://browserify.org/ +.. _webpack: https://webpack.github.io/ + + +Built packages +-------------- + +:gh:`Releases ` are published on :gh:`our GitHub repository <>`. The +released zip file will contain minified, production-ready JavaScript files that +you can include in your application. + +To load Annotator with the default set of components, place the following +`` + +npm package +----------- + +We also publish an ``annotator`` package to npm. This package is not particularly +useful in a Node.js context, but can be used by browserify_ or webpack_. Please +see the documentation for these packages for more information on using them. diff --git a/doc/internationalization.rst b/doc/internationalization.rst index 4450e5a71..54d7cb946 100644 --- a/doc/internationalization.rst +++ b/doc/internationalization.rst @@ -1,7 +1,7 @@ Internationalisation and localisation (I18N, L10N) ================================================== -Annotator now has rudimentary support for localisation of its interface. +Annotator has rudimentary support for localisation of its interface. For users --------- diff --git a/doc/module-development.rst b/doc/module-development.rst new file mode 100644 index 000000000..9768ed3ac --- /dev/null +++ b/doc/module-development.rst @@ -0,0 +1,205 @@ +Module development +================== + +The basics +---------- + +An Annotator :term:`module` is a function that can be passed to +:func:`~annotator.App.prototype.include` in order to extend the functionality of +an Annotator application. + +The simplest possible Annotator module looks like this:: + + function myModule() { + return {}; + } + +This clearly won't do very much, but we can include it in an application:: + + app.include(myModule); + +If we want to do something more interesting, we have to provide some module +functionality. There are two ways of doing this: + +1. module hooks +2. component registration + +Use module hooks unless you are replacing core functionality of Annotator. +Module hooks are functions that will be run by the :class:`~annotator.App` when +important things happen. For example, here's a module that will say +``Hello, world!`` to the user when the application starts:: + + function helloWorld() { + return { + start: function (app) { + app.notify("Hello, world!"); + } + }; + } + +Just as before, we can include it in an application using +:func:`~annotator.App.prototype.include`:: + + app.include(helloWorld); + +Now, when you run ``app.start();``, this module will send a notification with +the words ``Hello, world!``. + +Or, here's another example that uses the `HTML5 Audio API`_ to play a sound +every time a new annotation is made [#1]_:: + + function fanfare(options) { + options = options || {}; + options.url = options.url || 'trumpets.mp3'; + + return { + annotationCreated: function (annotation) { + var audio = new Audio(options.url); + audio.play(); + } + }; + } + +Here we've added an ``options`` argument to the module function so we can +configure the module when it's included in our application:: + + app.include(fanfare, { + url: "brass_band.wav" + }); + +You may have noticed that the :func:`annotationCreated` module hook function +here receives one argument, ``annotation``. Similarly, the :func:`start` module +hook function in the previous example receives an ``app`` argument. A complete +reference of arguments and hooks is covered in the :ref:`module-hooks` section. + +.. _HTML5 Audio API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API + + +Loading custom modules +---------------------- + +When you write a custom module, you'll end up with a JavaScript function that +you need to reference when you build your application. In the examples above +we've just defined a function and then used it straight away. This is probably +fine for small examples, but when things get a bit more complicated you might +want to put your modules in a namespace. + +For example, if you were working on an application for annotating Shakespeare's +plays, you might put all your modules in a namespace called ``shakespeare``:: + + var shakespeare = {}; + shakespeare.fanfare = function fanfare(options) { + ... + }; + shakespeare.addSceneData = function addSceneData(options) { + ... + }; + +You get the idea. You can now :func:`~annotator.App.prototype.include` these +modules directly from the namespace:: + + app.include(shakespeare.fanfare, { + url: "elizabethan_sackbuts.mp3" + }); + app.include(shakespeare.addSceneData); + +.. _module-hooks: + +Module hooks +------------ + +Hooks are called by the application in order to delegate work to registered +modules. This is a list of module hooks, when they are called, and what +arguments they receive. + +It is possible to add your own hooks to your application by invoking the +:func:`~annotator.App.prototype.runHook` method on the application instance. +The return value is a :term:`Promise` that resolves to an ``Array`` of the +results of the functions registered for that hook (the order of which is +undefined). + +Hook functions may return a value or a :term:`Promise`. The latter is sometimes +useful for delaying actions. For example, you may wish to return a +:term:`Promise` from the ``beforeAnnotationCreated`` hook when an asynchronous +task must complete before the annotation data can be saved. + + +.. function:: configure(registry) + + Called when the plugin is included. If you are going to register components + with the registry, you should do so in the `configure` module hook. + + :param Registry registry: The application registry. + + +.. function:: start(app) + + Called when :func:`~annotator.App.prototype.start` is called. + + :param App app: The configured application. + + +.. function:: destroy() + + Called when :func:`~annotator.App.prototype.destroy` is called. If your + module needs to do any cleanup, such as unbinding events or disposing of + elements injected into the DOM, it should do so in the `destroy` hook. + + +.. function:: annotationsLoaded(annotations) + + Called with annotations retrieved from storage using + :func:`~annotator.storage.StorageAdapter.load`. + + :param Array[Object] annotations: The annotation objects loaded. + + +.. function:: beforeAnnotationCreated(annotation) + + Called immediately before an annotation is created. Modules may use this + hook to modify the annotation before it is saved. + + :param Object annotation: The annotation object. + + +.. function:: annotationCreated(annotation) + + Called when a new annotation is created. + + :param Object annotation: The annotation object. + + +.. function:: beforeAnnotationUpdated(annotation) + + Called immediately before an annotation is updated. Modules may use this + hook to modify the annotation before it is saved. + + :param Object annotation: The annotation object. + + +.. function:: annotationUpdated(annotation) + + Called when an annotation is updated. + + :param Object annotation: The annotation object. + + +.. function:: beforeAnnotationDeleted(annotation) + + Called immediately before an annotation is deleted. Use if you need to + conditionally cancel deletion, for example. + + :param Object annotation: The annotation object. + + +.. function:: annotationDeleted(annotation) + + Called when an annotation is deleted. + + :param Object annotation: The annotation object. + + +.. rubric:: Footnotes + +.. [#1] Yes, this might be quite annoying. Probably not an example to copy + wholesale into your real application... diff --git a/doc/modules/authz.rst b/doc/modules/authz.rst new file mode 100644 index 000000000..03fbc0b8f --- /dev/null +++ b/doc/modules/authz.rst @@ -0,0 +1,8 @@ +``annotator.authz.acl`` +======================= + +This module configures an authorization policy that grants or denies permission +on objects (especially annotations) based on the presence of ``permissions`` or +``user`` properties on the objects. + +See :func:`annotator.authz.acl` for full API documentation. diff --git a/doc/modules/identity.rst b/doc/modules/identity.rst new file mode 100644 index 000000000..841d853e2 --- /dev/null +++ b/doc/modules/identity.rst @@ -0,0 +1,20 @@ +``annotator.identity.simple`` +============================= + +This module configures an identity policy that considers the identity of the +current user to be an opaque identifier. By default the identity is +unconfigured, but can be set. + +Example +------- + +:: + + app.include(annotator.identity.simple); + app + .start() + .then(function () { + app.ident.identity = 'joebloggs'; + }); + +See :func:`annotator.identity.simple` for full API documentation. diff --git a/doc/modules/index.rst b/doc/modules/index.rst new file mode 100644 index 000000000..4402a0316 --- /dev/null +++ b/doc/modules/index.rst @@ -0,0 +1,11 @@ +Modules +======= + +A great deal of functionality in Annotator is provided by modules. These pages +document these modules and how they work together. + +.. toctree:: + :glob: + :maxdepth: 1 + + * diff --git a/doc/modules/storage.rst b/doc/modules/storage.rst new file mode 100644 index 000000000..87c5da682 --- /dev/null +++ b/doc/modules/storage.rst @@ -0,0 +1,329 @@ +========================== +``annotator.storage.http`` +========================== + +This module provides the ability to send annotations for storage in a remote +server that implements the storage-api_. + +Usage +===== + +To use the ``annotator.storage.http`` module, you should include it in an +instance of :class:`annotator.App`:: + + app.include(annotator.storage.http); + +You can provide options to the module by passing an additional argument to +:func:`annotator.App.prototype.include`:: + + app.include(annotator.storage.http, { + prefix: 'http://example.com/api' + }); + +See :data:`annotator.storage.HttpStorage.options` for the full list of options +to the ``annotator.storage.http`` module. + + +.. _storage-api: + +Storage API +=========== + +The :func:`annotator.storage.http` module talks to a remote server that serves +an HTTP API. This section documents the expected API. It is targeted at +developers interested in developing their own backend servers that integrate +with Annotator, or developing tools that integrate with existing instances of +the API. + +The storage API attempts to follow the principles of `REST +`__, and uses JSON +as its primary interchange format. + +.. contents:: + :local: + +Endpoints +--------- + +root +~~~~ + +.. http:get:: /api + + API root. Returns an object containing store metadata, including hypermedia + links to the rest of the API. + + **Example request**: + + .. sourcecode:: http + + GET /api + Host: example.com + Accept: application/json + + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Access-Control-Allow-Origin: * + Access-Control-Expose-Headers: Content-Length, Content-Type, Location + Content-Length: 1419 + Content-Type: application/json + + { + "message": "Annotator Store API", + "links": { + "annotation": { + "create": { + "desc": "Create a new annotation", + "method": "POST", + "url": "http://example.com/api/annotations" + }, + "delete": { + "desc": "Delete an annotation", + "method": "DELETE", + "url": "http://example.com/api/annotations/:id" + }, + "read": { + "desc": "Get an existing annotation", + "method": "GET", + "url": "http://example.com/api/annotations/:id" + }, + "update": { + "desc": "Update an existing annotation", + "method": "PUT", + "url": "http://example.com/api/annotations/:id" + } + }, + "search": { + "desc": "Basic search API", + "method": "GET", + "url": "http://example.com/api/search" + } + } + } + + :reqheader Accept: desired response content type + :resheader Content-Type: response content type + :statuscode 200: no error + + +read +~~~~ + +.. http:get:: /api/annotations/(string:id) + + Retrieve a single annotation. + + **Example request**: + + .. sourcecode:: http + + GET /api/annotations/utalbWjUaZK5ifydnohjmA + Host: example.com + Accept: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json; charset=UTF-8 + + { + "created": "2013-08-26T13:31:49.339078+00:00", + "updated": "2013-08-26T14:09:14.121339+00:00", + "id": "utalbWjUQZK5ifydnohjmA", + "uri": "http://example.com/foo", + "user": "acct:johndoe@example.org", + ... + } + + :reqheader Accept: desired response content type + :resheader Content-Type: response content type + :statuscode 200: no error + :statuscode 404: annotation with the specified `id` not found + + +create +~~~~~~ + +.. http:post:: /api/annotations + + Create a new annotation. + + **Example request**: + + .. sourcecode:: http + + POST /api/annotations + Host: example.com + Accept: application/json + Content-Type: application/json;charset=UTF-8 + + { + "uri": "http://example.org/", + "user": "joebloggs", + "permissions": { + "read": ["group:__world__"], + "update": ["joebloggs"], + "delete": ["joebloggs"], + "admin": ["joebloggs"], + }, + "target": [ ... ], + "text": "This is an annotation I made." + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json; charset=UTF-8 + + { + "id": "AUxWM-HasREW1YKAwhil", + "uri": "http://example.org/", + "user": "joebloggs", + ... + } + + :param id: annotation's unique id + :reqheader Accept: desired response content type + :reqheader Content-Type: request body content type + :resheader Content-Type: response content type + :>json string id: unique id of new annotation + :statuscode 200: no error + :statuscode 400: could not create annotation from your request (bad payload) + + +update +~~~~~~ + +.. http:put:: /api/annotations/(string:id) + + Update the annotation with the given `id`. Requires a valid authentication + token. + + **Example request**: + + .. sourcecode:: http + + PUT /api/annotations/AUxWM-HasREW1YKAwhil + Host: example.com + Accept: application/json + Content-Type: application/json;charset=UTF-8 + + { + "uri": "http://example.org/foo", + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json; charset=UTF-8 + + { + "id": "AUxWM-HasREW1YKAwhil", + "updated": "2015-03-26T13:09:42.646509+00:00" + "uri": "http://example.org/foo", + "user": "joebloggs", + ... + } + + :param id: annotation's unique id + :reqheader Accept: desired response content type + :reqheader Content-Type: request body content type + :resheader Content-Type: response content type + :statuscode 200: no error + :statuscode 400: could not update annotation from your request (bad payload) + :statuscode 404: annotation with the given `id` was not found + + +delete +~~~~~~ + +.. http:delete:: /api/annotations/(string:id) + + Delete the annotation with the given `id`. Requires a valid authentication + token. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/annotations/AUxWM-HasREW1YKAwhil + Host: example.com + Accept: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Content-Length: 0 + + :param id: annotation's unique id + :reqheader Accept: desired response content type + :resheader Content-Type: response content type + :statuscode 200: no error + :statuscode 404: annotation with the given `id` was not found + + +search +~~~~~~ + +.. http:get:: /api/search + + Search the database of annotations. Search for fields using query string + parameters. + + **Example request**: + + .. sourcecode:: http + + GET /api/search?text=foobar&limit=10 + Host: example.com + Accept: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Length: 6771 + Content-Type: application/json + + { + "total": 43127, + "rows": [ + { + "id": "d41d8cd98f00b204e9800998ecf8427e", + "text": "Updated annotation text", + ... + }, + ... + ] + } + + :query offset: return results starting at `offset` + :query limit: return only `limit` results + :reqheader Accept: desired response content type + :reqheader Content-Type: request body content type + :resheader Content-Type: response content type + :>json int total: total number of results across all pages + :>json array rows: array of matching annotations + :statuscode 200: no error + :statuscode 400: could not search the database with your request (invalid query) + +Storage implementations +----------------------- + +You can find a list of compatible backends implementing the above API `on the +GitHub wiki`_. + +.. _on the GitHub Wiki: https://github.com/openannotation/annotator/wiki#backend-stores diff --git a/doc/modules/ui.rst b/doc/modules/ui.rst new file mode 100644 index 000000000..eb0210faa --- /dev/null +++ b/doc/modules/ui.rst @@ -0,0 +1,57 @@ +``annotator.ui.main`` +===================== + +This module provides a user interface for the application, allowing users to +make annotations on a document or an element within the document. It can be used +as follows:: + + app.include(annotator.ui.main); + +By default, the module will set up event listeners on the document ``body`` so +that when the user makes a selection they will be prompted to create an +annotation. It is also possible to ask the module to only allow creation of +annotations within a specific element on the page:: + + app.include(annotator.ui.main, { + element: document.querySelector('#main') + }); + + +The module provides just one possible configuration of the various components in +the `annotator.ui` package, and users with more advanced needs may wish to +create their own modules that use those components (which include +:class:`~annotator.ui.textselector.TextSelector`, +:class:`~annotator.ui.adder.Adder`, +:class:`~annotator.ui.highlighter.Highlighter`, +:class:`~annotator.ui.viewer.Viewer`, and :class:`~annotator.ui.editor.Editor`). + +Viewer/editor extensions +------------------------ + +The `annotator.ui` package contains a number of extensions for the +:class:`~annotator.ui.viewer.Viewer` and :class:`~annotator.ui.editor.Editor`, +which extend the functionality. These include: + +- :func:`annotator.ui.tags.viewerExtension`: A viewer extension that displays + any tags stored on annotations. + +- :func:`annotator.ui.tags.editorExtension`: An editor extension that provides + a field for editing annotation tags. + +- :func:`annotator.ui.markdown.viewerExtension`: A viewer extension that + depends on Showdown_, and makes the viewer render Markdown_ annotation + bodies. + +.. _Showdown: https://github.com/showdownjs/showdown +.. _Markdown: https://daringfireball.net/projects/markdown/ + +These can be used by passing them to the relevant options of +``annotator.ui.main``:: + + app.include(annotator.ui.main, { + editorExtensions: [annotator.ui.tags.editorExtension], + viewerExtensions: [ + annotator.ui.markdown.viewerExtension, + annotator.ui.tags.viewerExtension + ] + }); diff --git a/doc/plugins/auth.rst b/doc/plugins/auth.rst deleted file mode 100644 index 4b0d0f62a..000000000 --- a/doc/plugins/auth.rst +++ /dev/null @@ -1,44 +0,0 @@ -``Auth`` plugin -=============== - -The Auth plugin complements the :doc:`store` by providing -authentication for requests. This may be necessary if you are running -the Store on a separate domain or using a third party service like -annotateit.org. - -The plugin works by requesting an authentication token from the local -server and then provides this in all requests to the store. For more -details see the :doc:`specification <../authentication>`. - -Usage ------ - -Adding the Auth plugin to the annotator is very simple. Simply add the -annotator to the page using the ``.annotator()`` jQuery plugin. Then -call the ``.addPlugin()`` method eg. -``.annotator('addPlugin', 'Auth')``. - -.. code:: javascript - - var content = $('#content')); - content.annotator('addPlugin', 'Auth', { - tokenUrl: '/auth/token' - }); - -Options -------- - -The following options are available to the Auth plugin. - -- ``tokenUrl``: The URL to request the token from. Defaults to - ``/auth/token``. -- ``token``: An auth token. If this is present it will not be requested - from the server. Defaults to ``null``. -- ``autoFetch``: Whether to fetch the token when the plugin is loaded. - Defaults to ``true`` - -Token format -^^^^^^^^^^^^ - -For details of the token format, see the page on :doc:`Annotator's -Authentication system <../authentication>`. diff --git a/doc/plugins/filter.rst b/doc/plugins/filter.rst deleted file mode 100644 index 95e5a7c0c..000000000 --- a/doc/plugins/filter.rst +++ /dev/null @@ -1,84 +0,0 @@ -``Filter`` plugin -================= - -This plugin allows the user to navigate and filter the displayed -annotations. - -Interface Overview ------------------- - -The plugin adds a toolbar to the top of the window. This contains the -available filters that can be applied to the current annotations. - -Usage ------ - -Adding the Filter plugin to the annotator is very simple. Add the -annotator to the page using the ``.annotator()`` jQuery plugin. Then -call the ``.addPlugin()`` method by calling -``.annotator('addPlugin', 'Filter')``. - -.. code:: javascript - - var content = $('#content').annotator().annotator('addPlugin', 'Filter'); - -Options -~~~~~~~ - -There are several options available to customise the plugin. - -- ``filters``: This is an array of filter objects. These will be added - to the toolbar on load. -- ``addAnnotationFilter``: If ``true`` this will display the default - filter that searches the annotation text. - -Filters -~~~~~~~ - -Filters are very easy to create. The options require two properties a -``label`` and an annotation ``property`` to search for. For example if -we wanted to filter on an annotations quoted text we can create the -following filter. - -.. code:: javascript - - content.annotator('addPlugin', 'Filter', { - filters: [ - { - label: 'Quote', - property: 'quote' - } - ] - }); - -You can also customise the filter logic that determines if an annotation -should be filtered by providing an ``isFiltered`` function. This -function receives the contents of the filter input as well as the -annotation property. It should return ``true`` if the annotation should -remain highlighted. - -Heres an example that uses the ``annotation.tags`` property, which is an -array of tags: - -.. code:: javascript - - content.annotator('addPlugin', 'Filter', { - filters: [ - { - label: 'Tag', - property: 'tags', - isFiltered: function (input, tags) { - if (input && tags && tags.length) { - var keywords = input.split(/\s+/g); - for (var i = 0; i < keywords.length; i += 1) { - for (var j = 0; j < tags.length; j += 1) { - if (tags[j].indexOf(keywords[i]) !== -1) { - return true; - } - } - } - } - return false; - }} - ] - }); diff --git a/doc/plugins/index.rst b/doc/plugins/index.rst deleted file mode 100644 index f2029b66a..000000000 --- a/doc/plugins/index.rst +++ /dev/null @@ -1,12 +0,0 @@ -Plugins -======= - -Annotator has a highly modular architecture, and a great deal of functionality -is provided by plugins. These pages document these plugins and how they work -together. - -.. toctree:: - :glob: - :maxdepth: 1 - - * diff --git a/doc/plugins/markdown.rst b/doc/plugins/markdown.rst deleted file mode 100644 index b225b807c..000000000 --- a/doc/plugins/markdown.rst +++ /dev/null @@ -1,41 +0,0 @@ -``Markdown`` Plugin -=================== - -The Markdown plugin allows you to use -`Markdown `__ in your -annotation comments. It will then render them in the Viewer. - -Requirements ------------- - -This plugin requires that the -`Showdown `__ Markdown library be -loaded in the page before the plugin is added to the annotator. To do -this simply -`download `__ -the showdown.js and include it on your page before the annotator. - -.. code:: html - - - - - - -Usage ------ - -Adding the Markdown plugin to the annotator is very simple. Simply add -the annotator to the page using the ``.annotator()`` jQuery plugin and -retrieve the annotator object using ``.data('annotator')``. Then add the -``Markdown`` plugin. - -.. code:: javascript - - var content = $('#content').annotator(); - content.annotator('addPlugin', 'Markdown'); - -Options -~~~~~~~ - -*There are no options available for this plugin* diff --git a/doc/plugins/permissions.rst b/doc/plugins/permissions.rst deleted file mode 100644 index c61b88963..000000000 --- a/doc/plugins/permissions.rst +++ /dev/null @@ -1,267 +0,0 @@ -``Permissions`` plugin -====================== - -This plugin handles setting the user and permissions properties on -annotations as well as providing some enhancements to the interface. - -Interface Overview ------------------- - -The following elements are added to the Annotator interface by this -plugin. - -Viewer -^^^^^^ - -The plugin adds a section to a viewed annotation displaying the name of -the user who created it. It also checks the annotation's permissions to -see if the current user can **edit**/**delete** the current annotation -and displays controls appropriately. - -Editor -^^^^^^ - -The plugin adds two fields with checkboxes to the annotation editor -(these are only displayed if the current user has **admin** permissions -on the annotation). One to allow anyone to view the annotation and one -to allow anyone to edit the annotation. - -Usage ------ - -Adding the permissions plugin to the annotator is very simple. Simply -add the annotator to the page using the ``.annotator()`` jQuery plugin -and retrieve the annotator object using ``.data('annotator')``. We now -add the plugin and pass an options object to set the current user. - -.. code:: javascript - - var annotator = $('#content').annotator().data('annotator'); - annotator.addPlugin('Permissions', { - user: 'Alice' - }); - -By default all annotations are publicly viewable/editable/deleteable. We -can set our own permissions using the options object. - -.. code:: javascript - - var annotator = $('#content').annotator().data('annotator'); - annotator.addPlugin('Permissions', { - user: 'Alice', - permissions: { - 'read': [], - 'update': ['Alice'], - 'delete': ['Alice'], - 'admin': ['Alice'] - } - }); - -Now only our current user can edit the annotations but anyone can view -them. - -Options -~~~~~~~ - -The options object allows you to completely define the way permissions -are handled for your site. - -- ``user``: The current user (required). -- ``permissions``: An object defining annotation permissions. -- ``userId``: A callback that returns the user id. -- ``userString``: A callback that returns the users name. -- ``userAuthorize``: A callback that allows custom authorisation. -- ``showViewPermissionsCheckbox``: If ``false`` hides the "Anyone can - view…" checkbox. -- ``showEditPermissionsCheckbox``: If ``false`` hides the "Anyone can - edit…" checkbox. - -user (required) -^^^^^^^^^^^^^^^ - -This value sets the current user and will be attached to all newly -created annotations. It can be as simple as a username string or if your -users objects are more complex an object literal. - -.. code:: javascript - - // Simple example. - annotator.addPlugin('Permissions', { - user: 'Alice' - }); - - // Complex example. - annotator.addPlugin('Permissions', { - user: { - id: 6, - username: 'Alice', - location: 'Brighton, UK' - } - }); - -If you do decide to use an object for your user as well as permissions -you'll need to also provide ``userId`` and ``userString`` callbacks. See -below for more information. - -permissions -^^^^^^^^^^^ - -Permissions set who is allowed to do what to your annotations. There are -four actions: - -- ``read``: Who can view the annotation -- ``update``: Who can edit the annotation -- ``delete``: Who can delete the annotation -- ``admin``: Who can change these permissions on the annotation - -Each action should be an array of tokens. An empty array means that -anyone can perform that action. Generally the token will just be the -users id. If you need something more complex (like groups) you can use -your own syntax and provide a ``userAuthorize`` callback with your -options. - -Here's a simple example of setting the permissions so that only the -current user can perform all actions: - -.. code:: javascript - - annotator.addPlugin('Permissions', { - user: 'Alice', - permissions: { - 'read': ['Alice'], - 'update': ['Alice'], - 'delete': ['Alice'], - 'admin': ['Alice'] - } - }); - -Or here is an example using numerical user ids: - -.. code:: javascript - - annotator.addPlugin('Permissions', { - user: {id: 6, name:'Alice'}, - permissions: { - 'read': [6], - 'update': [6], - 'delete': [6], - 'admin': [6] - } - }); - -userId(user) -^^^^^^^^^^^^ - -This is a callback that accepts a ``user`` parameter and returns the -identifier. By default this assumes you will be using strings for your -ids and simply returns the parameter. However if you are using a user -object you'll need to implement this: - -.. code:: javascript - - annotator.addPlugin('Permissions', { - user: {id: 6, name:'Alice'}, - userId: function (user) { - if (user && user.id) { - return user.id; - } - return user; - } - }); - // When called. - userId({id: 6, name:'Alice'}) // => Returns 6 - -NOTE: This function should handle ``null`` being passed as a parameter. -This is done when checking a globally editable annotation. - -userString(user) -^^^^^^^^^^^^^^^^ - -This is a callback that accepts a ``user`` parameter and returns the -human readable name for display. By default this assumes you will be -using a string to represent your users name and id so simply returns the -parameter. However if you are using a user object you'll need to -implement this: - -.. code:: javascript - - annotator.addPlugin('Permissions', { - user: {id: 6, name:'Alice'}, - userString: function (user) { - if (user && user.name) { - return user.name; - } - return user; - } - }); - // When called. - userString({id: 6, name:'Alice'}) // => Returns 'Alice' - -userAuthorize(action, annotation, user) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -This is another callback that allows you to implement your own -authorization logic. It receives three arguments: - -- ``action``: Action that is being checked, 'update', 'delete' or - 'admin'. 'create' does not call this callback -- ``annotation``: The entire annotation object; note that the - permissions subobject is at ``annotation.permissions`` -- ``user``: current user, as passed in to the permissions plugin - -Your function will check to see if the user can perform an action based -on these values. - -The default implementation assumes that the user is a simple string and -the tokens used (within ``annotation.permissions``) are also strings so -simply checks that the user is one of the tokens for the current action. - -.. code:: javascript - - // This is the default implementation as an example. - annotator.addPlugin('Permissions', { - user: 'Alice', - userAuthorize: function(action, annotation, user) { - var token, tokens, _i, _len; - if (annotation.permissions) { - tokens = annotation.permissions[action] || []; - if (tokens.length === 0) { - return true; - } - for (_i = 0, _len = tokens.length; _i < _len; _i++) { - token = tokens[_i]; - if (this.userId(user) === token) { - return true; - } - } - return false; - } else if (annotation.user) { - if (user) { - return this.userId(user) === this.userId(annotation.user); - } else { - return false; - } - } - return true; - }, - }); - // When called. - userAuthorize('update', aliceAnnotation, 'Alice') // => Returns true - userAuthorize('Alice', bobAnnotation, 'Bob') // => Returns false - -.. raw:: html - - - -A more complex example might involve you wanting to have a groups -property on your user object. If the user is a member of the 'Admin' -group they can perform any action on the annotation. - -// When called by a normal user. userAuthorize('update', -adminAnnotation, { id: 1, group: 'user' }) // => Returns false - -// When called by an admin. userAuthorize('update', adminAnnotation, { -id: 2, group: 'Admin' }) // => Returns true - -// When called by the owner. userAuthorize('update', regularAnnotation, -ownerOfRegularAnnotation) // => Returns true \`\`\` diff --git a/doc/plugins/store.rst b/doc/plugins/store.rst deleted file mode 100644 index 7834392f8..000000000 --- a/doc/plugins/store.rst +++ /dev/null @@ -1,172 +0,0 @@ -``Store`` plugin -================ - -This plugin sends annotations (serialised as JSON) to the server at key -events broadcast by the annotator. - -Actions -------- - -The following actions are performed by the annotator. - -- ``read``: GETs all annotations. Called when plugin loads or - ``.loadAnnotations()`` is called. Server should return an array of - annotations serialised as JSON. -- ``create``: POSTs an annotation (serialised as JSON) to the server. - Called when the annotator publishes the "annotationCreated" event. - The annotation is updated with any data (such as a newly created id) - returned from the server. -- ``update``: PUTs an annotation (serialised as JSON) on the server - under its id. Called when the annotator publishes the - "annotationUpdated" event. The annotation is updated with any data - (such as a newly created id) returned from the server. -- ``destroy``: Issues a DELETE request to server for the annotation. -- ``search``: GETs all annotations relevant to the query. Should return - a JSON object with a ``rows`` property containing an array of - annotations. - -Stores ------- - -For an example store check out our -`annotator-store `__ project on -GitHub which you can use or examine as the basis for your own store. If -you're looking to get up and running quickly then -`annotateit.org `__ will store your annotations -remotely under your account. - -Interface Overview ------------------- - -This plugin adds no additional UI to the Annotator but will display -error notifications if a request to the store fails. - -Usage ------ - -Adding the store plugin to the annotator is very simple. Simply add the -annotator to the page using the ``.annotator()`` jQuery plugin and -retrieve the annotator object using ``.data('annotator')``. Then add the -``Store`` plugin. - -.. code:: javascript - - var content = $('#content').annotator(); - content.annotator('addPlugin', 'Store', { - // The endpoint of the store on your server. - prefix: '/store/endpoint', - - // Attach the uri of the current page to all annotations to allow search. - annotationData: { - 'uri': 'http://this/document/only' - }, - - // This will perform a "search" action rather than "read" when the plugin - // loads. Will request the last 20 annotations for the current url. - // eg. /store/endpoint/search?limit=20&uri=http://this/document/only - loadFromSearch: { - 'limit': 20, - 'uri': 'http://this/document/only' - } - }); - -Options -~~~~~~~ - -The following options are made available for customisation of the store. - -- ``prefix``: The store endpoint. -- ``annotationData``: An object literal containing any data to attach - to the annotation on submission. -- ``loadFromSearch``: Search options for using the "search" action. -- ``urls``: Custom URL paths. -- ``showViewPermissionsCheckbox``: If ``true`` will display the "anyone - can view this annotation" checkbox. -- ``showEditPermissionsCheckbox``: If ``true`` will display the "anyone - can edit this annotation" checkbox. - -prefix -^^^^^^ - -This is the API endpoint. If the server supports Cross Origin Resource -Sharing (CORS) a full URL can be used here. Defaults to ``/store``. - -NOTE: The trailing slash should be omitted. - -Example: - -.. code:: javascript - - $('#content').annotator('addPlugin', 'Store', { - prefix: '/store/endpoint' - }); - -annotationData -^^^^^^^^^^^^^^ - -Custom meta data that will be attached to every annotation that is sent -to the server. This *will* override previous values. - -Example: - -.. code:: javascript - - $('#content').annotator('addPlugin', 'Store', { - // Attach a uri property to every annotation sent to the server. - annotationData: { - 'uri': 'http://this/document/only' - } - }); - -loadFromSearch -^^^^^^^^^^^^^^ - -An object literal containing query string parameters to query the store. -If ``loadFromSearch`` is set, then we load the first batch of -annotations from the 'search' URL as set in ``options.urls`` instead of -the registry path 'prefix/read'. Defaults to ``false``. - -Example: - -.. code:: javascript - - $('#content').annotator('addPlugin', 'Store', { - loadFromSearch: { - 'limit': 0, - 'all_fields': 1, - 'uri': 'http://this/document/only' - } - }); - -urls -^^^^ - -The server URLs for each available action (excluding ``prefix``). These -URLs can point anywhere but must respond to the appropriate HTTP method. -The ``:id`` token can be used anywhere in the URL and will be replaced -with the annotation id. - -Methods for actions are as follows: - -:: - - read: GET - create: POST - update: PUT - destroy: DELETE - search: GET - -Example: - -.. code:: javascript - - $('#content').annotator('addPlugin', 'Store', { - urls: { - // These are the default URLs. - create: '/annotations', - read: '/annotations/:id', - update: '/annotations/:id', - destroy: '/annotations/:id', - search: '/search' - } - }): diff --git a/doc/plugins/tags.rst b/doc/plugins/tags.rst deleted file mode 100644 index 18ad9542b..000000000 --- a/doc/plugins/tags.rst +++ /dev/null @@ -1,46 +0,0 @@ -``Tags`` plugin -=============== - -This plugin allows the user to tag their annotations with keywords. - -Interface Overview ------------------- - -The following elements are added to the Annotator interface by this -plugin. - -Viewer -^^^^^^ - -The plugin adds a section to a viewed annotation displaying any tags -that have been added. - -Editor -^^^^^^ - -The plugin adds an input field to the editor allowing the user to enter -a space separated list of tags. - -Usage ------ - -Adding the tags plugin to the annotator is very simple. Simply add the -annotator to the page using the ``.annotator()`` jQuery plugin. Then -call the ``.addPlugin()`` method by calling -``.annotator('addPlugin', 'Tags')``. - -.. code:: javascript - - var content = $('#content').annotator().annotator('addPlugin', 'Tags'); - -Options -~~~~~~~ - -*There are no options available for this plugin* - -Adding autocompletion of tags -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -See `this -example `__ -using jQueryUI autocomplete. diff --git a/doc/plugins/unsupported.rst b/doc/plugins/unsupported.rst deleted file mode 100644 index 2d3147a83..000000000 --- a/doc/plugins/unsupported.rst +++ /dev/null @@ -1,42 +0,0 @@ -``Unsupported`` plugin -====================== - -The Annotator only supports browsers that have the -``window.getSelection()`` method (for a table of support please see -`this Quirksmode -article `__). This -plugin provides a notification to users of these unsupported browsers -letting them know that the plugin has not loaded. - -Usage ------ - -Adding the unsupported plugin to the annotator is very simple. Simply -add the annotator to the page using the ``.annotator()`` jQuery plugin. -Then call the ``.addPlugin()`` method eg. -``.annotator('addPlugin', 'Unsupported')``. - -.. code:: javascript - - var content = $('#content').annotator(); - content.annotator('addPlugin', 'Unsupported'); - -Options -~~~~~~~ - -You can provide options - -- ``message``: A customised message that you wish to display to users. - -message -^^^^^^^ - -The message that you wish to display to users. - -.. code:: javascript - - var annotator = $('#content').annotator().data('annotator'); - - annotator.addPlugin('Unsupported', { - message: "We're sorry the Annotator is not supported by this browser" - }); diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 000000000..4df3b687d --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1 @@ +sphinxcontrib-httpdomain diff --git a/doc/roadmap.rst b/doc/roadmap.rst new file mode 100644 index 000000000..f4045f8cc --- /dev/null +++ b/doc/roadmap.rst @@ -0,0 +1,79 @@ +Annotator Roadmap +================= + +This document lays out the planned schedule and roadmap for the future +development of Annotator. + +For each release below, the planned features reflect what the core team intend +to work on, but are not an exhaustive list of what could be in the release. From +the release of Annotator 2.0 onwards, we will operate a time-based release +process, and any features merged by the relevant cutoff dates will be in the +release. + +.. note:: This is a living document. Nothing herein constitutes a guarantee that + a given Annotator release will contain a given feature, or that a + release will happen on a specified date. + +2.0 ++++ + +What will be in 2.0 +------------------- + +- Improved internal API +- UI component library (the UI was previously "baked in" to Annotator) +- Support (for most features) for Internet Explorer 8 and up +- Internal data model consistent with `Open Annotation`_ +- A (beta-quality) storage component that speaks OA JSON-LD +- Core code translated from CoffeeScript to JavaScript + +.. _Open Annotation: http://www.openannotation.org/ + +Schedule +-------- + +The following dates are subject to change as needed. + +================== ============================================ +April 25, 2015 Annotator 2.0 alpha; major feature freeze +August 1, 2015 Annotator 2.0 beta; complete feature freeze +September 15, 2015 Annotator 2.0 RC1; translation string freeze +2 weeks after RC1 Annotator 2.0 final (or RC2 if needed) +================== ============================================ + +The long period between a beta release and RC1 takes account of time for other +developers to test and report bugs. + + +2.1 ++++ + +The main goals for this release, which we aim to ship by Jan 1, 2016 (with a +major feature freeze on Nov 15): + +- Support for selections made using the keyboard +- Support in the core for annotation on touch devices +- Support for multiple typed selectors in annotations +- Support for components that resolve ('reanchor') an annotation's selectors + into a form suitable for display in the page + + +2.2 ++++ + +The main goals for this release, which we aim to ship by Apr 1, 2016 (with a +major feature freeze on Feb 15): + +- Support for annotation of additional media types (images, possibly video) in + the core + +2.3 ++++ + +The main goals for this release, which we aim to ship by Jul 1, 2016 (with a +major feature freeze on May 15): + +- Improved highlight rendering (faster, doesn't modify underlying DOM) +- Replace existing XPath-based selector code with Rangy_ + +.. _Rangy: https://github.com/timdown/rangy diff --git a/doc/storage.rst b/doc/storage.rst deleted file mode 100644 index 041e6c2cc..000000000 --- a/doc/storage.rst +++ /dev/null @@ -1,218 +0,0 @@ -Storage -======= - -Some kind of storage is needed to save your annotations after you leave -a web page. - -To do this you can use the :doc:`plugins/store` and a remote JSON API. This -page describes the API expected by the Store plugin, and implemented by -the `reference backend `__. It -is this backend that runs the `AnnotateIt `__ web -service. - -Core storage API ----------------- - -The storage API is defined in terms of a ``prefix`` and a number of -endpoints. It attempts to follow the principles of -`REST `__, -and emits JSON documents to be parsed by the Annotator. Each of the -following endpoints for the storage API is expected to be found on the -web at ``prefix`` + ``path``. For example, if the prefix were -``http://example.com/api``, then the **index** endpoint would be found -at ``http://example.com/api/annotations``. - -General rules are those common to most REST APIs. If a resource cannot -be found, return ``404 NOT FOUND``. If an action is not permitted for -the current user, return ``401 NOT AUTHORIZED``, otherwise return -``200 OK``. Send JSON text with the header -``Content-Type: application/json``. - -Below you can find details of the six core endpoints, **root**, -**index**, **create**, **read**, **update**, **delete**, as well as an -optional **search** API. - -.. raw:: html - -

- -WARNING: - -.. raw:: html - -

- -The spec below requires you return ``303 SEE OTHER`` from the **create** -and **update** endpoints. Ideally this *is* what you'd do, but -unfortunately most modern browsers (Firefox and Webkit) still make a -hash of CORS requests when they include redirects. A simple workaround -for the time being is to return ``200 OK`` and the JSON annotation that -*would* be returned by the **read** endpoint in the body of the -**create** and **update** responses. See bugs in -`Chromium `__ -and `Webkit `__. - -root -~~~~ - -- method: ``GET`` -- path: ``/`` -- returns: object containing store metadata, including API version - -Example: - -:: - - $ curl http://example.com/api/ - { - "name": "Annotator Store API", - "version": "2.0.0" - } - -index -~~~~~ - -- method: ``GET`` -- path: ``/annotations`` -- returns: a list of all annotation objects - -Example (see :doc:`annotation-format` for details of the format of -individual annotations): - -.. code:: json - - $ curl http://example.com/api/annotations - [ - { - "text": "Example annotation text", - "ranges": [ ... ], - ... - }, - { - "text": "Another annotation", - "ranges": [ ... ], - ... - }, - ... - ] - -create -~~~~~~ - -- method: ``POST`` -- path: ``/annotations`` -- receives: an annotation object, sent with - ``Content-Type: application/json`` -- returns: ``303 SEE OTHER`` redirect to the appropriate **read** - endpoint - -Example: - -:: - - $ curl -i -X POST \ - -H 'Content-Type: application/json' \ - -d '{"text": "Annotation text"}' \ - http://example.com/api/annotations - HTTP/1.0 303 SEE OTHER - Location: http://example.com/api/annotations/d41d8cd98f00b204e9800998ecf8427e - ... - -read -~~~~ - -- method: ``GET`` -- path: ``/annotations/`` -- returns: an annotation object - -Example: - -:: - - $ curl http://example.com/api/annotations/d41d8cd98f00b204e9800998ecf8427e - { - "id": "d41d8cd98f00b204e9800998ecf8427e", - "text": "Annotation text", - ... - } - -update -~~~~~~ - -- method: ``PUT`` -- path: ``/annotations/`` -- receives: a (partial) annotation object, sent with - ``Content-Type: application/json`` -- returns: ``303 SEE OTHER`` redirect to the appropriate **read** - endpoint - -Example: - -:: - - $ curl -i -X PUT \ - -H 'Content-Type: application/json' \ - -d '{"text": "Updated annotation text"}' \ - http://example.com/api/annotations/d41d8cd98f00b204e9800998ecf8427e - HTTP/1.0 303 SEE OTHER - Location: http://example.com/api/annotations/d41d8cd98f00b204e9800998ecf8427e - ... - -delete -~~~~~~ - -- method: ``DELETE`` -- path: ``/annotations/`` -- returns: ``204 NO CONTENT``, and -- obviously -- no content - -:: - - $ curl -i -X DELETE http://example.com/api/annotations/d41d8cd98f00b204e9800998ecf8427e - HTTP/1.0 204 NO CONTENT - Content-Length: 0 - -Search API ----------- - -You may also choose to implement a search API, which can be used by the -Store plugin's ``loadFromSearch`` configuration option. - -search -~~~~~~ - -- method: ``GET`` -- path: ``/search?text=foobar`` -- returns: an object with ``total`` and ``rows`` fields. ``total`` is - an integer denoting the *total* number of annotations matched by the - search, while ``rows`` is a list containing what might be a subset of - these annotations. -- If implemented, this method should also support the ``limit`` and - ``offset`` query parameters for paging through results. - -:: - - $ curl http://example.com/api/search?text=annotation - { - "total": 43127, - "rows": [ - { - "id": "d41d8cd98f00b204e9800998ecf8427e", - "text": "Updated annotation text", - ... - }, - ... - ] - } - -Storage Implementations ------------------------ - -- Reference backend, a Python Flask app: - https://github.com/okfn/annotator-store (in particular, see - `store.py `__, - although be aware that this file also deals with authentication and - authorization, making the code a good deal more complex than would be - required to implement what is described above). -- PHP (Silex) and MongoDB-based basic implementation: - https://github.com/julien-c/annotator-php (in particular, see - `index.php `__). diff --git a/doc/upgrading.rst b/doc/upgrading.rst new file mode 100644 index 000000000..77997cd05 --- /dev/null +++ b/doc/upgrading.rst @@ -0,0 +1,203 @@ +Upgrading guide +=============== + +Annotator 2.0 represents a substantial change from the 1.2 series, and +developers are advised to read this document before attempting to upgrade +existing installations. + +In addition, plugin authors will want to read this document in order to +understand how to update their plugins to work with the new Annotator. + +.. contents:: + + +Motivation +---------- + +The architecture of the first version of Annotator dates back to 2009, when the +Annotator application was developed to enable annotation in a project called +"Open Shakespeare". At the time, Annotator was designed primarily as a drop-in +annotation application, with only limited support for customization. + +Over several years, Annotator gained support for plugins that allowed +developers to customize and extend the behavior of the application. + +In order to ensure a stable platform for future development, we have made some +substantial changes to Annotator's architecture. Unfortunately, this means that +the upgrade from 1.2 to 2.0 will not always be painless. + +If you're very happy with Annotator 1.2 as it is now, you may wish to continue +continue using it until such time as the features added to the 2.x series +attract your interest. We'll continue to answer questions about 1.2. + +The target audience for Annotator 2.0 is those who have been frustrated by the +coupling and architecture of 1.2. If any of the following apply to you, +Annotator 2.0 should make you happier: + +- You work on an Annotator application that overrides part or all of the + default user interface. + +- You have made substantial modifications to the annotation viewer or editor + components. + +- You use a custom storage plugin. + +- You use a custom server-side storage component. + +- You integrate Annotator with your own user database. + +- You have a custom permissions model for your application. + +If you want to know what you'll need to do to upgrade your application or +plugins to work with Annotator 2.0, keep reading. + + +Upgrading an application +------------------------ + +The first step to understanding what you need to do to upgrade to 2.0 is to +identify which parts of Annotator 1.2 you use. Review the list below, which +attempts to catalogue Annotator 1.2 patterns and demonstrate the new patterns. + + +Basic usage +~~~~~~~~~~~ + +Annotator 1.2 shipped with a jQuery integration, allowing you to write code such +as:: + + $('body').annotator(); + +This has been removed in 2.0. Here's what you'd write now:: + + var app = new annotator.App(); + app.include(annotator.ui.main, {element: document.body}); + app.start(); + +This sets up an Annotator with a user interface. If you decide not to include +the ``annotator.ui.main`` module then your application will not have any of +the familiar user interface components. Instead, you can begin to construct +your own annotation application from those components assembled in a way that +best serves your needs. + + +Store plugin +~~~~~~~~~~~~ + +In Annotator 1.2, configuring storage looked like this:: + + annotator.addPlugin('Store', { + prefix: 'http://example.com/api', + loadFromSearch: { + uri: window.location.href, + }, + annotationData: { + uri: window.location.href, + } + }); + +This code is doing three distinct things: + +1. Load the "Store" plugin pointing to an API endpoint at + ``http://example.com/api``. +2. Make a request to the API with the query ``{uri: window.location.href}``. +3. Add extra data to each created annotation containing the page URL: ``{uri: + window.location.href}``. + +In Annotator 2.0 the configuration of the storage component +(:func:`annotator.storage.http`) is logically separate from a) the loading +of annotations from storage, and b) the extension of annotations with additional +data. An example that replicates the above behavior would look like this +in Annotator 2.0:: + + + var pageUri = function () { + return { + beforeAnnotationCreated: function (ann) { + ann.uri = window.location.href; + } + }; + }; + + var app = new annotator.App() + .include(annotator.ui.main, {element: elem}) + .include(annotator.storage.http, {prefix: 'http://example.com/api'}) + .include(pageUri); + + app.start() + .then(function () { + app.annotations.load({uri: window.location.href}); + }); + +We first create an Annotator extension module that sets the ``uri`` property +property on new annotations. Then we create and configure an +:class:`~annotator.App` that includes the :func:`annotator.storage.http` module. +Lastly, we start the application and load the annotations using the same query +as in the 1.2 example. + + +Auth plugin +~~~~~~~~~~~ + +The auth plugin, which in 1.2 retrieved an authentication token from an API +endpoint and set up the Store plugin, is not available for 2.0. See the +documentation for :data:`annotator.storage.HttpStorage.options` for configuring +the request headers directly according to your needs. + + + +Upgrading a plugin +------------------ + +The first thing to know about Annotator 2.0 is that we are retiring the use of +the word "plugin". Our documentation and code refers to a reusable piece of code +such as :func:`annotator.storage.http` as a :term:`module`. Modules are included +into an :class:`~annotator.App`, and are able to register providers of named +interfaces (such as "storage" or "notifier"), as well as providing runnable +:term:`hook` functions that are called at important moments. The lifecycle +events in Annotator 1.2 (``beforeAnnotationCreated``, ``annotationCreated``, +etc.) are still available as hooks, and it should be reasonably straightforward +to migrate plugins that simply respond to lifecycle events. + +The second important observation is that Annotator 2.0 is written in JavaScript, +not CoffeeScript. You may continue to write modules in any dialect you like, +but we hope that this change makes Annotator more accessible to the broader +JavaScript community and encourage you to consider doing the same in order to +promote collaboration. + +Lastly, writing an extension module is simpler and more idiomatic than writing a +plugin. Whereas Annotator 1.2 assumed that plugins were "subclasses" of +``Annotator.Plugin``, in Annotator 2.0 a module is a function that returns an +object containing hook functions. It is through these hook functions that +modules provide the bulk of their functionality. + +Upgrading a trivial plugin +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Here's an Annotator 1.2 plugin that logs to the console when started:: + + class Annotator.Plugin.HelloWorld extends Annotator.Plugin + pluginInit: -> + console.log("Hello, world!") + +Or, in JavaScript:: + + Annotator.Plugin.HelloWorld = function HelloWorld() { + Annotator.Plugin.call(this); + }; + Annotator.Plugin.HelloWorld.prototype = Object.create(Annotator.Plugin.prototype); + Annotator.Plugin.HelloWorld.prototype.pluginInit = function pluginInit() { + console.log("Hello, world!"); + }; + +Here's the equivalent module for Annotator 2.0:: + + function hello() { + return { + start: function () { + console.log("Hello, world!"); + } + }; + } + +For full documentation on writing modules, please see :doc:`module-development`. diff --git a/doc/usage.rst b/doc/usage.rst new file mode 100644 index 000000000..5409c9b2d --- /dev/null +++ b/doc/usage.rst @@ -0,0 +1,86 @@ +Configuring and using Annotator +=============================== + +This document assumes you have already downloaded and installed Annotator. +If you have not done so, please read :doc:`installing` before continuing. + +The basics +---------- + +.. |App| replace:: :class:`~annotator.App` + +When Annotator is loaded into the page, it exposes a single object, +``annotator``, which provides access to the main :class:`annotator.App` object +and all other included components. To use Annotator, you must configure and +start an |App|. At its simplest, that looks like this:: + + var app = new annotator.App(); + app.start(); + +You probably want to keep reading if you want your Annotator installation to be +useful straight away, as by default an |App| is extremely minimal. You can can +easily add functionality from an Annotator :term:`module`, an independent +components that you can load into your :term:`application`. For example, here +we create an |App| that uses the default Annotator user interface +(:func:`annotator.ui.main`), and the :func:`annotator.storage.http` storage +component in order to save annotations to a remote server:: + + var app = new annotator.App(); + app.include(annotator.ui.main); + app.include(annotator.storage.http); + app.start(); + +This is how most Annotator deployments will look: create an |App|, configure it +with :func:`~annotator.App.prototype.include`, and then run it using +:func:`~annotator.App.prototype.start`. + +If you want to do something (for example, load annotations from storage) when +the |App| has started, you can take advantage of the fact that +:func:`~annotator.App.prototype.start` returns a :term:`Promise`. Extending our +example above:: + + var app = new annotator.App(); + app.include(annotator.ui.main); + app.include(annotator.storage.http); + app + .start() + .then(function () { + app.annotations.load(); + }); + + +This example calls :func:`~annotator.storage.StorageAdapter.prototype.load` on +the ``annotations`` property of the |App|. This will load annotations from +whatever storage component you have configured. + +Most functionality in Annotator comes from these modules, so you should +familiarise yourself with what's available to you in order to make the most of +Annotator. Next we talk about how to configure modules when you add them to your +|App|. + + +Configuring modules +------------------- + +Once you have a basic Annotator application working, you can begin to customize +it. Some modules can be configured, and you can find out what options they +accept in the relevant :doc:`api/index`. + +For example, here are the options accepted by the :func:`annotator.storage.http` +module: :data:`annotator.storage.HttpStorage.options`. Let's say we have an +`annotator-store server`_ running at ``http://example.com/api``. We can +configure the :func:`~annotator.storage.http` module to address it like so:: + + app.include(annotator.storage.http, { + prefix: 'http://example.com/api' + }); + +.. _annotator-store server: https://github.com/openannotation/annotator-store + + +Writing modules +--------------- + +If you've looked through the available :doc:`modules` and haven't found what you +want, you can write your own module. Read more about that in +:doc:`module-development`. diff --git a/example/openshakespeare/index.html b/example/openshakespeare/index.html deleted file mode 100644 index c1d10b3ec..000000000 --- a/example/openshakespeare/index.html +++ /dev/null @@ -1,1485 +0,0 @@ - - - - - OpenShakespeare demo - - - - - - - - - - - - - -

OpenShakespeare annotation demo

- -
-

Controls

-

- -

-
- -
-

The Comedy of Errors

ACT I

SCENE I. A hall in DUKE SOLINUS'S palace.

- -

Enter DUKE SOLINUS, AEGEON, Gaoler, Officers, and other - Attendants

-
-

AEGEON

-

Proceed, Solinus, to procure my fall
And by the doom of death end woes and all.

-
-
-

DUKE SOLINUS

-

Merchant of Syracuse, plead no more;
I am not partial to infringe our laws:
The enmity and discord which of late
Sprung from the rancorous outrage of your duke
To merchants, our well-dealing countrymen,
Who wanting guilders to redeem their lives
Have seal'd his rigorous statutes with their bloods,
Excludes all pity from our threatening looks.
For, since the mortal and intestine jars
'Twixt thy seditious countrymen and us,
It hath in solemn synods been decreed
Both by the Syracusians and ourselves,
To admit no traffic to our adverse towns Nay, more,
If any born at Ephesus be seen
At any Syracusian marts and fairs;
Again: if any Syracusian born
Come to the bay of Ephesus, he dies,
His goods confiscate to the duke's dispose,
Unless a thousand marks be levied,
To quit the penalty and to ransom him.
Thy substance, valued at the highest rate,
Cannot amount unto a hundred marks;
Therefore by law thou art condemned to die.

- -
-
-

AEGEON

-

Yet this my comfort: when your words are done,
My woes end likewise with the evening sun.

-
-
-

DUKE SOLINUS

-

Well, Syracusian, say in brief the cause
Why thou departed'st from thy native home
And for what cause thou camest to Ephesus.

-
-
- -

AEGEON

-

A heavier task could not have been imposed
Than I to speak my griefs unspeakable:
Yet, that the world may witness that my end
Was wrought by nature, not by vile offence,
I'll utter what my sorrows give me leave.
In Syracusa was I born, and wed
Unto a woman, happy but for me,
And by me, had not our hap been bad.
With her I lived in joy; our wealth increased
By prosperous voyages I often made
To Epidamnum; till my factor's death
And the great care of goods at random left
Drew me from kind embracements of my spouse:
From whom my absence was not six months old
Before herself, almost at fainting under
The pleasing punishment that women bear,
Had made provision for her following me
And soon and safe arrived where I was.
There had she not been long, but she became
A joyful mother of two goodly sons;
And, which was strange, the one so like the other,
As could not be distinguish'd but by names.
That very hour, and in the self-same inn,
A meaner woman was delivered
Of such a burden, male twins, both alike:
Those,--for their parents were exceeding poor,--
I bought and brought up to attend my sons.
My wife, not meanly proud of two such boys,
Made daily motions for our home return:
Unwilling I agreed. Alas! too soon,
We came aboard.
A league from Epidamnum had we sail'd,
Before the always wind-obeying deep
Gave any tragic instance of our harm:
But longer did we not retain much hope;
For what obscured light the heavens did grant
Did but convey unto our fearful minds
A doubtful warrant of immediate death;
Which though myself would gladly have embraced,
Yet the incessant weepings of my wife,
Weeping before for what she saw must come,
And piteous plainings of the pretty babes,
That mourn'd for fashion, ignorant what to fear,
Forced me to seek delays for them and me.
And this it was, for other means was none:
The sailors sought for safety by our boat,
And left the ship, then sinking-ripe, to us:
My wife, more careful for the latter-born,
Had fasten'd him unto a small spare mast,
Such as seafaring men provide for storms;
To him one of the other twins was bound,
Whilst I had been like heedful of the other:
The children thus disposed, my wife and I,
Fixing our eyes on whom our care was fix'd,
Fasten'd ourselves at either end the mast;
And floating straight, obedient to the stream,
Was carried towards Corinth, as we thought.
At length the sun, gazing upon the earth,
Dispersed those vapours that offended us;
And by the benefit of his wished light,
The seas wax'd calm, and we discovered
Two ships from far making amain to us,
Of Corinth that, of Epidaurus this:
But ere they came,--O, let me say no more!
Gather the sequel by that went before.

- -
-
-

DUKE SOLINUS

-

Nay, forward, old man; do not break off so;
For we may pity, though not pardon thee.

-
-
-

AEGEON

-

O, had the gods done so, I had not now
Worthily term'd them merciless to us!
For, ere the ships could meet by twice five leagues,
We were encounterd by a mighty rock;
Which being violently borne upon,
Our helpful ship was splitted in the midst;
So that, in this unjust divorce of us,
Fortune had left to both of us alike
What to delight in, what to sorrow for.
Her part, poor soul! seeming as burdened
With lesser weight but not with lesser woe,
Was carried with more speed before the wind;
And in our sight they three were taken up
By fishermen of Corinth, as we thought.
At length, another ship had seized on us;
And, knowing whom it was their hap to save,
Gave healthful welcome to their shipwreck'd guests;
And would have reft the fishers of their prey,
Had not their bark been very slow of sail;
And therefore homeward did they bend their course.
Thus have you heard me sever'd from my bliss;
That by misfortunes was my life prolong'd,
To tell sad stories of my own mishaps.

- -
-
-

DUKE SOLINUS

-

And for the sake of them thou sorrowest for,
Do me the favour to dilate at full
What hath befall'n of them and thee till now.

-
-
-

AEGEON

-

My youngest boy, and yet my eldest care,
At eighteen years became inquisitive
After his brother: and importuned me
That his attendant--so his case was like,
Reft of his brother, but retain'd his name--
Might bear him company in the quest of him:
Whom whilst I labour'd of a love to see,
I hazarded the loss of whom I loved.
Five summers have I spent in furthest Greece,
Roaming clean through the bounds of Asia,
And, coasting homeward, came to Ephesus;
Hopeless to find, yet loath to leave unsought
Or that or any place that harbours men.
But here must end the story of my life;
And happy were I in my timely death,
Could all my travels warrant me they live.

- -
-
-

DUKE SOLINUS

-

Hapless AEgeon, whom the fates have mark'd
To bear the extremity of dire mishap!
Now, trust me, were it not against our laws,
Against my crown, my oath, my dignity,
Which princes, would they, may not disannul,
My soul would sue as advocate for thee.
But, though thou art adjudged to the death
And passed sentence may not be recall'd
But to our honour's great disparagement,
Yet I will favour thee in what I can.
Therefore, merchant, I'll limit thee this day
To seek thy life by beneficial help:
Try all the friends thou hast in Ephesus;
Beg thou, or borrow, to make up the sum,
And live; if no, then thou art doom'd to die.
Gaoler, take him to thy custody.

- -
-
-

Gaoler

-

I will, my lord.

-
-
-

AEGEON

-

Hopeless and helpless doth AEgeon wend,
But to procrastinate his lifeless end.

-
-

Exeunt

-

SCENE II. The Mart.

- -

Enter ANTIPHOLUS of Syracuse, DROMIO of Syracuse, - and First Merchant

-
-

First Merchant

-

Therefore give out you are of Epidamnum,
Lest that your goods too soon be confiscate.
This very day a Syracusian merchant
Is apprehended for arrival here;
And not being able to buy out his life
According to the statute of the town,
Dies ere the weary sun set in the west.
There is your money that I had to keep.

-
-
-

ANTIPHOLUS OF SYRACUSE

- -

Go bear it to the Centaur, where we host,
And stay there, Dromio, till I come to thee.
Within this hour it will be dinner-time:
Till that, I'll view the manners of the town,
Peruse the traders, gaze upon the buildings,
And then return and sleep within mine inn,
For with long travel I am stiff and weary.
Get thee away.

-
-
-

DROMIO OF SYRACUSE

-

Many a man would take you at your word,
And go indeed, having so good a mean.

-
- -

Exit

-
-

ANTIPHOLUS OF SYRACUSE

-

A trusty villain, sir, that very oft,
When I am dull with care and melancholy,
Lightens my humour with his merry jests.
What, will you walk with me about the town,
And then go to my inn and dine with me?

-
-
-

First Merchant

-

I am invited, sir, to certain merchants,
Of whom I hope to make much benefit;
I crave your pardon. Soon at five o'clock,
Please you, I'll meet with you upon the mart
And afterward consort you till bed-time:
My present business calls me from you now.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

Farewell till then: I will go lose myself
And wander up and down to view the city.

-
-
-

First Merchant

-

Sir, I commend you to your own content.

-
-

Exit

-
- -

ANTIPHOLUS OF SYRACUSE

-

He that commends me to mine own content
Commends me to the thing I cannot get.
I to the world am like a drop of water
That in the ocean seeks another drop,
Who, falling there to find his fellow forth,
Unseen, inquisitive, confounds himself:
So I, to find a mother and a brother,
In quest of them, unhappy, lose myself.
Here comes the almanac of my true date.
What now? how chance thou art return'd so soon?

-
-
-

DROMIO OF EPHESUS

- -

Return'd so soon! rather approach'd too late:
The capon burns, the pig falls from the spit,
The clock hath strucken twelve upon the bell;
My mistress made it one upon my cheek:
She is so hot because the meat is cold;
The meat is cold because you come not home;
You come not home because you have no stomach;
You have no stomach having broke your fast;
But we that know what 'tis to fast and pray
Are penitent for your default to-day.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Stop in your wind, sir: tell me this, I pray:
Where have you left the money that I gave you?

- -
-
-

DROMIO OF EPHESUS

-

O,--sixpence, that I had o' Wednesday last
To pay the saddler for my mistress' crupper?
The saddler had it, sir; I kept it not.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

I am not in a sportive humour now:
Tell me, and dally not, where is the money?
We being strangers here, how darest thou trust
So great a charge from thine own custody?

- -
-
-

DROMIO OF EPHESUS

-

I pray you, air, as you sit at dinner:
I from my mistress come to you in post;
If I return, I shall be post indeed,
For she will score your fault upon my pate.
Methinks your maw, like mine, should be your clock,
And strike you home without a messenger.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Come, Dromio, come, these jests are out of season;
Reserve them till a merrier hour than this.
Where is the gold I gave in charge to thee?

- -
-
-

DROMIO OF EPHESUS

-

To me, sir? why, you gave no gold to me.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Come on, sir knave, have done your foolishness,
And tell me how thou hast disposed thy charge.

-
-
-

DROMIO OF EPHESUS

- -

My charge was but to fetch you from the mart
Home to your house, the Phoenix, sir, to dinner:
My mistress and her sister stays for you.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

In what safe place you have bestow'd my money,
Or I shall break that merry sconce of yours
That stands on tricks when I am undisposed:
Where is the thousand marks thou hadst of me?

-
-
-

DROMIO OF EPHESUS

- -

I have some marks of yours upon my pate,
Some of my mistress' marks upon my shoulders,
But not a thousand marks between you both.
If I should pay your worship those again,
Perchance you will not bear them patiently.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Thy mistress' marks? what mistress, slave, hast thou?

-
-
-

DROMIO OF EPHESUS

-

Your worship's wife, my mistress at the Phoenix;
She that doth fast till you come home to dinner,
And prays that you will hie you home to dinner.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

What, wilt thou flout me thus unto my face,
Being forbid? There, take you that, sir knave.

-
-
-

DROMIO OF EPHESUS

-

What mean you, sir? for God's sake, hold your hands!
Nay, and you will not, sir, I'll take my heels.

-
-

Exit

- -
-

ANTIPHOLUS OF SYRACUSE

-

Upon my life, by some device or other
The villain is o'er-raught of all my money.
They say this town is full of cozenage,
As, nimble jugglers that deceive the eye,
Dark-working sorcerers that change the mind,
Soul-killing witches that deform the body,
Disguised cheaters, prating mountebanks,
And many such-like liberties of sin:
If it prove so, I will be gone the sooner.
I'll to the Centaur, to go seek this slave:
I greatly fear my money is not safe.

-
-

Exit

- -

ACT II

SCENE I. The house of ANTIPHOLUS of Ephesus.

-

Enter ADRIANA and LUCIANA

-
-

ADRIANA

-

Neither my husband nor the slave return'd,
That in such haste I sent to seek his master!
Sure, Luciana, it is two o'clock.

-
-
-

LUCIANA

-

Perhaps some merchant hath invited him,
And from the mart he's somewhere gone to dinner.
Good sister, let us dine and never fret:
A man is master of his liberty:
Time is their master, and, when they see time,
They'll go or come: if so, be patient, sister.

- -
-
-

ADRIANA

-

Why should their liberty than ours be more?

-
-
-

LUCIANA

-

Because their business still lies out o' door.

-
-
-

ADRIANA

-

Look, when I serve him so, he takes it ill.

- -
-
-

LUCIANA

-

O, know he is the bridle of your will.

-
-
-

ADRIANA

-

There's none but asses will be bridled so.

-
-
-

LUCIANA

-

Why, headstrong liberty is lash'd with woe.
There's nothing situate under heaven's eye
But hath his bound, in earth, in sea, in sky:
The beasts, the fishes, and the winged fowls,
Are their males' subjects and at their controls:
Men, more divine, the masters of all these,
Lords of the wide world and wild watery seas,
Indued with intellectual sense and souls,
Of more preeminence than fish and fowls,
Are masters to their females, and their lords:
Then let your will attend on their accords.

- -
-
-

ADRIANA

-

This servitude makes you to keep unwed.

-
-
-

LUCIANA

-

Not this, but troubles of the marriage-bed.

-
-
-

ADRIANA

-

But, were you wedded, you would bear some sway.

- -
-
-

LUCIANA

-

Ere I learn love, I'll practise to obey.

-
-
-

ADRIANA

-

How if your husband start some other where?

-
-
-

LUCIANA

-

Till he come home again, I would forbear.

- -
-
-

ADRIANA

-

Patience unmoved! no marvel though she pause;
They can be meek that have no other cause.
A wretched soul, bruised with adversity,
We bid be quiet when we hear it cry;
But were we burdened with like weight of pain,
As much or more would we ourselves complain:
So thou, that hast no unkind mate to grieve thee,
With urging helpless patience wouldst relieve me,
But, if thou live to see like right bereft,
This fool-begg'd patience in thee will be left.

-
-
- -

LUCIANA

-

Well, I will marry one day, but to try.
Here comes your man; now is your husband nigh.

-
-

Enter DROMIO of Ephesus

-
-

ADRIANA

-

Say, is your tardy master now at hand?

-
-
-

DROMIO OF EPHESUS

- -

Nay, he's at two hands with me, and that my two ears
can witness.

-
-
-

ADRIANA

-

Say, didst thou speak with him? know'st thou his mind?

-
-
-

DROMIO OF EPHESUS

-

Ay, ay, he told his mind upon mine ear:
Beshrew his hand, I scarce could understand it.

-
- -
-

LUCIANA

-

Spake he so doubtfully, thou couldst not feel his meaning?

-
-
-

DROMIO OF EPHESUS

-

Nay, he struck so plainly, I could too well feel his
blows; and withal so doubtfully that I could scarce
understand them.

-
-
-

ADRIANA

- -

But say, I prithee, is he coming home? It seems he
hath great care to please his wife.

-
-
-

DROMIO OF EPHESUS

-

Why, mistress, sure my master is horn-mad.

-
-
-

ADRIANA

-

Horn-mad, thou villain!

-
-
- -

DROMIO OF EPHESUS

-

I mean not cuckold-mad;
But, sure, he is stark mad.
When I desired him to come home to dinner,
He ask'd me for a thousand marks in gold:
''Tis dinner-time,' quoth I; 'My gold!' quoth he;
'Your meat doth burn,' quoth I; 'My gold!' quoth he:
'Will you come home?' quoth I; 'My gold!' quoth he.
'Where is the thousand marks I gave thee, villain?'
'The pig,' quoth I, 'is burn'd;' 'My gold!' quoth he:
'My mistress, sir' quoth I; 'Hang up thy mistress!
I know not thy mistress; out on thy mistress!'

-
-
-

LUCIANA

- -

Quoth who?

-
-
-

DROMIO OF EPHESUS

-

Quoth my master:
'I know,' quoth he, 'no house, no wife, no mistress.'
So that my errand, due unto my tongue,
I thank him, I bare home upon my shoulders;
For, in conclusion, he did beat me there.

-
-
-

ADRIANA

-

Go back again, thou slave, and fetch him home.

- -
-
-

DROMIO OF EPHESUS

-

Go back again, and be new beaten home?
For God's sake, send some other messenger.

-
-
-

ADRIANA

-

Back, slave, or I will break thy pate across.

-
-
-

DROMIO OF EPHESUS

- -

And he will bless that cross with other beating:
Between you I shall have a holy head.

-
-
-

ADRIANA

-

Hence, prating peasant! fetch thy master home.

-
-
-

DROMIO OF EPHESUS

-

Am I so round with you as you with me,
That like a football you do spurn me thus?
You spurn me hence, and he will spurn me hither:
If I last in this service, you must case me in leather.

- -
-

Exit

-
-

LUCIANA

-

Fie, how impatience loureth in your face!

-
-
-

ADRIANA

-

His company must do his minions grace,
Whilst I at home starve for a merry look.
Hath homely age the alluring beauty took
From my poor cheek? then he hath wasted it:
Are my discourses dull? barren my wit?
If voluble and sharp discourse be marr'd,
Unkindness blunts it more than marble hard:
Do their gay vestments his affections bait?
That's not my fault: he's master of my state:
What ruins are in me that can be found,
By him not ruin'd? then is he the ground
Of my defeatures. My decayed fair
A sunny look of his would soon repair
But, too unruly deer, he breaks the pale
And feeds from home; poor I am but his stale.

- -
-
-

LUCIANA

-

Self-harming jealousy! fie, beat it hence!

-
-
-

ADRIANA

-

Unfeeling fools can with such wrongs dispense.
I know his eye doth homage otherwhere,
Or else what lets it but he would be here?
Sister, you know he promised me a chain;
Would that alone, alone he would detain,
So he would keep fair quarter with his bed!
I see the jewel best enamelled
Will lose his beauty; yet the gold bides still,
That others touch, and often touching will
Wear gold: and no man that hath a name,
By falsehood and corruption doth it shame.
Since that my beauty cannot please his eye,
I'll weep what's left away, and weeping die.

- -
-
-

LUCIANA

-

How many fond fools serve mad jealousy!

-
-

Exeunt

-

SCENE II. A public place.

-

Enter ANTIPHOLUS of Syracuse

-
-

ANTIPHOLUS OF SYRACUSE

-

The gold I gave to Dromio is laid up
Safe at the Centaur; and the heedful slave
Is wander'd forth, in care to seek me out
By computation and mine host's report.
I could not speak with Dromio since at first
I sent him from the mart. See, here he comes.
How now sir! is your merry humour alter'd?
As you love strokes, so jest with me again.
You know no Centaur? you received no gold?
Your mistress sent to have me home to dinner?
My house was at the Phoenix? Wast thou mad,
That thus so madly thou didst answer me?

- -
-
-

DROMIO OF SYRACUSE

-

What answer, sir? when spake I such a word?

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Even now, even here, not half an hour since.

-
-
-

DROMIO OF SYRACUSE

-

I did not see you since you sent me hence,
Home to the Centaur, with the gold you gave me.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

Villain, thou didst deny the gold's receipt,
And told'st me of a mistress and a dinner;
For which, I hope, thou felt'st I was displeased.

-
-
-

DROMIO OF SYRACUSE

-

I am glad to see you in this merry vein:
What means this jest? I pray you, master, tell me.

-
-
- -

ANTIPHOLUS OF SYRACUSE

-

Yea, dost thou jeer and flout me in the teeth?
Think'st thou I jest? Hold, take thou that, and that.

-
-

Beating him

-
-

DROMIO OF SYRACUSE

-

Hold, sir, for God's sake! now your jest is earnest:
Upon what bargain do you give it me?

-
-
-

ANTIPHOLUS OF SYRACUSE

- -

Because that I familiarly sometimes
Do use you for my fool and chat with you,
Your sauciness will jest upon my love
And make a common of my serious hours.
When the sun shines let foolish gnats make sport,
But creep in crannies when he hides his beams.
If you will jest with me, know my aspect,
And fashion your demeanor to my looks,
Or I will beat this method in your sconce.

-
-
-

DROMIO OF SYRACUSE

-

Sconce call you it? so you would leave battering, I
had rather have it a head: an you use these blows
long, I must get a sconce for my head and ensconce
it too; or else I shall seek my wit in my shoulders.
But, I pray, sir why am I beaten?

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

Dost thou not know?

-
-
-

DROMIO OF SYRACUSE

-

Nothing, sir, but that I am beaten.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Shall I tell you why?

- -
-
-

DROMIO OF SYRACUSE

-

Ay, sir, and wherefore; for they say every why hath
a wherefore.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Why, first,--for flouting me; and then, wherefore--
For urging it the second time to me.

-
-
-

DROMIO OF SYRACUSE

- -

Was there ever any man thus beaten out of season,
When in the why and the wherefore is neither rhyme
nor reason?
Well, sir, I thank you.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Thank me, sir, for what?

-
-
-

DROMIO OF SYRACUSE

-

Marry, sir, for this something that you gave me for nothing.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

I'll make you amends next, to give you nothing for
something. But say, sir, is it dinner-time?

-
-
-

DROMIO OF SYRACUSE

-

No, sir; I think the meat wants that I have.

-
-
-

ANTIPHOLUS OF SYRACUSE

- -

In good time, sir; what's that?

-
-
-

DROMIO OF SYRACUSE

-

Basting.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Well, sir, then 'twill be dry.

-
-
-

DROMIO OF SYRACUSE

- -

If it be, sir, I pray you, eat none of it.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Your reason?

-
-
-

DROMIO OF SYRACUSE

-

Lest it make you choleric and purchase me another
dry basting.

-
-
- -

ANTIPHOLUS OF SYRACUSE

-

Well, sir, learn to jest in good time: there's a
time for all things.

-
-
-

DROMIO OF SYRACUSE

-

I durst have denied that, before you were so choleric.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

By what rule, sir?

- -
-
-

DROMIO OF SYRACUSE

-

Marry, sir, by a rule as plain as the plain bald
pate of father Time himself.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Let's hear it.

-
-
-

DROMIO OF SYRACUSE

- -

There's no time for a man to recover his hair that
grows bald by nature.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

May he not do it by fine and recovery?

-
-
-

DROMIO OF SYRACUSE

-

Yes, to pay a fine for a periwig and recover the
lost hair of another man.

-
- -
-

ANTIPHOLUS OF SYRACUSE

-

Why is Time such a niggard of hair, being, as it is,
so plentiful an excrement?

-
-
-

DROMIO OF SYRACUSE

-

Because it is a blessing that he bestows on beasts;
and what he hath scanted men in hair he hath given them in wit.

-
-
-

ANTIPHOLUS OF SYRACUSE

- -

Why, but there's many a man hath more hair than wit.

-
-
-

DROMIO OF SYRACUSE

-

Not a man of those but he hath the wit to lose his hair.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Why, thou didst conclude hairy men plain dealers without wit.

-
-
-

DROMIO OF SYRACUSE

- -

The plainer dealer, the sooner lost: yet he loseth
it in a kind of jollity.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

For what reason?

-
-
-

DROMIO OF SYRACUSE

-

For two; and sound ones too.

-
-
- -

ANTIPHOLUS OF SYRACUSE

-

Nay, not sound, I pray you.

-
-
-

DROMIO OF SYRACUSE

-

Sure ones, then.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Nay, not sure, in a thing falsing.

-
- -
-

DROMIO OF SYRACUSE

-

Certain ones then.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Name them.

-
-
-

DROMIO OF SYRACUSE

-

The one, to save the money that he spends in
trimming; the other, that at dinner they should not
drop in his porridge.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

You would all this time have proved there is no
time for all things.

-
-
-

DROMIO OF SYRACUSE

-

Marry, and did, sir; namely, no time to recover hair
lost by nature.

-
-
-

ANTIPHOLUS OF SYRACUSE

- -

But your reason was not substantial, why there is no
time to recover.

-
-
-

DROMIO OF SYRACUSE

-

Thus I mend it: Time himself is bald and therefore
to the world's end will have bald followers.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

I knew 'twould be a bald conclusion:
But, soft! who wafts us yonder?

- -
-

Enter ADRIANA and LUCIANA

-
-

ADRIANA

-

Ay, ay, Antipholus, look strange and frown:
Some other mistress hath thy sweet aspects;
I am not Adriana nor thy wife.
The time was once when thou unurged wouldst vow
That never words were music to thine ear,
That never object pleasing in thine eye,
That never touch well welcome to thy hand,
That never meat sweet-savor'd in thy taste,
Unless I spake, or look'd, or touch'd, or carved to thee.
How comes it now, my husband, O, how comes it,
That thou art thus estranged from thyself?
Thyself I call it, being strange to me,
That, undividable, incorporate,
Am better than thy dear self's better part.
Ah, do not tear away thyself from me!
For know, my love, as easy mayest thou fall
A drop of water in the breaking gulf,
And take unmingled that same drop again,
Without addition or diminishing,
As take from me thyself and not me too.
How dearly would it touch me to the quick,
Shouldst thou but hear I were licentious
And that this body, consecrate to thee,
By ruffian lust should be contaminate!
Wouldst thou not spit at me and spurn at me
And hurl the name of husband in my face
And tear the stain'd skin off my harlot-brow
And from my false hand cut the wedding-ring
And break it with a deep-divorcing vow?
I know thou canst; and therefore see thou do it.
I am possess'd with an adulterate blot;
My blood is mingled with the crime of lust:
For if we too be one and thou play false,
I do digest the poison of thy flesh,
Being strumpeted by thy contagion.
Keep then far league and truce with thy true bed;
I live unstain'd, thou undishonoured.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

Plead you to me, fair dame? I know you not:
In Ephesus I am but two hours old,
As strange unto your town as to your talk;
Who, every word by all my wit being scann'd,
Want wit in all one word to understand.

-
-
-

LUCIANA

-

Fie, brother! how the world is changed with you!
When were you wont to use my sister thus?
She sent for you by Dromio home to dinner.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

By Dromio?

-
-
-

DROMIO OF SYRACUSE

-

By me?

-
-
-

ADRIANA

-

By thee; and this thou didst return from him,
That he did buffet thee, and, in his blows,
Denied my house for his, me for his wife.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

Did you converse, sir, with this gentlewoman?
What is the course and drift of your compact?

-
-
-

DROMIO OF SYRACUSE

-

I, sir? I never saw her till this time.

-
-
-

ANTIPHOLUS OF SYRACUSE

- -

Villain, thou liest; for even her very words
Didst thou deliver to me on the mart.

-
-
-

DROMIO OF SYRACUSE

-

I never spake with her in all my life.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

How can she thus then call us by our names,
Unless it be by inspiration.

-
- -
-

ADRIANA

-

How ill agrees it with your gravity
To counterfeit thus grossly with your slave,
Abetting him to thwart me in my mood!
Be it my wrong you are from me exempt,
But wrong not that wrong with a more contempt.
Come, I will fasten on this sleeve of thine:
Thou art an elm, my husband, I a vine,
Whose weakness, married to thy stronger state,
Makes me with thy strength to communicate:
If aught possess thee from me, it is dross,
Usurping ivy, brier, or idle moss;
Who, all for want of pruning, with intrusion
Infect thy sap and live on thy confusion.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

To me she speaks; she moves me for her theme:
What, was I married to her in my dream?
Or sleep I now and think I hear all this?
What error drives our eyes and ears amiss?
Until I know this sure uncertainty,
I'll entertain the offer'd fallacy.

-
-
-

LUCIANA

-

Dromio, go bid the servants spread for dinner.

- -
-
-

DROMIO OF SYRACUSE

-

O, for my beads! I cross me for a sinner.
This is the fairy land: O spite of spites!
We talk with goblins, owls and sprites:
If we obey them not, this will ensue,
They'll suck our breath, or pinch us black and blue.

-
-
-

LUCIANA

-

Why pratest thou to thyself and answer'st not?
Dromio, thou drone, thou snail, thou slug, thou sot!

- -
-
-

DROMIO OF SYRACUSE

-

I am transformed, master, am I not?

-
-
-

ANTIPHOLUS OF SYRACUSE

-

I think thou art in mind, and so am I.

-
-
-

DROMIO OF SYRACUSE

-

Nay, master, both in mind and in my shape.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

Thou hast thine own form.

-
-
-

DROMIO OF SYRACUSE

-

No, I am an ape.

-
-
-

LUCIANA

-

If thou art changed to aught, 'tis to an ass.

- -
-
-

DROMIO OF SYRACUSE

-

'Tis true; she rides me and I long for grass.
'Tis so, I am an ass; else it could never be
But I should know her as well as she knows me.

-
-
-

ADRIANA

-

Come, come, no longer will I be a fool,
To put the finger in the eye and weep,
Whilst man and master laugh my woes to scorn.
Come, sir, to dinner. Dromio, keep the gate.
Husband, I'll dine above with you to-day
And shrive you of a thousand idle pranks.
Sirrah, if any ask you for your master,
Say he dines forth, and let no creature enter.
Come, sister. Dromio, play the porter well.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

Am I in earth, in heaven, or in hell?
Sleeping or waking? mad or well-advised?
Known unto these, and to myself disguised!
I'll say as they say and persever so,
And in this mist at all adventures go.

-
-
-

DROMIO OF SYRACUSE

-

Master, shall I be porter at the gate?

-
- -
-

ADRIANA

-

Ay; and let none enter, lest I break your pate.

-
-
-

LUCIANA

-

Come, come, Antipholus, we dine too late.

-
-

Exeunt

-

ACT III

SCENE I. Before the house of ANTIPHOLUS of Ephesus.

- -

Enter ANTIPHOLUS of Ephesus, DROMIO of Ephesus, - ANGELO, and BALTHAZAR

-
-

OF EPHESUS

-

Good Signior Angelo, you must excuse us all;
My wife is shrewish when I keep not hours:
Say that I linger'd with you at your shop
To see the making of her carcanet,
And that to-morrow you will bring it home.
But here's a villain that would face me down
He met me on the mart, and that I beat him,
And charged him with a thousand marks in gold,
And that I did deny my wife and house.
Thou drunkard, thou, what didst thou mean by this?

-
- -
-

DROMIO OF EPHESUS

-

Say what you will, sir, but I know what I know;
That you beat me at the mart, I have your hand to show:
If the skin were parchment, and the blows you gave were ink,
Your own handwriting would tell you what I think.

-
-
-

ANTIPHOLUS OF EPHESUS

-

I think thou art an ass.

-
-
-

DROMIO OF EPHESUS

- -

Marry, so it doth appear
By the wrongs I suffer and the blows I bear.
I should kick, being kick'd; and, being at that pass,
You would keep from my heels and beware of an ass.

-
-
-

ANTIPHOLUS OF EPHESUS

-

You're sad, Signior Balthazar: pray God our cheer
May answer my good will and your good welcome here.

-
-
-

BALTHAZAR

-

I hold your dainties cheap, sir, and your
welcome dear.

- -
-
-

ANTIPHOLUS OF EPHESUS

-

O, Signior Balthazar, either at flesh or fish,
A table full of welcome make scarce one dainty dish.

-
-
-

BALTHAZAR

-

Good meat, sir, is common; that every churl affords.

-
-
-

ANTIPHOLUS OF EPHESUS

- -

And welcome more common; for that's nothing but words.

-
-
-

BALTHAZAR

-

Small cheer and great welcome makes a merry feast.

-
-
-

ANTIPHOLUS OF EPHESUS

-

Ay, to a niggardly host, and more sparing guest:
But though my cates be mean, take them in good part;
Better cheer may you have, but not with better heart.
But, soft! my door is lock'd. Go bid them let us in.

- -
-
-

DROMIO OF EPHESUS

-

Maud, Bridget, Marian, Cicel, Gillian, Ginn!

-
-
-

DROMIO OF SYRACUSE

-

- [Within] - Mome, malt-horse, capon, coxcomb,
idiot, patch!
Either get thee from the door, or sit down at the hatch.
Dost thou conjure for wenches, that thou call'st
for such store,
When one is one too many? Go, get thee from the door.

- -
-
-

DROMIO OF EPHESUS

-

What patch is made our porter? My master stays in
the street.

-
-
-

DROMIO OF SYRACUSE

-

- [Within] - Let him walk from whence he came, lest he
catch cold on's feet.

- -
-
-

ANTIPHOLUS OF EPHESUS

-

Who talks within there? ho, open the door!

-
-
-

DROMIO OF SYRACUSE

-

- [Within] - Right, sir; I'll tell you when, an you tell
me wherefore.

- -
-
-

ANTIPHOLUS OF EPHESUS

-

Wherefore? for my dinner: I have not dined to-day.

-
-
-

DROMIO OF SYRACUSE

-

- [Within] - Nor to-day here you must not; come again
when you may.

- -
-
-

ANTIPHOLUS OF EPHESUS

-

What art thou that keepest me out from the house I owe?

-
-
-

DROMIO OF SYRACUSE

-

- [Within] - The porter for this time, sir, and my name
is Dromio.

- -
-
-

DROMIO OF EPHESUS

-

O villain! thou hast stolen both mine office and my name.
The one ne'er got me credit, the other mickle blame.
If thou hadst been Dromio to-day in my place,
Thou wouldst have changed thy face for a name or thy
name for an ass.

-
-
-

LUCE

-

- [Within] - What a coil is there, Dromio? who are those
at the gate?

- -
-
-

DROMIO OF EPHESUS

-

Let my master in, Luce.

-
-
-

LUCE

-

- [Within] - Faith, no; he comes too late;
And so tell your master.

- -
-
-

DROMIO OF EPHESUS

-

O Lord, I must laugh!
Have at you with a proverb--Shall I set in my staff?

-
-
-

LUCE

-

- [Within] - Have at you with another; that's--When?
can you tell?

- -
-
-

DROMIO OF SYRACUSE

-

- [Within] - If thy name be call'd Luce--Luce, thou hast
answered him well.

-
-
-

ANTIPHOLUS OF EPHESUS

-

Do you hear, you minion? you'll let us in, I hope?

- -
-
-

LUCE

-

- [Within] - I thought to have asked you.

-
-
-

DROMIO OF SYRACUSE

-

- [Within] - And you said no.

- -
-
-

DROMIO OF EPHESUS

-

So, come, help: well struck! there was blow for blow.

-
-
-

ANTIPHOLUS OF EPHESUS

-

Thou baggage, let me in.

-
-
-

LUCE

-

- - [Within] - Can you tell for whose sake?

-
-
-

DROMIO OF EPHESUS

-

Master, knock the door hard.

-
-
-

LUCE

-

- [Within] - Let him knock till it ache.

- -
-
-

ANTIPHOLUS OF EPHESUS

-

You'll cry for this, minion, if I beat the door down.

-
-
-

LUCE

-

- [Within] - What needs all that, and a pair of stocks in the town?

-
- -
-

ADRIANA

-

- [Within] - Who is that at the door that keeps all
this noise?

-
-
-

DROMIO OF SYRACUSE

-

- [Within] - By my troth, your town is troubled with
unruly boys.

- -
-
-

ANTIPHOLUS OF EPHESUS

-

Are you there, wife? you might have come before.

-
-
-

ADRIANA

-

- [Within] - Your wife, sir knave! go get you from the door.

-
- -
-

DROMIO OF EPHESUS

-

If you went in pain, master, this 'knave' would go sore.

-
-
-

ANGELO

-

Here is neither cheer, sir, nor welcome: we would
fain have either.

-
-
-

BALTHAZAR

-

In debating which was best, we shall part with neither.

- -
-
-

DROMIO OF EPHESUS

-

They stand at the door, master; bid them welcome hither.

-
-
-

ANTIPHOLUS OF EPHESUS

-

There is something in the wind, that we cannot get in.

-
-
-

DROMIO OF EPHESUS

-

You would say so, master, if your garments were thin.
Your cake there is warm within; you stand here in the cold:
It would make a man mad as a buck, to be so bought and sold.

- -
-
-

ANTIPHOLUS OF EPHESUS

-

Go fetch me something: I'll break ope the gate.

-
-
-

DROMIO OF SYRACUSE

-

- [Within] - Break any breaking here, and I'll break your
knave's pate.

- -
-
-

DROMIO OF EPHESUS

-

A man may break a word with you, sir, and words are but wind,
Ay, and break it in your face, so he break it not behind.

-
-
-

DROMIO OF SYRACUSE

-

- [Within] - It seems thou want'st breaking: out upon
thee, hind!

- -
-
-

DROMIO OF EPHESUS

-

Here's too much 'out upon thee!' I pray thee,
let me in.

-
-
-

DROMIO OF SYRACUSE

-

- [Within] - Ay, when fowls have no feathers and fish have no fin.

- -
-
-

ANTIPHOLUS OF EPHESUS

-

Well, I'll break in: go borrow me a crow.

-
-
-

DROMIO OF EPHESUS

-

A crow without feather? Master, mean you so?
For a fish without a fin, there's a fowl without a feather;
If a crow help us in, sirrah, we'll pluck a crow together.

-
-
-

ANTIPHOLUS OF EPHESUS

- -

Go get thee gone; fetch me an iron crow.

-
-
-

BALTHAZAR

-

Have patience, sir; O, let it not be so!
Herein you war against your reputation
And draw within the compass of suspect
The unviolated honour of your wife.
Once this,--your long experience of her wisdom,
Her sober virtue, years and modesty,
Plead on her part some cause to you unknown:
And doubt not, sir, but she will well excuse
Why at this time the doors are made against you.
Be ruled by me: depart in patience,
And let us to the Tiger all to dinner,
And about evening come yourself alone
To know the reason of this strange restraint.
If by strong hand you offer to break in
Now in the stirring passage of the day,
A vulgar comment will be made of it,
And that supposed by the common rout
Against your yet ungalled estimation
That may with foul intrusion enter in
And dwell upon your grave when you are dead;
For slander lives upon succession,
For ever housed where it gets possession.

- -
-
-

ANTIPHOLUS OF EPHESUS

-

You have prevailed: I will depart in quiet,
And, in despite of mirth, mean to be merry.
I know a wench of excellent discourse,
Pretty and witty; wild, and yet, too, gentle:
There will we dine. This woman that I mean,
My wife--but, I protest, without desert--
Hath oftentimes upbraided me withal:
To her will we to dinner.
Get you home
And fetch the chain; by this I know 'tis made:
Bring it, I pray you, to the Porpentine;
For there's the house: that chain will I bestow--
Be it for nothing but to spite my wife--
Upon mine hostess there: good sir, make haste.
Since mine own doors refuse to entertain me,
I'll knock elsewhere, to see if they'll disdain me.

- -
-
-

ANGELO

-

I'll meet you at that place some hour hence.

-
-
-

ANTIPHOLUS OF EPHESUS

-

Do so. This jest shall cost me some expense.

-
-

Exeunt

-

SCENE II. The same.

- -

Enter LUCIANA and ANTIPHOLUS of Syracuse

-
-

LUCIANA

-

And may it be that you have quite forgot
A husband's office? shall, Antipholus.
Even in the spring of love, thy love-springs rot?
Shall love, in building, grow so ruinous?
If you did wed my sister for her wealth,
Then for her wealth's sake use her with more kindness:
Or if you like elsewhere, do it by stealth;
Muffle your false love with some show of blindness:
Let not my sister read it in your eye;
Be not thy tongue thy own shame's orator;
Look sweet, be fair, become disloyalty;
Apparel vice like virtue's harbinger;
Bear a fair presence, though your heart be tainted;
Teach sin the carriage of a holy saint;
Be secret-false: what need she be acquainted?
What simple thief brags of his own attaint?
'Tis double wrong, to truant with your bed
And let her read it in thy looks at board:
Shame hath a bastard fame, well managed;
Ill deeds are doubled with an evil word.
Alas, poor women! make us but believe,
Being compact of credit, that you love us;
Though others have the arm, show us the sleeve;
We in your motion turn and you may move us.
Then, gentle brother, get you in again;
Comfort my sister, cheer her, call her wife:
'Tis holy sport to be a little vain,
When the sweet breath of flattery conquers strife.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

Sweet mistress--what your name is else, I know not,
Nor by what wonder you do hit of mine,--
Less in your knowledge and your grace you show not
Than our earth's wonder, more than earth divine.
Teach me, dear creature, how to think and speak;
Lay open to my earthy-gross conceit,
Smother'd in errors, feeble, shallow, weak,
The folded meaning of your words' deceit.
Against my soul's pure truth why labour you
To make it wander in an unknown field?
Are you a god? would you create me new?
Transform me then, and to your power I'll yield.
But if that I am I, then well I know
Your weeping sister is no wife of mine,
Nor to her bed no homage do I owe
Far more, far more to you do I decline.
O, train me not, sweet mermaid, with thy note,
To drown me in thy sister's flood of tears:
Sing, siren, for thyself and I will dote:
Spread o'er the silver waves thy golden hairs,
And as a bed I'll take them and there lie,
And in that glorious supposition think
He gains by death that hath such means to die:
Let Love, being light, be drowned if she sink!

- -
-
-

LUCIANA

-

What, are you mad, that you do reason so?

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Not mad, but mated; how, I do not know.

-
-
-

LUCIANA

-

It is a fault that springeth from your eye.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

For gazing on your beams, fair sun, being by.

-
-
-

LUCIANA

-

Gaze where you should, and that will clear your sight.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

As good to wink, sweet love, as look on night.

- -
-
-

LUCIANA

-

Why call you me love? call my sister so.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Thy sister's sister.

-
-
-

LUCIANA

-

That's my sister.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

No;
It is thyself, mine own self's better part,
Mine eye's clear eye, my dear heart's dearer heart,
My food, my fortune and my sweet hope's aim,
My sole earth's heaven and my heaven's claim.

-
-
-

LUCIANA

-

All this my sister is, or else should be.

-
- -
-

ANTIPHOLUS OF SYRACUSE

-

Call thyself sister, sweet, for I am thee.
Thee will I love and with thee lead my life:
Thou hast no husband yet nor I no wife.
Give me thy hand.

-
-
-

LUCIANA

-

O, soft, air! hold you still:
I'll fetch my sister, to get her good will.

-
-

Exit

- -

Enter DROMIO of Syracuse

-
-

ANTIPHOLUS OF SYRACUSE

-

Why, how now, Dromio! where runn'st thou so fast?

-
-
-

DROMIO OF SYRACUSE

-

Do you know me, sir? am I Dromio? am I your man?
am I myself?

-
-
-

ANTIPHOLUS OF SYRACUSE

- -

Thou art Dromio, thou art my man, thou art thyself.

-
-
-

DROMIO OF SYRACUSE

-

I am an ass, I am a woman's man and besides myself.

-
-
-

DROMIO OF SYRACUSE

-

Marry, sir, besides myself, I am due to a woman; one
that claims me, one that haunts me, one that will have me.

-
-
- -

ANTIPHOLUS OF SYRACUSE

-

What claim lays she to thee?

-
-
-

DROMIO OF SYRACUSE

-

Marry sir, such claim as you would lay to your
horse; and she would have me as a beast: not that, I
being a beast, she would have me; but that she,
being a very beastly creature, lays claim to me.

-
-
-

ANTIPHOLUS OF SYRACUSE

- -

What is she?

-
-
-

DROMIO OF SYRACUSE

-

A very reverent body; ay, such a one as a man may
not speak of without he say 'Sir-reverence.' I have
but lean luck in the match, and yet is she a
wondrous fat marriage.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

How dost thou mean a fat marriage?

- -
-
-

DROMIO OF SYRACUSE

-

Marry, sir, she's the kitchen wench and all grease;
and I know not what use to put her to but to make a
lamp of her and run from her by her own light. I
warrant, her rags and the tallow in them will burn a
Poland winter: if she lives till doomsday,
she'll burn a week longer than the whole world.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

What complexion is she of?

- -
-
-

DROMIO OF SYRACUSE

-

Swart, like my shoe, but her face nothing half so
clean kept: for why, she sweats; a man may go over
shoes in the grime of it.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

That's a fault that water will mend.

-
-
-

DROMIO OF SYRACUSE

- -

No, sir, 'tis in grain; Noah's flood could not do it.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

What's her name?

-
-
-

DROMIO OF SYRACUSE

-

Nell, sir; but her name and three quarters, that's
an ell and three quarters, will not measure her from
hip to hip.

-
- -
-

ANTIPHOLUS OF SYRACUSE

-

Then she bears some breadth?

-
-
-

DROMIO OF SYRACUSE

-

No longer from head to foot than from hip to hip:
she is spherical, like a globe; I could find out
countries in her.

-
-
-

ANTIPHOLUS OF SYRACUSE

- -

In what part of her body stands Ireland?

-
-
-

DROMIO OF SYRACUSE

-

Marry, in her buttocks: I found it out by the bogs.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Where Scotland?

-
-
-

DROMIO OF SYRACUSE

- -

I found it by the barrenness; hard in the palm of the hand.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Where France?

-
-
-

DROMIO OF SYRACUSE

-

In her forehead; armed and reverted, making war
against her heir.

-
-
- -

ANTIPHOLUS OF SYRACUSE

-

Where England?

-
-
-

DROMIO OF SYRACUSE

-

I looked for the chalky cliffs, but I could find no
whiteness in them; but I guess it stood in her chin,
by the salt rheum that ran between France and it.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Where Spain?

- -
-
-

DROMIO OF SYRACUSE

-

Faith, I saw it not; but I felt it hot in her breath.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Where America, the Indies?

-
-
-

DROMIO OF SYRACUSE

-

Oh, sir, upon her nose all o'er embellished with
rubies, carbuncles, sapphires, declining their rich
aspect to the hot breath of Spain; who sent whole
armadoes of caracks to be ballast at her nose.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

Where stood Belgia, the Netherlands?

-
-
-

DROMIO OF SYRACUSE

-

Oh, sir, I did not look so low. To conclude, this
drudge, or diviner, laid claim to me, call'd me
Dromio; swore I was assured to her; told me what
privy marks I had about me, as, the mark of my
shoulder, the mole in my neck, the great wart on my
left arm, that I amazed ran from her as a witch:
And, I think, if my breast had not been made of
faith and my heart of steel,
She had transform'd me to a curtal dog and made
me turn i' the wheel.

- -
-
-

ANTIPHOLUS OF SYRACUSE

-

Go hie thee presently, post to the road:
An if the wind blow any way from shore,
I will not harbour in this town to-night:
If any bark put forth, come to the mart,
Where I will walk till thou return to me.
If every one knows us and we know none,
'Tis time, I think, to trudge, pack and be gone.

-
-
-

DROMIO OF SYRACUSE

-

As from a bear a man would run for life,
So fly I from her that would be my wife.

- -
-

Exit

-
-

ANTIPHOLUS OF SYRACUSE

-

There's none but witches do inhabit here;
And therefore 'tis high time that I were hence.
She that doth call me husband, even my soul
Doth for a wife abhor. But her fair sister,
Possess'd with such a gentle sovereign grace,
Of such enchanting presence and discourse,
Hath almost made me traitor to myself:
But, lest myself be guilty to self-wrong,
I'll stop mine ears against the mermaid's song.

-
- -

Enter ANGELO with the chain

-
-

ANGELO

-

Master Antipholus,--

-
-
-

ANTIPHOLUS OF SYRACUSE

-

Ay, that's my name.

-
-
-

ANGELO

- -

I know it well, sir, lo, here is the chain.
I thought to have ta'en you at the Porpentine:
The chain unfinish'd made me stay thus long.

-
-
-

ANTIPHOLUS OF SYRACUSE

-

What is your will that I shall do with this?

-
-
-

ANGELO

-

What please yourself, sir: I have made it for you.

-
- -
-

ANTIPHOLUS OF SYRACUSE

-

Made it for me, sir! I bespoke it not.

-
-
-

ANGELO

-

Not once, nor twice, but twenty times you have.
Go home with it and please your wife withal;
And soon at supper-time I'll visit you
And then receive my money for the chain.

-
-
-

ANTIPHOLUS OF SYRACUSE

- -

I pray you, sir, receive the money now,
For fear you ne'er see chain nor money more.

-
-
-

ANGELO

-

You are a merry man, sir: fare you well.

-
-

Exit

-
-

ANTIPHOLUS OF SYRACUSE

-

What I should think of this, I cannot tell:
But this I think, there's no man is so vain
That would refuse so fair an offer'd chain.
I see a man here needs not live by shifts,
When in the streets he meets such golden gifts.
I'll to the mart, and there for Dromio stay
If any ship put out, then straight away.

- -
-

Exit

-

ACT IV

SCENE I. A public place.

-

Enter Second Merchant, ANGELO, and an Officer

-
-

Second Merchant

-

You know since Pentecost the sum is due,
And since I have not much importuned you;
Nor now I had not, but that I am bound
To Persia, and want guilders for my voyage:
Therefore make present satisfaction,
Or I'll attach you by this officer.

- -
-
-

ANGELO

-

Even just the sum that I do owe to you
Is growing to me by Antipholus,
And in the instant that I met with you
He had of me a chain: at five o'clock
I shall receive the money for the same.
Pleaseth you walk with me down to his house,
I will discharge my bond and thank you too.

-
-

Enter ANTIPHOLUS of Ephesus and DROMIO of Ephesus - from the courtezan's

-
-

Officer

- -

That labour may you save: see where he comes.

-
-
-

ANTIPHOLUS OF EPHESUS

-

While I go to the goldsmith's house, go thou
And buy a rope's end: that will I bestow
Among my wife and her confederates,
For locking me out of my doors by day.
But, soft! I see the goldsmith. Get thee gone;
Buy thou a rope and bring it home to me.

-
-
-

DROMIO OF EPHESUS

- -

I buy a thousand pound a year: I buy a rope.

-
-

Exit

-
-

ANTIPHOLUS OF EPHESUS

-

A man is well holp up that trusts to you:
I promised your presence and the chain;
But neither chain nor goldsmith came to me.
Belike you thought our love would last too long,
If it were chain'd together, and therefore came not.

-
-
-

ANGELO

- -

Saving your merry humour, here's the note
How much your chain weighs to the utmost carat,
The fineness of the gold and chargeful fashion.
Which doth amount to three odd ducats more
Than I stand debted to this gentleman:
I pray you, see him presently discharged,
For he is bound to sea and stays but for it.

-
-
-

ANTIPHOLUS OF EPHESUS

-

I am not furnish'd with the present money;
Besides, I have some business in the town.
Good signior, take the stranger to my house
And with you take the chain and bid my wife
Disburse the sum on the receipt thereof:
Perchance I will be there as soon as you.

- -
-
-

ANGELO

-

Then you will bring the chain to her yourself?

-
-
-

ANTIPHOLUS OF EPHESUS

-

No; bear it with you, lest I come not time enough.

-
-
-

ANGELO

-

Well, sir, I will. Have you the chain about you?

- -
-
-

ANTIPHOLUS OF EPHESUS

-

An if I have not, sir, I hope you have;
Or else you may return without your money.

-
-
-

ANGELO

-

Nay, come, I pray you, sir, give me the chain:
Both wind and tide stays for this gentleman,
And I, to blame, have held him here too long.

-
-
- -

ANTIPHOLUS OF EPHESUS

-

Good Lord! you use this dalliance to excuse
Your breach of promise to the Porpentine.
I should have chid you for not bringing it,
But, like a shrew, you first begin to brawl.

-
-
-

Second Merchant

-

The hour steals on; I pray you, sir, dispatch.

-
-
-

ANGELO

- -

You hear how he importunes me;--the chain!

-
-
-

ANTIPHOLUS OF EPHESUS

-

Why, give it to my wife and fetch your money.

-
-
-

ANGELO

-

Come, come, you know I gave it you even now.
Either send the chain or send me by some token.

-
-
- -

ANTIPHOLUS OF EPHESUS

-

Fie, now you run this humour out of breath,
where's the chain? I pray you, let me see it.

-
-
-

Second Merchant

-

My business cannot brook this dalliance.
Good sir, say whether you'll answer me or no:
If not, I'll leave him to the officer.

-
-
-

ANTIPHOLUS OF EPHESUS

- -

I answer you! what should I answer you?

-
-
-

ANGELO

-

The money that you owe me for the chain.

-
-
-

ANTIPHOLUS OF EPHESUS

-

I owe you none till I receive the chain.

-
-
-

ANGELO

- -

You know I gave it you half an hour since.

-
-
-

ANTIPHOLUS OF EPHESUS

-

You gave me none: you wrong me much to say so.

-
-
-

ANGELO

-

You wrong me more, sir, in denying it:
Consider how it stands upon my credit.

-
-
- -

Second Merchant

-

Well, officer, arrest him at my suit.

-
-
-

Officer

-

I do; and charge you in the duke's name to obey me.

-
-
- - - - - - - - diff --git a/example/openshakespeare/openshakespeare.css b/example/openshakespeare/openshakespeare.css deleted file mode 100644 index 8b964d4aa..000000000 --- a/example/openshakespeare/openshakespeare.css +++ /dev/null @@ -1,69 +0,0 @@ -body { - font-family: "Helvetica Neue", Helvetica, sans-serif; - background: #eee; - color: #222; -} - -h1 { - font-size: 1.8em; - margin: 1em 50px; -} - -/* OS-specific annotation styling */ - -.annotator-hl { - background: rgba(200, 0, 0, 0.3); -} - -#controls { - margin: 0; - padding: 0 1em; - width: 18em; - position: fixed; - top: 2em; - right: 2em; - background: #fff; - border-radius: 10px; - -moz-border-radius: 10px; - -webkit-border-radius: 10px; -} - -#text-to-annotate { - font-size: 1.2em; - margin: 2em 50px; - padding: 1em 2em; - width: 26em; - font-family: Garamond, Georgia, serif; - background: #F8E5CB; - border: 1px solid #D8A56E; - border-top-color: #FDFDBE; - border-left-color: #FDFDBE; - box-shadow: 5px 5px 25px rgba(0,0,0,0.5); - -webkit-box-shadow: 5px 5px 25px rgba(0,0,0,0.5); - -moz-box-shadow: 5px 5px 25px rgba(0,0,0,0.5); -} - -/* Some styling for script */ - -.shkspr-act-title { - margin-top: 2em; -} - -.shkspr-scene-title { - margin-top: 1.5em; -} - -.shkspr-speech-speaker { -} - -p.shkspr-speech-body { - margin-left: 1em; -} - -.shkspr-stagedir { - font-style: italic; -} - -.shkspr-stagedir-inline { - font-style: italic; -} diff --git a/example/openshakespeare/openshakespeare.js b/example/openshakespeare/openshakespeare.js deleted file mode 100644 index 538562285..000000000 --- a/example/openshakespeare/openshakespeare.js +++ /dev/null @@ -1,28 +0,0 @@ -OpenShakespeare = ("OpenShakespeare" in window) ? OpenShakespeare : {} - -OpenShakespeare.Annotator = function (element) { - var $ = jQuery, self = this - - this.annotator = $(element).annotator().data('annotator') - this.currentUser = null - - this.options = { - user: { }, - - store: { - prefix: 'http://localhost:5000/store' - } - } - - // Init - ;(function () { - self.annotator.addPlugin("Permissions", self.options.user) - self.annotator.addPlugin("Store", self.options.store) - })() - - this.setCurrentUser = function (user) { - self.annotator.plugins["Permissions"].setUser(user) - } - - return this -} diff --git a/index.js b/index.js index 2b2f653fe..79c84fdd2 100644 --- a/index.js +++ b/index.js @@ -1,34 +1 @@ -var fs = require('fs'); -var path = require('path'); -var through = require('through'); - - -/** - * Populate the Annotator namespace by require()'ing any exposed plugins. - * - * Given a browserify bundle transform any module exposed as 'annotator' - * by appending `require()` calls all other exposed modules in the bundle. - * - * @param {object} b - A browserify bundle. - */ -module.exports = function (b) { - if (b[__filename]) return b; - else b[__filename] = true; - - return b - .transform(function (file) { - if (b._mapped['annotator'] == file) { - return through(null, function () { - this.queue('\n'); - for (var m in b._mapped) { - if (m == 'annotator') continue; - this.queue("require('" + m + "');\n"); - } - this.queue(null); - }); - } else { - return through(); - } - }) - ; -} +module.exports = require('./src/app'); diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 000000000..cc9896d5a --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,98 @@ +module.exports = function (karma) { + karma.set({ + frameworks: ["mocha", "browserify"], + + files: [ + // Fixtures + {pattern: 'test/fixtures/*.html', included: false}, + + // IE-specific shims + {pattern: 'node_modules/wgxpath/wgxpath.install.js', watched: false}, + + // Test harness + {pattern: 'node_modules/sinon/pkg/sinon.js', watched: false}, + {pattern: 'node_modules/sinon/pkg/sinon-ie.js', watched: false}, + 'test/init.js', + + // Test suites + 'test/spec/**/*_spec.js' + ], + + preprocessors: { + 'node_modules/wgxpath/wgxpath.install.js': 'browserify', + 'test/**/*.js': 'browserify' + }, + + browserify: { + debug: true, + configure: function (bundle) { + bundle.require('.', {expose: 'annotator'}); + } + }, + + browsers: ['PhantomJS'], + + reporters: ['dots'], + + customLaunchers: { + 'SL_Chrome': { + base: 'SauceLabs', + browserName: 'chrome', + version: '41' + }, + 'SL_Firefox': { + base: 'SauceLabs', + browserName: 'firefox', + version: '36' + }, + 'SL_Safari': { + base: 'SauceLabs', + browserName: 'safari', + platform: 'OS X 10.10', + version: '8' + }, + 'SL_IE_8': { + base: 'SauceLabs', + browserName: 'internet explorer', + platform: 'Windows 7', + version: '8' + }, + 'SL_IE_9': { + base: 'SauceLabs', + browserName: 'internet explorer', + platform: 'Windows 7', + version: '9' + }, + 'SL_IE_10': { + base: 'SauceLabs', + browserName: 'internet explorer', + platform: 'Windows 8', + version: '10' + }, + 'SL_IE_11': { + base: 'SauceLabs', + browserName: 'internet explorer', + platform: 'Windows 8.1', + version: '11' + } + } + }); + + if (process.env.TRAVIS) { + var buildLabel = 'TRAVIS #' + process.env.TRAVIS_BUILD_NUMBER + ' (' + process.env.TRAVIS_BUILD_ID + ')'; + + karma.sauceLabs = { + build: buildLabel, + startConnect: false, + testName: 'Annotator', + tunnelIdentifier: process.env.TRAVIS_JOB_NUMBER + }; + + // Travis/Sauce runs frequently time out before data starts coming back + // from Sauce. This ups the timeout from 10 to 30 seconds. + karma.browserNoActivityTimeout = 30 * 1000; + + karma.browsers = [process.env.BROWSER]; + karma.reporters = ['dots', 'saucelabs']; + } +}; diff --git a/lib/.gitignore b/lib/.gitignore deleted file mode 100644 index 29dee08ca..000000000 --- a/lib/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/* -!/vendor/ diff --git a/lib/.npmignore b/lib/.npmignore deleted file mode 100644 index e69de29bb..000000000 diff --git a/lib/vendor/gettext.js b/lib/vendor/gettext.js deleted file mode 100644 index 8a905e6a1..000000000 --- a/lib/vendor/gettext.js +++ /dev/null @@ -1,1265 +0,0 @@ -/* -Pure Javascript implementation of Uniforum message translation. -Copyright (C) 2008 Joshua I. Miller , all rights reserved - -This program is free software; you can redistribute it and/or modify it -under the terms of the GNU Library General Public License as published -by the Free Software Foundation; either version 2, or (at your option) -any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Library General Public License for more details. - -You should have received a copy of the GNU Library General Public -License along with this program; if not, write to the Free Software -Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, -USA. - -=head1 NAME - -Javascript Gettext - Javascript implemenation of GNU Gettext API. - -=head1 SYNOPSIS - - // ////////////////////////////////////////////////////////// - // Optimum caching way - - - - var gt = new Gettext({ "domain" : "myDomain" }); - // rest is the same - - - // ////////////////////////////////////////////////////////// - // The reson the shortcuts aren't exported by default is because they'd be - // glued to the single domain you created. So, if you're adding i18n support - // to some js library, you should use it as so: - - if (typeof(MyNamespace) == 'undefined') MyNamespace = {}; - MyNamespace.MyClass = function () { - var gtParms = { "domain" : 'MyNamespace_MyClass' }; - this.gt = new Gettext(gtParams); - return this; - }; - MyNamespace.MyClass.prototype._ = function (msgid) { - return this.gt.gettext(msgid); - }; - MyNamespace.MyClass.prototype.something = function () { - var myString = this._("this will get translated"); - }; - - // ////////////////////////////////////////////////////////// - // Adding the shortcuts to a global scope is easier. If that's - // ok in your app, this is certainly easier. - var myGettext = new Gettext({ 'domain' : 'myDomain' }); - function _ (msgid) { - return myGettext.gettext(msgid); - } - alert( _("text") ); - - // ////////////////////////////////////////////////////////// - // Data structure of the json data - // NOTE: if you're loading via the - - // in domain.json - json_locale_data = { - "mydomain" : { - // po header fields - "" : { - "plural-forms" : "...", - "lang" : "en", - }, - // all the msgid strings and translations - "msgid" : [ "msgid_plural", "translation", "plural_translation" ], - }, - }; - // please see the included bin/po2json script for the details on this format - -This method also allows you to use unsupported file formats, so long as you can parse them into the above format. - -=item 2. Use AJAX to load language file. - -Use XMLHttpRequest (actually, SJAX - syncronous) to load an external resource. - -Supported external formats are: - -=over - -=item * Javascript Object Notation (.json) - -(see bin/po2json) - - type=application/json - -=item * Uniforum Portable Object (.po) - -(see GNU Gettext's xgettext) - - type=application/x-po - -=item * Machine Object (compiled .po) (.mo) - -NOTE: .mo format isn't actually supported just yet, but support is planned. - -(see GNU Gettext's msgfmt) - - type=application/x-mo - -=back - -=back - -=head1 METHODS - -The following methods are implemented: - - new Gettext(args) - textdomain (domain) - gettext (msgid) - dgettext (domainname, msgid) - dcgettext (domainname, msgid, LC_MESSAGES) - ngettext (msgid, msgid_plural, count) - dngettext (domainname, msgid, msgid_plural, count) - dcngettext (domainname, msgid, msgid_plural, count, LC_MESSAGES) - pgettext (msgctxt, msgid) - dpgettext (domainname, msgctxt, msgid) - dcpgettext (domainname, msgctxt, msgid, LC_MESSAGES) - npgettext (msgctxt, msgid, msgid_plural, count) - dnpgettext (domainname, msgctxt, msgid, msgid_plural, count) - dcnpgettext (domainname, msgctxt, msgid, msgid_plural, count, LC_MESSAGES) - strargs (string, args_array) - - -=head2 new Gettext (args) - -Several methods of loading locale data are included. You may specify a plugin or alternative method of loading data by passing the data in as the "locale_data" option. For example: - - var get_locale_data = function () { - // plugin does whatever to populate locale_data - return locale_data; - }; - var gt = new Gettext( 'domain' : 'messages', - 'locale_data' : get_locale_data() ); - -The above can also be used if locale data is specified in a statically included - - - diff --git a/package.json b/package.json index 626bf6677..558fbbf3f 100644 --- a/package.json +++ b/package.json @@ -1,42 +1,55 @@ { "name": "annotator", - "version": "2.0.0-dev", - "description": "Inline annotation for the web. Select text, images, or (nearly) anything else, and add your notes.", + "version": "2.0.0-alpha.3", + "description": "Annotation for the web. Select text, images, or (nearly) anything else, and add your notes.", "repository": { "type": "git", - "url": "https://github.com/okfn/annotator.git" + "url": "https://github.com/openannotation/annotator.git" }, "dependencies": { - "through": "~2.3.4" + "backbone-extend-standalone": "^0.1.2", + "clean-css": "^3.4.4", + "enhance-css": "^1.1.0", + "es6-promise": "^3.0.2", + "insert-css": "^0.2.0", + "jquery": "^1.11.3", + "xpath-range": "0.0.5" }, "devDependencies": { - "backbone-events-standalone": "~0.2.1", - "backbone-extend-standalone": "~0.1.2", - "browser-resolve": "~1.2.1", - "coffee-script": "~1.6.3", - "uglify-js": "~2.4.12", - "uglifycss": "~0.0.5", - "mocha": "~1.12.1", - "mocha-phantomjs": "~3.3.2", - "chai": "~1.7.2", - "sinon": "~1.6.0", - "jwt-simple": "~0.1.0", - "iso8601": "~1.1.1", - "connect": "~2.10.1", - "browserify": "~3.30.1", - "coffeeify": "~0.6.0", - "convert-source-map": "~0.3.1", - "glob": "~3.2.6", - "kew": "~0.2.2", - "source-map": "~0.1.32", - "watchify": "~0.6.1" + "assertive-chai": "^1.0.0", + "browserify": "^11.2.0", + "browserify-middleware": "^7.0.0", + "concat-stream": "^1.5.0", + "connect": "^3.4.0", + "esprima": "^2.6.0", + "jscs": "^2.1.1", + "jshint": "^2.8.0", + "jwt-simple": "^0.3.1", + "karma": "^0.13.10", + "karma-browserify": "^4.3.0", + "karma-mocha": "^0.2.0", + "karma-phantomjs-launcher": "^0.2.1", + "karma-sauce-launcher": "^0.2.14", + "mocha": "^2.3.3", + "phantomjs": "^1.9.18", + "serve-static": "^1.10.0", + "sinon": "^1.17.0", + "through": "^2.3.8", + "uglify-js": "^2.4.24", + "wgxpath": "^1.1.0" + }, + "browser": "browser.js", + "browserify": { + "transform": [ + "./tools/cssify" + ] }, "engines": { - "node": ">=0.8 <0.12" + "node": ">=0.10 <0.12" }, "scripts": { "start": "./tools/serve", - "test": "./tools/test" - }, - "browser": "./lib/annotator" + "test": "./node_modules/karma/bin/karma start --single-run", + "lint": "jshint src && jscs src && jshint -c test/.jshintrc test && jscs -c test/.jscsrc test" + } } diff --git a/src/annotations.coffee b/src/annotations.coffee deleted file mode 100644 index 0aa92b93d..000000000 --- a/src/annotations.coffee +++ /dev/null @@ -1,102 +0,0 @@ -StorageProvider = require('./storage') - - -# Public: Provides CRUD methods for annotations which call corresponding registry hooks. -class AnnotationProvider - - @configure: (registry) -> - registry['annotations'] ?= new this(registry) - registry.include(StorageProvider) - - constructor: (@registry) -> - - # Creates and returns a new annotation object. - # - # Runs the 'beforeCreateAnnotation' hook to allow the new annotation to - # be initialized or prevented. - # - # Runs the 'createAnnotation' hook when the new annotation is initialized. - # - # Examples - # - # .create({}) - # - # registry.on 'beforeAnnotationCreated', (annotation) -> - # annotation.myProperty = 'This is a custom property' - # registry.create({}) # Resolves to {myProperty: "This is a…"} - # - # Returns a Promise of an annotation Object. - create: (obj={}) -> - this._cycle(obj, 'create') - - # Updates an annotation. - # - # Publishes the 'beforeAnnotationUpdated' and 'annotationUpdated' events. - # Listeners wishing to modify an updated annotation should subscribe to - # 'beforeAnnotationUpdated' while listeners storing annotations should - # subscribe to 'annotationUpdated'. - # - # annotation - An annotation Object to update. - # - # Examples - # - # annotation = {tags: 'apples oranges pears'} - # registry.on 'beforeAnnotationUpdated', (annotation) -> - # # validate or modify a property. - # annotation.tags = annotation.tags.split(' ') - # registry.update(annotation) - # # => Returns ["apples", "oranges", "pears"] - # - # Returns a Promise of an annotation Object. - update: (obj) -> - if not obj.id? - throw new TypeError("annotation must have an id for update()") - this._cycle(obj, 'update') - - # Public: Deletes the annotation. - # - # annotation - An annotation Object to delete. - # - # Returns a Promise of an annotation Object. - delete: (obj) -> - if not obj.id? - throw new TypeError("annotation must have an id for delete()") - this._cycle(obj, 'delete') - - # Public: Queries the store - # - # query - An Object defining a query. This may be interpreted differently by - # different stores. - # - # Returns a Promise resolving to the store return value. - query: (query) -> - return @registry['store'].query(query) - - # Public: Queries the store - # - # query - An Object defining a query. This may be interpreted differently by - # different stores. - # - # Returns a Promise resolving to the annotations. - load: (query) -> - return this.query(query) - - # Private: cycle a store event, keeping track of the annotation object and - # updating it as necessary. - _cycle: (obj, storeFunc) -> - safeCopy = $.extend(true, {}, obj) - delete safeCopy._local - - @registry['store'][storeFunc](safeCopy) - .then (ret) => - # Empty object without changing identity - for own k, v of obj - if k != '_local' - delete obj[k] - - # Update with store return value - $.extend(obj, ret) - - return obj - -module.exports = AnnotationProvider diff --git a/src/annotator.coffee b/src/annotator.coffee deleted file mode 100644 index f5c14624f..000000000 --- a/src/annotator.coffee +++ /dev/null @@ -1,840 +0,0 @@ -extend = require 'backbone-extend-standalone' - -Delegator = require './class' -Range = require './range' -Util = require './util' -Widget = require './widget' -Viewer = require './viewer' -Editor = require './editor' -Notification = require './notification' -Registry = require './registry' - -AnnotationProvider = require './annotations' - -_t = Util.TranslationString - - - -# Selection and range creation reference for the following code: -# http://www.quirksmode.org/dom/range_intro.html -# -# I've removed any support for IE TextRange (see commit d7085bf2 for code) -# for the moment, having no means of testing it. - -# Store a reference to the current Annotator object. -_Annotator = this.Annotator - -handleError = -> - console.error.apply(console, arguments) - -class Annotator extends Delegator - # Events to be bound on Annotator#element. - events: - ".annotator-adder button click": "onAdderClick" - ".annotator-adder button mousedown": "onAdderMousedown" - ".annotator-hl mouseover": "onHighlightMouseover" - ".annotator-hl mouseout": "startViewerHideTimer" - - html: - adder: '
' - wrapper: '
' - - options: # Configuration options - - store: null # Store plugin to use. If null, Annotator will use a default store. - - readOnly: false # Start Annotator in read-only mode. No controls will be shown. - - loadQuery: {} # Initial query to load Annotations - - plugins: {} - - editor: null - - viewer: null - - selectedRanges: null - - mouseIsDown: false - - ignoreMouseup: false - - viewerHideTimer: null - - # Public: Creates an instance of the Annotator. Requires a DOM Element in - # which to watch for annotations as well as any options. - # - # NOTE: If the Annotator is not supported by the current browser it will not - # perform any setup and simply return a basic object. This allows plugins - # to still be loaded but will not function as expected. It is reccomended - # to call Annotator.supported() before creating the instance or using the - # Unsupported plugin which will notify users that the Annotator will not work. - # - # element - A DOM Element in which to annotate. - # options - An options Object. NOTE: There are currently no user options. - # - # Examples - # - # annotator = new Annotator(document.body) - # - # # Example of checking for support. - # if Annotator.supported() - # annotator = new Annotator(document.body) - # else - # # Fallback for unsupported browsers. - # - # Returns a new instance of the Annotator. - constructor: (element, options) -> - super - @plugins = {} - - Annotator._instances.push(this) - - # Return early if the annotator is not supported. - return this unless Annotator.supported() - - # Create the registry and start the application - Registry.createApp(this, options) - - # Public: Creates a subclass of Annotator. - # - # See the documentation from Backbone: http://backbonejs.org/#Model-extend - # - # Examples - # - # var ExtendedAnnotator = Annotator.extend({ - # setupAnnotation: function (annotation) { - # // Invoke the built-in implementation - # try { - # Annotator.prototype.setupAnnotation.call(this, annotation); - # } catch (e) { - # if (e instanceof Annotator.Range.RangeError) { - # // Try to locate the Annotation using the quote - # } else { - # throw e; - # } - # } - # - # return annotation; - # }); - # - # var annotator = new ExtendedAnnotator(document.body, /* {options} */); - @extend: extend - - # Wraps the children of @element in a @wrapper div. NOTE: This method will also - # remove any script elements inside @element to prevent them re-executing. - # - # Returns itself to allow chaining. - _setupWrapper: -> - @wrapper = $(@html.wrapper) - - # We need to remove all scripts within the element before wrapping the - # contents within a div. Otherwise when scripts are reappended to the DOM - # they will re-execute. This is an issue for scripts that call - # document.write() - such as ads - as they will clear the page. - @element.find('script').remove() - @element.wrapInner(@wrapper) - @wrapper = @element.find('.annotator-wrapper') - - this - - # Creates an instance of Annotator.Viewer and assigns it to the @viewer - # property, appends it to the @wrapper and sets up event listeners. - # - # Returns itself to allow chaining. - _setupViewer: -> - @viewer = new Annotator.Viewer(readOnly: @options.readOnly) - @viewer.hide() - .on("edit", this.onEditAnnotation) - .on("delete", (annotation) => - @viewer.hide() - this.publish('beforeAnnotationDeleted', [annotation]) - # Delete highlight elements. - this.cleanupAnnotation(annotation) - # Delete annotation - this.annotations.delete(annotation) - .done => this.publish('annotationDeleted', [annotation]) - ) - .addField({ - load: (field, annotation) => - if annotation.text - $(field).html(Util.escape(annotation.text)) - else - $(field).html("#{_t 'No Comment'}") - this.publish('annotationViewerTextField', [field, annotation]) - }) - .element.appendTo(@wrapper).bind({ - "mouseover": this.clearViewerHideTimer - "mouseout": this.startViewerHideTimer - }) - this - - # Creates an instance of the Annotator.Editor and assigns it to @editor. - # Appends this to the @wrapper and sets up event listeners. - # - # Returns itself for chaining. - _setupEditor: -> - @editor = new Annotator.Editor() - @editor.hide() - .on('hide', this.onEditorHide) - .on('save', this.onEditorSubmit) - .addField({ - type: 'textarea', - label: _t('Comments') + '\u2026' - load: (field, annotation) -> - $(field).find('textarea').val(annotation.text || '') - submit: (field, annotation) -> - annotation.text = $(field).find('textarea').val() - }) - - @editor.element.appendTo(@wrapper) - this - - # Sets up the selection event listeners to watch mouse actions on the document. - # - # Returns itself for chaining. - _setupDocumentEvents: -> - $(document).bind({ - "mouseup": this.checkForEndSelection - "mousedown": this.checkForStartSelection - }) - this - - # Sets up any dynamically calculated CSS for the Annotator. - # - # Returns itself for chaining. - _setupDynamicStyle: -> - style = $('#annotator-dynamic-style') - - if (!style.length) - style = $('').appendTo(document.head) - - sel = '*' + (":not(.annotator-#{x})" for x in ['adder', 'outer', 'notice', 'filter']).join('') - - # use the maximum z-index in the page - max = Util.maxZIndex($(document.body).find(sel)) - - # but don't go smaller than 1010, because this isn't bulletproof -- - # dynamic elements in the page (notifications, dialogs, etc.) may well - # have high z-indices that we can't catch using the above method. - max = Math.max(max, 1000) - - style.text [ - ".annotator-adder, .annotator-outer, .annotator-notice {" - " z-index: #{max + 20};" - "}" - ".annotator-filter {" - " z-index: #{max + 10};" - "}" - ].join("\n") - - this - - # Public: Load and draw annotations from a given query. - # - # query - the query to pass to the backend - # - # Returns a Promise that resolves when loading is complete. - load: (query) -> - @annotations.load(query) - .then (annotations, meta) => - this.loadAnnotations(annotations) - - # Public: Destroy the current Annotator instance, unbinding all events and - # disposing of all relevant elements. - # - # Returns nothing. - destroy: -> - $(document).unbind({ - "mouseup": this.checkForEndSelection - "mousedown": this.checkForStartSelection - }) - - $('#annotator-dynamic-style').remove() - - @adder.remove() - @viewer.destroy() - @editor.destroy() - - @wrapper.find('.annotator-hl').each -> - $(this).contents().insertBefore(this) - $(this).remove() - - @wrapper.contents().insertBefore(@wrapper) - @wrapper.remove() - @element.data('annotator', null) - - for name, plugin of @plugins - @plugins[name].destroy() - - this.removeEvents() - idx = Annotator._instances.indexOf(this) - if idx != -1 - Annotator._instances.splice(idx, 1) - - # Public: Gets the current selection excluding any nodes that fall outside of - # the @wrapper. Then returns and Array of NormalizedRange instances. - # - # Examples - # - # # A selection inside @wrapper - # annotation.getSelectedRanges() - # # => Returns [NormalizedRange] - # - # # A selection outside of @wrapper - # annotation.getSelectedRanges() - # # => Returns [] - # - # Returns Array of NormalizedRange instances. - getSelectedRanges: -> - selection = Util.getGlobal().getSelection() - - ranges = [] - rangesToIgnore = [] - unless selection.isCollapsed - ranges = for i in [0...selection.rangeCount] - r = selection.getRangeAt(i) - browserRange = new Range.BrowserRange(r) - normedRange = browserRange.normalize().limit(@wrapper[0]) - - # If the new range falls fully outside the wrapper, we - # should add it back to the document but not return it from - # this method - rangesToIgnore.push(r) if normedRange is null - - normedRange - - # BrowserRange#normalize() modifies the DOM structure and deselects the - # underlying text as a result. So here we remove the selected ranges and - # reapply the new ones. - selection.removeAllRanges() - - for r in rangesToIgnore - selection.addRange(r) - - # Remove any ranges that fell outside of @wrapper. - $.grep ranges, (range) -> - # Add the normed range back to the selection if it exists. - selection.addRange(range.toRange()) if range - range - - - # Public: Initialises an annotation from an object representation. It finds - # the selected range and higlights the selection in the DOM. - # - # annotation - An annotation Object to initialise. - # - # Examples - # - # # Create a brand new annotation from the currently selected text. - # annotation = annotator.setupAnnotation({ranges: annotator.selectedRanges}) - # # annotation has now been assigned the currently selected range - # # and a highlight appended to the DOM. - # - # # Add an existing annotation that has been stored elsewere to the DOM. - # annotation = getStoredAnnotationWithSerializedRanges() - # annotation = annotator.setupAnnotation(annotation) - # - # Returns the initialised annotation. - setupAnnotation: (annotation) -> - root = @wrapper[0] - - normedRanges = [] - for r in annotation.ranges - try - normedRanges.push(Range.sniff(r).normalize(root)) - catch e - if e instanceof Range.RangeError - this.publish('rangeNormalizeFail', [annotation, r, e]) - else - # Oh Javascript, why you so crap? This will lose the traceback. - throw e - - annotation.quote = [] - annotation.ranges = [] - annotation._local = {} - annotation._local.highlights = [] - - for normed in normedRanges - annotation.quote.push $.trim(normed.text()) - annotation.ranges.push normed.serialize(@wrapper[0], '.annotator-hl') - $.merge annotation._local.highlights, this.highlightRange(normed) - - # Join all the quotes into one string. - annotation.quote = annotation.quote.join(' / ') - - # Save the annotation data on each highlighter element. - $(annotation._local.highlights).data('annotation', annotation) - - annotation - - # Public: Deletes the annotation by removing the highlight from the DOM. - # - # annotation - An annotation Object to delete. - # - # Returns deleted annotation. - cleanupAnnotation: (annotation) -> - if annotation._local?.highlights? - for h in annotation._local.highlights when h.parentNode? - $(h).replaceWith(h.childNodes) - delete annotation._local.highlights - - annotation - - # Public: Loads an Array of annotations into the @element. Breaks the task - # into chunks of 10 annotations. - # - # annotations - An Array of annotation Objects. - # - # Examples - # - # loadAnnotationsFromStore (annotations) -> - # annotator.loadAnnotations(annotations) - # - # Returns itself for chaining. - loadAnnotations: (annotations=[]) -> - loader = (annList=[]) => - now = annList.splice(0,10) - - for n in now - this.setupAnnotation(n) - - # If there are more to do, do them after a 10ms break (for browser - # responsiveness). - if annList.length > 0 - setTimeout((-> loader(annList)), 10) - else - this.publish 'annotationsLoaded', [clone] - - clone = annotations.slice() - loader annotations - - this - - # Public: Calls the Store#dumpAnnotations() method. - # - # Returns dumped annotations Array or false if Store is not loaded. - dumpAnnotations: () -> - if @plugins['Store'] - @plugins['Store'].dumpAnnotations() - else - console.warn(_t("Can't dump annotations without Store plugin.")) - return false - - # Public: Wraps the DOM Nodes within the provided range with a highlight - # element of the specified class and returns the highlight Elements. - # - # normedRange - A NormalizedRange to be highlighted. - # cssClass - A CSS class to use for the highlight (default: 'annotator-hl') - # - # Returns an array of highlight Elements. - highlightRange: (normedRange, cssClass='annotator-hl') -> - white = /^\s*$/ - - hl = $("") - - # Ignore text nodes that contain only whitespace characters. This prevents - # spans being injected between elements that can only contain a restricted - # subset of nodes such as table rows and lists. This does mean that there - # may be the odd abandoned whitespace node in a paragraph that is skipped - # but better than breaking table layouts. - for node in normedRange.textNodes() when not white.test(node.nodeValue) - $(node).wrapAll(hl).parent().show()[0] - - # Public: highlight a list of ranges - # - # normedRanges - An array of NormalizedRanges to be highlighted. - # cssClass - A CSS class to use for the highlight (default: 'annotator-hl') - # - # Returns an array of highlight Elements. - highlightRanges: (normedRanges, cssClass='annotator-hl') -> - highlights = [] - for r in normedRanges - $.merge highlights, this.highlightRange(r, cssClass) - highlights - - # Public: Registers a plugin with the Annotator. A plugin can only be - # registered once. The plugin will be instantiated in the following order. - # - # 1. A new instance of the plugin will be created (providing the @element and - # options as params) then assigned to the @plugins registry. - # 2. The current Annotator instance will be attached to the plugin. - # 3. The Plugin#pluginInit() method will be called if it exists. - # - # name - Plugin to instantiate. Must be in the Annotator.Plugins namespace. - # options - Any options to be provided to the plugin constructor. - # - # Examples - # - # annotator - # .addPlugin('Tags') - # .addPlugin('Store', { - # prefix: '/store' - # }) - # .addPlugin('Permissions', { - # user: 'Bill' - # }) - # - # Returns itself to allow chaining. - addPlugin: (name, options) -> - if @plugins[name] - console.error _t("You cannot have more than one instance of any plugin.") - else - klass = Annotator.Plugin[name] - if typeof klass is 'function' - @plugins[name] = new klass(@element[0], options) - @plugins[name].annotator = this - @plugins[name].pluginInit?() - else - console.error _t("Could not load ") + name + _t(" plugin. Have you included the appropriate - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/spec/annotations_spec.coffee b/test/spec/annotations_spec.coffee deleted file mode 100644 index fcc1f07c1..000000000 --- a/test/spec/annotations_spec.coffee +++ /dev/null @@ -1,116 +0,0 @@ -Registry = require('../../src/registry') -AnnotationProvider = require('../../src/annotations') - - -describe 'AnnotationProvider', -> - a = null - r = null - m = null - - beforeEach -> - r = new Registry() - .include(AnnotationProvider) - - a = r['annotations'] - m = r['store'] - - sinon.spy(m, 'create') - sinon.spy(m, 'update') - sinon.spy(m, 'delete') - sinon.spy(m, 'query') - - describe '#create()', -> - - it "should pass annotation data to the store's #create()", -> - - a.create({some: 'data'}) - assert(m.create.calledOnce, 'store .create() called once') - assert( - m.create.calledWith(sinon.match({some: 'data'})), - 'store .create() called with correct args' - ) - - it "should return a promise resolving to the created annotation", (done) -> - ann = {some: 'data'} - a.create(ann) - .done (ret) -> - assert.equal(ret, ann) - assert.property(ret, 'id', 'created annotation has an id') - done() - .fail (obj, msg) -> - done(new Error("promise rejected: #{msg}")) - - describe '#update()', -> - - it "should pass annotation data to the store's #update()", -> - - a.update({id: '123', some: 'data'}) - assert(m.update.calledOnce, 'store .update() called once') - assert( - m.update.calledWith(sinon.match({id: '123', some: 'data'})), - 'store .update() called with correct args' - ) - - it "should throw a TypeError if the data lacks an id", -> - ann = {some: 'data'} - assert.throws((-> a.update(ann)), TypeError, ' id ') - - describe '#delete()', -> - - it "should pass annotation data to the store's #delete()", -> - - a.delete({id: '123', some: 'data'}) - assert(m.delete.calledOnce, 'store .delete() called once') - assert( - m.delete.calledWith(sinon.match({id: '123', some: 'data'})), - 'store .delete() called with correct args' - ) - - it "should throw a TypeError if the data lacks an id", -> - ann = {some: 'data'} - assert.throws((-> a.delete(ann)), TypeError, ' id ') - - describe '#query()', -> - - it "should invoke the query method on the registered store service", -> - query = {url: 'foo'} - a.query(query) - assert(m.query.calledWith(query)) - - describe '#load()', -> - - it "should call the query method", -> - sinon.spy(a, 'query') - a.load({foo: 'bar', type: 'giraffe'}) - assert(a.query.calledWith, sinon.match({foo: 'bar', type: 'giraffe'})) - - describe '#_cycle()', -> - store_noop = (a) -> $.Deferred().resolve(a).promise() - local = null - ann = null - - beforeEach -> - local = {foo: 'bar', numbers: [1,2,3]} - ann = {some: 'data', _local: local} - m['bogus'] = sinon.spy(store_noop) - - it "should strip an annotation of any _local before passing to the store", -> - a._cycle(ann, 'bogus') - assert( - m.bogus.calledWith(sinon.match({some: 'data'})) - 'annotation _local stripped before store call' - ) - it "should pass annotation data to the store method", -> - a._cycle(ann, 'bogus') - assert(m.bogus.calledOnce, 'store method called once') - assert( - m.bogus.calledWith(sinon.match({some: 'data'})), - 'store method called with correct args' - ) - - it "should reattach _local after the store promise resolves", (done) -> - after = sinon.spy (ret) -> - after.calledWith(sinon.match({some: 'data', _local: local})) - done() - - a._cycle(ann, 'bogus').done(after) diff --git a/test/spec/annotator_spec.coffee b/test/spec/annotator_spec.coffee deleted file mode 100644 index 43cc1e126..000000000 --- a/test/spec/annotator_spec.coffee +++ /dev/null @@ -1,957 +0,0 @@ -h = require('helpers') - -Annotator = require('annotator') -Util = Annotator.Util -Range = Annotator.Range - - -describe 'Annotator', -> - annotator = null - - beforeEach -> annotator = new Annotator($('
')[0]) - afterEach -> $(document).unbind() - - describe "events", -> - it "should call Annotator#onAdderClick() when adder is clicked", -> - stub = sinon.stub(annotator, 'onAdderClick') - - annotator.element.find('.annotator-adder button').click() - - assert(stub.calledOnce) - - it "should call Annotator#onAdderMousedown() when mouse button is held down on adder", -> - stub = sinon.stub(annotator, 'onAdderMousedown') - - annotator.element.find('.annotator-adder button').mousedown() - - assert(stub.calledOnce) - - it "should call Annotator#onHighlightMouseover() when mouse moves over a highlight", -> - stub = sinon.stub(annotator, 'onHighlightMouseover') - - highlight = $('').appendTo(annotator.element) - highlight.mouseover() - - assert(stub.calledOnce) - - it "should call Annotator#startViewerHideTimer() when mouse moves off a highlight", -> - stub = sinon.stub(annotator, 'startViewerHideTimer') - - highlight = $('').appendTo(annotator.element) - highlight.mouseout() - - assert(stub.calledOnce) - - describe "constructor", -> - beforeEach -> - sinon.stub(annotator, '_setupWrapper').returns(annotator) - sinon.stub(annotator, '_setupViewer').returns(annotator) - sinon.stub(annotator, '_setupEditor').returns(annotator) - sinon.stub(annotator, '_setupDocumentEvents').returns(annotator) - sinon.stub(annotator, '_setupDynamicStyle').returns(annotator) - - it 'should include the default modules', -> - assert.isObject(annotator['annotations'], 'annotations service exists') - assert.isObject(annotator['annotations'], 'storage service exists') - - it "should have a jQuery wrapper as @element", -> - Annotator.prototype.constructor.call(annotator, annotator.element[0]) - assert.instanceOf(annotator.element, $) - - it "should create an empty @plugin object", -> - Annotator.prototype.constructor.call(annotator, annotator.element[0]) - assert.isTrue(annotator.hasOwnProperty('plugins')) - - it "should create the adder properties from the @html strings", -> - Annotator.prototype.constructor.call(annotator, annotator.element[0]) - assert.instanceOf(annotator.adder, $) - - it "should call Annotator#_setupWrapper()", -> - Annotator.prototype.constructor.call(annotator, annotator.element[0]) - assert(annotator._setupWrapper.called) - - it "should call Annotator#_setupViewer()", -> - Annotator.prototype.constructor.call(annotator, annotator.element[0]) - assert(annotator._setupViewer.called) - - it "should call Annotator#_setupEditor()", -> - Annotator.prototype.constructor.call(annotator, annotator.element[0]) - assert(annotator._setupEditor.called) - - it "should call Annotator#_setupDocumentEvents()", -> - Annotator.prototype.constructor.call(annotator, annotator.element[0]) - assert(annotator._setupDocumentEvents.called) - - it "should NOT call Annotator#_setupDocumentEvents() if options.readOnly is true", -> - Annotator.prototype.constructor.call(annotator, annotator.element[0], { - readOnly: true - }) - assert.isFalse(annotator._setupDocumentEvents.called) - - it "should call Annotator#_setupDynamicStyle()", -> - Annotator.prototype.constructor.call(annotator, annotator.element[0]) - assert(annotator._setupDynamicStyle.called) - - describe "#destroy()", -> - it "should unbind Annotator's events from the page", -> - stub = sinon.stub(annotator, 'checkForStartSelection') - - annotator._setupDocumentEvents() - annotator.destroy() - $(document).mousedown() - - assert.isFalse(stub.called) - $(document).unbind('mousedown') - - it "should remove Annotator's elements from the page", -> - annotator.destroy() - assert.equal(annotator.element.find('[class^=annotator-]').length, 0) - - describe "_setupDocumentEvents", -> - beforeEach: -> - $(document).unbind('mouseup').unbind('mousedown') - - it "should call Annotator#checkForStartSelection() when mouse button is pressed", -> - stub = sinon.stub(annotator, 'checkForStartSelection') - annotator._setupDocumentEvents() - $(document).mousedown() - assert(stub.calledOnce) - - it "should call Annotator#checkForEndSelection() when mouse button is lifted", -> - stub = sinon.stub(annotator, 'checkForEndSelection') - annotator._setupDocumentEvents() - $(document).mouseup() - assert(stub.calledOnce) - - describe "_setupWrapper", -> - it "should wrap children of @element in the @html.wrapper element", -> - annotator.element = $('
contents
') - annotator._setupWrapper() - assert.equal(annotator.wrapper.html(), 'contents') - - it "should remove all script elements prior to wrapping", -> - div = document.createElement('div') - div.appendChild(document.createElement('script')) - - annotator.element = $(div) - annotator._setupWrapper() - - assert.equal(annotator.wrapper[0].innerHTML, '') - - describe "_setupViewer", -> - mockViewer = null - - beforeEach -> - element = $('
') - - mockViewer = - fields: [] - element: element - - mockViewer.on = -> mockViewer - mockViewer.hide = -> mockViewer - mockViewer.addField = (options) -> - mockViewer.fields.push options - mockViewer - - sinon.spy(mockViewer, 'on') - sinon.spy(mockViewer, 'hide') - sinon.spy(mockViewer, 'addField') - sinon.stub(element, 'bind').returns(element) - sinon.stub(element, 'appendTo').returns(element) - sinon.stub(Annotator, 'Viewer').returns(mockViewer) - - annotator._setupViewer() - - afterEach -> - Annotator.Viewer.restore() - - it "should create a new instance of Annotator.Viewer and set Annotator#viewer", -> - assert.strictEqual(annotator.viewer, mockViewer) - - it "should hide the annotator on creation", -> - assert(mockViewer.hide.calledOnce) - - it "should setup the default text field", -> - args = mockViewer.addField.lastCall.args[0] - - assert(mockViewer.addField.calledOnce) - assert.equal(typeof args.load, "function") - - it "should set the contents of the field on load", -> - field = document.createElement('div') - annotation = {text: "test"} - - annotator.viewer.fields[0].load(field, annotation) - assert.equal(jQuery(field).html(), "test") - - it "should set the contents of the field to placeholder text when empty", -> - field = document.createElement('div') - annotation = {text: ""} - - annotator.viewer.fields[0].load(field, annotation) - assert.equal(jQuery(field).html(), "No Comment") - - it "should setup the default text field to publish an event on load", -> - field = document.createElement('div') - annotation = {text: "test"} - callback = sinon.spy() - - annotator.on('annotationViewerTextField', callback) - annotator.viewer.fields[0].load(field, annotation) - assert(callback.calledWith(field, annotation)) - - it "should subscribe to custom events", -> - assert.equal('edit', mockViewer.on.args[0][0]) - assert.equal('delete', mockViewer.on.args[1][0]) - - it "should bind to browser mouseover and mouseout events", -> - assert(mockViewer.element.bind.calledWith({ - 'mouseover': annotator.clearViewerHideTimer - 'mouseout': annotator.startViewerHideTimer - })) - - it "should append the Viewer#element to the Annotator#wrapper", -> - assert(mockViewer.element.appendTo.calledWith(annotator.wrapper)) - - describe "_setupEditor", -> - mockEditor = null - - beforeEach -> - element = $('
') - - mockEditor = { - element: element - } - mockEditor.on = -> mockEditor - mockEditor.hide = -> mockEditor - mockEditor.addField = -> document.createElement('li') - - sinon.spy(mockEditor, 'on') - sinon.spy(mockEditor, 'hide') - sinon.spy(mockEditor, 'addField') - sinon.stub(element, 'appendTo').returns(element) - sinon.stub(Annotator, 'Editor').returns(mockEditor) - - annotator._setupEditor() - - afterEach -> - Annotator.Editor.restore() - - it "should create a new instance of Annotator.Editor and set Annotator#editor", -> - assert.strictEqual(annotator.editor, mockEditor) - - it "should hide the annotator on creation", -> - assert(mockEditor.hide.calledOnce) - - it "should add the default textarea field", -> - options = mockEditor.addField.lastCall.args[0] - - assert(mockEditor.addField.calledOnce) - assert.equal(options.type, 'textarea') - assert.equal(options.label, 'Comments\u2026') - assert.typeOf(options.load, 'function') - assert.typeOf(options.submit, 'function') - - it "should subscribe to custom events", -> - assert(mockEditor.on.calledWith('hide', annotator.onEditorHide)) - assert(mockEditor.on.calledWith('save', annotator.onEditorSubmit)) - - it "should append the Editor#element to the Annotator#wrapper", -> - assert(mockEditor.element.appendTo.calledWith(annotator.wrapper)) - - describe "_setupDynamicStyle", -> - $fix = null - - beforeEach -> - h.addFixture 'annotator' - $fix = $(h.fix()) - - afterEach -> h.clearFixtures() - - it 'should ensure Annotator z-indices are larger than others in the page', -> - $fix.show() - - $adder = $('
 
').appendTo($fix) - $filter = $('
 
').appendTo($fix) - - check = (minimum) -> - adderZ = parseInt($adder.css('z-index'), 10) - filterZ = parseInt($filter.css('z-index'), 10) - assert.isTrue(adderZ > minimum) - assert.isTrue(filterZ > minimum) - assert.isTrue(adderZ > filterZ) - - check(1000) - - $fix.append('
') - annotator._setupDynamicStyle() - check(2000) - - $fix.append('
') - annotator._setupDynamicStyle() - check(10000) - - $fix.hide() - - describe "getSelectedRanges", -> - mockGlobal = null - mockSelection = null - mockRange = null - mockBrowserRange = null - - beforeEach -> - mockBrowserRange = { - cloneRange: sinon.stub() - } - mockBrowserRange.cloneRange.returns(mockBrowserRange) - - # This mock pretends to be both NormalizedRange and BrowserRange. - mockRange = { - limit: sinon.stub() - normalize: sinon.stub() - toRange: sinon.stub().returns('range') - } - mockRange.limit.returns(mockRange) - mockRange.normalize.returns(mockRange) - - # https://developer.mozilla.org/en/nsISelection - mockSelection = { - getRangeAt: sinon.stub().returns(mockBrowserRange) - removeAllRanges: sinon.spy() - addRange: sinon.spy() - rangeCount: 1 - } - mockGlobal = { - getSelection: sinon.stub().returns(mockSelection) - } - sinon.stub(Util, 'getGlobal').returns(mockGlobal) - sinon.stub(Range, 'BrowserRange').returns(mockRange) - - afterEach -> - Util.getGlobal.restore() - Range.BrowserRange.restore() - - it "should retrieve the global object and call getSelection()", -> - annotator.getSelectedRanges() - assert(mockGlobal.getSelection.calledOnce) - - it "should retrieve the global object and call getSelection()", -> - ranges = annotator.getSelectedRanges() - assert.deepEqual(ranges, [mockRange]) - - it "should remove any failed calls to NormalizedRange#limit(), but re-add them to the global selection", -> - mockRange.limit.returns(null) - ranges = annotator.getSelectedRanges() - assert.deepEqual(ranges, []) - assert.isTrue(mockSelection.addRange.calledWith(mockBrowserRange)) - - it "should return an empty array if selection.isCollapsed is true", -> - mockSelection.isCollapsed = true - ranges = annotator.getSelectedRanges() - assert.deepEqual(ranges, []) - - it "should deselect all current ranges", -> - ranges = annotator.getSelectedRanges() - assert(mockSelection.removeAllRanges.calledOnce) - - it "should reassign the newly normalized ranges", -> - ranges = annotator.getSelectedRanges() - assert(mockSelection.addRange.calledOnce) - assert.isTrue(mockSelection.addRange.calledWith('range')) - - describe "setupAnnotation", -> - annotation = null - quote = null - comment = null - element = null - annotationObj = null - normalizedRange = null - sniffedRange = null - - beforeEach -> - quote = 'This is some annotated text' - comment = 'This is a comment on an annotation' - element = $('') - - normalizedRange = { - text: sinon.stub().returns(quote) - serialize: sinon.stub().returns({}) - } - sniffedRange = { - normalize: sinon.stub().returns(normalizedRange) - } - sinon.stub(Range, 'sniff').returns(sniffedRange) - sinon.stub(annotator, 'highlightRange').returns(element) - sinon.spy(annotator, 'publish') - - annotationObj = { - text: comment, - ranges: [1] - } - annotation = annotator.setupAnnotation(annotationObj) - - afterEach -> - Range.sniff.restore() - - it "should return the annotation object with a comment", -> - assert.equal(annotation.text, comment) - - it "should return the annotation object with the quoted text", -> - assert.equal(annotation.quote, quote) - - it "should trim whitespace from start and end of quote", -> - normalizedRange.text.returns('\n\t ' + quote + ' \n') - annotation = annotator.setupAnnotation(annotationObj) - assert.equal(annotation.quote, quote) - - it "should set the annotation.ranges", -> - assert.deepEqual(annotation.ranges, [{}]) - - it "should exclude any ranges that could not be normalized", -> - e = new Range.RangeError("typ", "msg") - sniffedRange.normalize.throws(e) - annotation = annotator.setupAnnotation({ - text: comment, - ranges: [1] - }) - - assert.deepEqual(annotation.ranges, []) - - it "should trigger rangeNormalizeFail for each range that can't be normalized", -> - e = new Range.RangeError("typ", "msg") - sniffedRange.normalize.throws(e) - annotator.publish = sinon.spy() - annotation = annotator.setupAnnotation({ - text: comment, - ranges: [1] - }) - - assert.isTrue(annotator.publish.calledWith('rangeNormalizeFail', [annotation, 1, e])) - - it "should call Annotator#highlightRange() with the normed range", -> - assert.isTrue(annotator.highlightRange.calledWith(normalizedRange)) - - it "should store the annotation in the highlighted element's data store", -> - assert.equal(element.data('annotation'), annotation) - - describe "cleanupAnnotation", -> - annotation = null - div = null - - beforeEach -> - annotation = { - text: "my annotation comment" - _local: - highlights: $('HatsGloves') - } - div = $('
').append(annotation._local.highlights) - - it "should remove the highlights from the DOM", -> - highlights = annotation._local.highlights - highlights.each -> - assert.lengthOf($(this).parent(), 1) - - annotator.cleanupAnnotation(annotation) - highlights.each -> - assert.lengthOf($(this).parent(), 0) - - assert.isUndefined(annotation._local.highlights, "highlights property removed") - - it "should leave the content of the highlights in place", -> - annotator.cleanupAnnotation(annotation) - assert.equal(div.html(), 'HatsGloves') - - it "should not choke when there are no highlights", -> - assert.doesNotThrow((-> annotator.cleanupAnnotation({})), Error) - - describe "loadAnnotations", -> - beforeEach -> - sinon.stub(annotator, 'setupAnnotation') - sinon.spy(annotator, 'publish') - - it "should call Annotator#setupAnnotation for each annotation in the Array", -> - annotations = [{}, {}, {}, {}] - annotator.loadAnnotations(annotations) - assert.equal(annotator.setupAnnotation.callCount, 4) - - it "should publish the annotationsLoaded event with all loaded annotations", -> - annotations = [{}, {}, {}, {}] - annotator.loadAnnotations(annotations.slice()) - assert.isTrue(annotator.publish.calledWith('annotationsLoaded', [annotations])) - - it "should break the annotations into blocks of 10", -> - clock = sinon.useFakeTimers() - annotations = [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}] - - annotator.loadAnnotations(annotations) - assert.equal(annotator.setupAnnotation.callCount, 10) - - while annotations.length > 0 - clock.tick(10) - - assert.equal(annotator.setupAnnotation.callCount, 13) - clock.restore() - - describe "dumpAnnotations", -> - it "returns false and prints a warning if no Store plugin is active", -> - sinon.stub(console, 'warn') - assert.isFalse(annotator.dumpAnnotations()) - assert(console.warn.calledOnce) - - it "returns the results of the Store plugins dumpAnnotations method", -> - annotator.plugins.Store = { dumpAnnotations: -> [1,2,3] } - assert.deepEqual(annotator.dumpAnnotations(), [1,2,3]) - - describe "highlightRange", -> - it "should return a highlight element for every textNode in the range", -> - textNodes = (document.createTextNode(text) for text in ['hello', 'world']) - mockRange = - textNodes: -> textNodes - - elements = annotator.highlightRange(mockRange) - assert.lengthOf(elements, 2) - assert.equal(elements[0].className, 'annotator-hl') - assert.equal(elements[0].firstChild, textNodes[0]) - assert.equal(elements[1].firstChild, textNodes[1]) - - it "should ignore textNodes that contain only whitespace", -> - textNodes = (document.createTextNode(text) for text in ['hello', '\n ', ' ']) - mockRange = - textNodes: -> textNodes - - elements = annotator.highlightRange(mockRange) - assert.lengthOf(elements, 1) - assert.equal(elements[0].className, 'annotator-hl') - assert.equal(elements[0].firstChild, textNodes[0]) - - it "should set highlight element class names to its second argument", -> - textNodes = (document.createTextNode(text) for text in ['hello', 'world']) - mockRange = - textNodes: -> textNodes - - elements = annotator.highlightRange(mockRange, 'monkeys') - assert.equal(elements[0].className, 'monkeys') - - describe "highlightRanges", -> - it "should return a list of highlight elements all highlighted ranges", -> - textNodes = (document.createTextNode(text) for text in ['hello', 'world']) - mockRange = - textNodes: -> textNodes - ranges = [mockRange, mockRange, mockRange] - elements = annotator.highlightRanges(ranges) - assert.lengthOf(elements, 6) - assert.equal(elements[0].className, 'annotator-hl') - - it "should set highlight element class names to its second argument", -> - textNodes = (document.createTextNode(text) for text in ['hello', 'world']) - mockRange = - textNodes: -> textNodes - ranges = [mockRange, mockRange, mockRange] - elements = annotator.highlightRanges(ranges, 'monkeys') - assert.equal(elements[0].className, 'monkeys') - - describe "addPlugin", -> - plugin = null - - beforeEach -> - plugin = { - pluginInit: sinon.spy() - } - Annotator.Plugin.Foo = sinon.stub().returns(plugin) - - it "should add and instantiate a plugin of the specified name", -> - annotator.addPlugin('Foo') - assert.isTrue(Annotator.Plugin.Foo.calledWith(annotator.element[0], undefined)) - - it "should pass on the provided options", -> - options = {foo: 'bar'} - annotator.addPlugin('Foo', options) - assert.isTrue(Annotator.Plugin.Foo.calledWith(annotator.element[0], options)) - - it "should attach the Annotator instance", -> - annotator.addPlugin('Foo') - assert.equal(plugin.annotator, annotator) - - it "should call Plugin#pluginInit()", -> - annotator.addPlugin('Foo') - assert(plugin.pluginInit.calledOnce) - - it "should complain if you try and instantiate a plugin twice", -> - sinon.stub(console, 'error') - annotator.addPlugin('Foo') - annotator.addPlugin('Foo') - assert.equal(Annotator.Plugin.Foo.callCount, 1) - assert(console.error.calledOnce) - console.error.restore() - - it "should complain if you try and instantiate a plugin that doesn't exist", -> - sinon.stub(console, 'error') - annotator.addPlugin('Bar') - assert.isFalse(annotator.plugins['Bar']?) - assert(console.error.calledOnce) - console.error.restore() - - describe "showEditor", -> - beforeEach -> - sinon.spy(annotator, 'publish') - sinon.spy(annotator.editor, 'load') - sinon.spy(annotator.editor.element, 'css') - - it "should call Editor#load() on the Annotator#editor", -> - annotation = {text: 'my annotation comment'} - annotator.showEditor(annotation, {}) - assert.isTrue(annotator.editor.load.calledWith(annotation)) - - it "should set the top/left properties of the Editor#element", -> - location = {top: 20, left: 20} - annotator.showEditor({}, location) - assert.isTrue(annotator.editor.element.css.calledWith(location)) - - it "should publish the 'annotationEditorShown' event passing the editor and annotations", -> - annotation = {text: 'my annotation comment'} - annotator.showEditor(annotation, {}) - assert(annotator.publish.calledWith('annotationEditorShown', [annotator.editor, annotation])) - - describe "onEditorHide", -> - it "should publish the 'annotationEditorHidden' event and provide the Editor and annotation", -> - sinon.spy(annotator, 'publish') - annotator.onEditorHide() - assert(annotator.publish.calledWith('annotationEditorHidden', [annotator.editor])) - - it "should set the Annotator#ignoreMouseup property to false", -> - annotator.ignoreMouseup = true - annotator.onEditorHide() - assert.isFalse(annotator.ignoreMouseup) - - describe "onEditorSubmit", -> - annotation = null - - beforeEach -> - annotation = {"text": "bah"} - sinon.spy(annotator, 'publish') - sinon.spy(annotator, 'setupAnnotation') - - it "should publish the 'annotationEditorSubmit' event and pass the Editor and annotation", -> - annotator.onEditorSubmit(annotation) - assert(annotator.publish.calledWith('annotationEditorSubmit', [annotator.editor, annotation])) - - describe "showViewer", -> - beforeEach -> - sinon.spy(annotator, 'publish') - sinon.spy(annotator.viewer, 'load') - sinon.spy(annotator.viewer.element, 'css') - - it "should call Viewer#load() on the Annotator#viewer", -> - annotations = [{text: 'my annotation comment'}] - annotator.showViewer(annotations, {}) - assert.isTrue(annotator.viewer.load.calledWith(annotations)) - - it "should set the top/left properties of the Editor#element", -> - location = {top: 20, left: 20} - annotator.showViewer([], location) - assert.isTrue(annotator.viewer.element.css.calledWith(location)) - - it "should publish the 'annotationViewerShown' event passing the viewer and annotations", -> - annotations = [{text: 'my annotation comment'}] - annotator.showViewer(annotations, {}) - assert(annotator.publish.calledWith('annotationViewerShown', [annotator.viewer, annotations])) - - describe "startViewerHideTimer", -> - beforeEach -> - sinon.spy(annotator.viewer, 'hide') - - it "should call Viewer.hide() on the Annotator#viewer after 250ms", -> - clock = sinon.useFakeTimers() - annotator.startViewerHideTimer() - clock.tick(250) - assert(annotator.viewer.hide.calledOnce) - clock.restore() - - it "should NOT call Viewer.hide() on the Annotator#viewer if @viewerHideTimer is set", -> - clock = sinon.useFakeTimers() - annotator.viewerHideTimer = 60 - annotator.startViewerHideTimer() - clock.tick(250) - assert.isFalse(annotator.viewer.hide.calledOnce) - clock.restore() - - describe "clearViewerHideTimer", -> - it "should clear the @viewerHideTimer property", -> - annotator.viewerHideTimer = 456 - annotator.clearViewerHideTimer() - assert.isFalse(annotator.viewerHideTimer) - - describe "checkForStartSelection", -> - beforeEach -> - sinon.spy(annotator, 'startViewerHideTimer') - annotator.mouseIsDown = false - annotator.checkForStartSelection() - - it "should call Annotator#startViewerHideTimer()", -> - assert(annotator.startViewerHideTimer.calledOnce) - - it "should NOT call #startViewerHideTimer() if mouse is over the annotator", -> - annotator.startViewerHideTimer.reset() - annotator.checkForStartSelection({target: annotator.viewer.element}) - assert.isFalse(annotator.startViewerHideTimer.called) - - it "should set @mouseIsDown to true", -> - assert.isTrue(annotator.mouseIsDown) - - describe "checkForEndSelection", -> - mockEvent = null - mockOffset = null - mockRanges = null - - beforeEach -> - mockEvent = { target: document.createElement('span') } - mockOffset = {top: 0, left: 0} - mockRanges = [{}] - - sinon.stub(Util, 'mousePosition').returns(mockOffset) - sinon.stub(annotator.adder, 'show').returns(annotator.adder) - sinon.stub(annotator.adder, 'hide').returns(annotator.adder) - sinon.stub(annotator.adder, 'css').returns(annotator.adder) - sinon.stub(annotator, 'getSelectedRanges').returns(mockRanges) - - annotator.mouseIsDown = true - annotator.selectedRanges = [] - annotator.checkForEndSelection(mockEvent) - - afterEach -> - Util.mousePosition.restore() - - it "should get the current selection from Annotator#getSelectedRanges()", -> - assert(annotator.getSelectedRanges.calledOnce) - - it "should set @mouseIsDown to false", -> - assert.isFalse(annotator.mouseIsDown) - - it "should set the Annotator#selectedRanges property", -> - assert.equal(annotator.selectedRanges, mockRanges) - - it "should display the Annotator#adder if valid selection", -> - assert(annotator.adder.show.calledOnce) - assert.isTrue(annotator.adder.css.calledWith(mockOffset)) - assert.isTrue(Util.mousePosition.calledWith(mockEvent, annotator.wrapper[0])) - - it "should hide the Annotator#adder if NOT valid selection", -> - annotator.adder.hide.reset() - annotator.adder.show.reset() - annotator.getSelectedRanges.returns([]) - - annotator.checkForEndSelection(mockEvent) - assert(annotator.adder.hide.calledOnce) - assert.isFalse(annotator.adder.show.called) - - it "should hide the Annotator#adder if target is part of the annotator", -> - annotator.adder.hide.reset() - annotator.adder.show.reset() - - mockNode = document.createElement('span') - mockEvent.target = annotator.viewer.element[0] - - sinon.stub(annotator, 'isAnnotator').returns(true) - annotator.getSelectedRanges.returns([{commonAncestor: mockNode}]) - - annotator.checkForEndSelection(mockEvent) - assert.isTrue(annotator.isAnnotator.calledWith(mockNode)) - - assert.isFalse(annotator.adder.hide.called) - assert.isFalse(annotator.adder.show.called) - - it "should return if @ignoreMouseup is true", -> - annotator.getSelectedRanges.reset() - annotator.ignoreMouseup = true - annotator.checkForEndSelection(mockEvent) - assert.isFalse(annotator.getSelectedRanges.called) - - describe "isAnnotator", -> - it "should return true if the element is part of the annotator", -> - elements = [ - annotator.viewer.element - annotator.adder - annotator.editor.element.find('ul') - ] - - for element in elements - assert.isTrue(annotator.isAnnotator(element)) - - it "should return false if the element is NOT part of the annotator", -> - elements = [ - null - annotator.element.parent() - document.createElement('span') - annotator.wrapper - ] - for element in elements - assert.isFalse(annotator.isAnnotator(element)) - - describe "onHighlightMouseover", -> - element = null - mockEvent = null - mockOffset = null - annotation = null - - beforeEach -> - annotation = {text: "my comment"} - element = $('').data('annotation', annotation) - mockEvent = { - target: element[0] - } - mockOffset = {top: 0, left: 0} - - sinon.stub(Util, 'mousePosition').returns(mockOffset) - sinon.spy(annotator, 'showViewer') - - annotator.viewerHideTimer = 60 - annotator.onHighlightMouseover(mockEvent) - - afterEach -> - Util.mousePosition.restore() - - it "should clear the current @viewerHideTimer", -> - assert.isFalse(annotator.viewerHideTimer) - - it "should fetch the current mouse position", -> - assert.isTrue(Util.mousePosition.calledWith(mockEvent, annotator.wrapper[0])) - - it "should display the Annotation#viewer with annotations", -> - assert.isTrue(annotator.showViewer.calledWith([annotation], mockOffset)) - - describe "onAdderMousedown", -> - it "should set the @ignoreMouseup property to true", -> - annotator.ignoreMouseup = false - annotator.onAdderMousedown() - assert.isTrue(annotator.ignoreMouseup) - - describe "onAdderClick", -> - annotation = null - mockOffset = null - mockSubscriber = null - quote = null - element = null - normalizedRange = null - sniffedRange = null - - beforeEach -> - annotation = - text: "test" - quote = 'This is some annotated text' - element = $('').addClass('annotator-hl') - - mockOffset = {top: 0, left:0} - - normalizedRange = { - text: sinon.stub().returns(quote) - serialize: sinon.stub().returns({}) - } - sniffedRange = { - normalize: sinon.stub().returns(normalizedRange) - } - - sinon.stub(annotator.adder, 'hide') - sinon.stub(annotator.adder, 'position').returns(mockOffset) - sinon.spy(annotator, 'setupAnnotation') - sinon.spy(annotator.annotations, 'create') - sinon.stub(annotator, 'showEditor') - sinon.stub(Range, 'sniff').returns(sniffedRange) - sinon.stub(annotator, 'highlightRange').returns(element) - sinon.spy(element, 'addClass') - annotator.selectedRanges = ['foo'] - annotator.onAdderClick() - - afterEach -> - Range.sniff.restore() - - it "should hide the adder", -> - assert(annotator.adder.hide.calledOnce) - - it "should set up the annotation", -> - assert(annotator.setupAnnotation.calledOnce) - - it "should display the editor", -> - assert(annotator.showEditor.calledOnce) - - it "should add temporary highlights to the document to show the user what they selected", -> - assert(annotator.highlightRange.calledWith(normalizedRange)) - assert.equal(element[0].className, 'annotator-hl annotator-hl-temporary') - - it "should persist the temporary highlights if the annotation is saved", -> - annotator.publish('annotationEditorSubmit') - assert.equal(element[0].className, 'annotator-hl') - - it "should create the annotation if the edit is saved", -> - annotator.onEditorSubmit(annotation) - assert(annotator.annotations.create.calledOnce) - - it "should not create the annotation if editing is cancelled", -> - do annotator.onEditorHide - do annotator.onEditorSubmit - assert.isFalse(annotator.annotations.create.called) - - describe "onEditAnnotation", -> - annotation = null - mockOffset = null - mockSubscriber = null - - beforeEach -> - annotation = {id: 123, text: "my mock annotation"} - mockOffset = {top: 0, left: 0} - mockSubscriber = sinon.spy() - sinon.spy(annotator, "showEditor") - sinon.spy(annotator.annotations, "update") - sinon.spy(annotator.viewer, "hide") - sinon.stub(annotator.viewer.element, "position").returns(mockOffset) - annotator.onEditAnnotation(annotation) - - it "should hide the viewer", -> - assert(annotator.viewer.hide.calledOnce) - - it "should show the editor", -> - assert(annotator.showEditor.calledOnce) - - it "should update the annotation if the edit is saved", -> - annotator.onEditorSubmit(annotation) - assert(annotator.annotations.update.calledWith(annotation)) - - it "should not update the annotation if editing is cancelled", -> - do annotator.onEditorHide - annotator.onEditorSubmit(annotation) - assert.isFalse(annotator.annotations.update.calledWith(annotation)) - -describe "Annotator.noConflict()", -> - _Annotator = null - - beforeEach -> - _Annotator = Annotator - - afterEach -> - window.Annotator = _Annotator - - it "should restore the value previously occupied by window.Annotator", -> - Annotator.noConflict() - assert.isUndefined(window.Annotator) - - it "should return the Annotator object", -> - result = Annotator.noConflict() - assert.equal(result, _Annotator) - -describe "Annotator.supported()", -> - - beforeEach -> - window._Selection = window.getSelection - - afterEach -> - window.getSelection = window._Selection - - it "should return true if the browser has window.getSelection method", -> - window.getSelection = -> - assert.isTrue(Annotator.supported()) - - xit "should return false if the browser has no window.getSelection method", -> - # The method currently checks for getSelection on load and will always - # return that result. - window.getSelection = undefined - assert.isFalse(Annotator.supported()) diff --git a/test/spec/app_spec.js b/test/spec/app_spec.js new file mode 100644 index 000000000..3ec4e2d05 --- /dev/null +++ b/test/spec/app_spec.js @@ -0,0 +1,205 @@ +var assert = require('assertive-chai').assert; + +var Promise = require('es6-promise').Promise; + +var app = require('../../src/app'); +var storage = require('../../src/storage'); + + +describe('App', function () { + var sandbox; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('#include', function () { + it('should call module configure functions with the registry', function () { + var b = new app.App(); + var config = sandbox.stub(); + var mod = function () { + return {configure: config}; + }; + b.include(mod); + sinon.assert.calledWith(config, b.registry); + }); + + it('should call module functions with options if supplied', function () { + var b = new app.App(); + var mod = sandbox.stub().returns({}); + b.include(mod, {foo: 'bar'}); + sinon.assert.calledWith(mod, {foo: 'bar'}); + }); + }); + + describe('#destroy', function () { + it("should call each module's destroy function, if it has one", function (done) { + var b = new app.App(); + var destroy1 = sandbox.stub(); + var destroy2 = sandbox.stub(); + var mod1 = function () { return {destroy: destroy1}; }; + var mod2 = function () { return {destroy: destroy2}; }; + var mod3 = function () { return {}; }; + b.include(mod1); + b.include(mod2); + b.include(mod3); + b.destroy() + .then(function () { + sinon.assert.called(destroy1); + sinon.assert.called(destroy2); + }) + .then(done, done); + }); + }); + + describe('#runHook', function () { + it('should run the named hook handler on each module', function () { + var b = new app.App(); + var hook1 = sandbox.stub(); + var hook2 = sandbox.stub(); + var mod1 = function () { return {annotationCreated: hook1}; }; + var mod2 = function () { return {annotationCreated: hook2}; }; + var mod3 = function () { return {}; }; + b.include(mod1); + b.include(mod2); + b.include(mod3); + + b.runHook('annotationCreated'); + + sinon.assert.calledWithExactly(hook1); + sinon.assert.calledWithExactly(hook2); + }); + + it('should return a promise that resolves if all the ' + 'handlers resolve', function (done) { + var b = new app.App(); + var plug1 = {}; + var plug2 = {}; + var plug3 = {}; + var mod1 = function () { return plug1; }; + var mod2 = function () { return plug2; }; + var mod3 = function () { return plug3; }; + b.include(mod1); + b.include(mod2); + b.include(mod3); + + plug1.annotationCreated = sandbox.stub().returns(123); + plug2.annotationCreated = sandbox.stub().returns(Promise.resolve("ok")); + + var delayedResolve = null; + plug3.annotationCreated = sandbox.stub().returns(new Promise(function (resolve) { + delayedResolve = resolve; + })); + + var ret = b.runHook('annotationCreated'); + ret.then(function () { + done(); + }, function () { + done(new Error("Promise should not have been rejected!")); + }); + + delayedResolve("finally..."); + }); + + it('should return a promise that rejects if any handler rejects', function (done) { + var b = new app.App(); + var plug1 = {}; + var plug2 = {}; + var mod1 = function () { return plug1; }; + var mod2 = function () { return plug2; }; + b.include(mod1); + b.include(mod2); + plug1.annotationCreated = sandbox.stub().returns(Promise.resolve("ok")); + + var delayedReject = null; + plug2.annotationCreated = sandbox.stub().returns(new Promise(function (resolve, reject) { + delayedReject = reject; + })); + + var ret = b.runHook('annotationCreated'); + ret.then(function () { + done(new Error("Promise should not have been resolved!")); + }, function () { + done(); + }); + + delayedReject("fail..."); + }); + }); + + describe('#start', function () { + it('sets the authz property on the app', function () { + var b = new app.App(); + b.start(); + + assert.ok(b.authz); + }); + + it('sets the ident property on the app', function () { + var b = new app.App(); + b.start(); + + assert.ok(b.ident); + }); + + it('sets the notify property on the app', function () { + var b = new app.App(); + b.start(); + + assert.ok(b.notify); + }); + + it('sets the annotations property on app to be a storage adapter', function () { + var b = new app.App(); + var s = sandbox.stub(); + var adapter = {}; + sandbox.stub(storage, 'StorageAdapter').returns(adapter); + b.registry.registerUtility(s, 'storage'); + + b.start(); + + assert.equal(adapter, b.annotations); + }); + + it('should pass the adapter the storage component', function () { + var b = new app.App(); + var s = sandbox.stub(); + sandbox.stub(storage, 'StorageAdapter').returns('adapter'); + b.registry.registerUtility(s, 'storage'); + + b.start(); + + sinon.assert.calledOnce(storage.StorageAdapter); + sinon.assert.calledWith(storage.StorageAdapter, s); + }); + + it('should pass the adapter a hook runner which calls the runHook method of the app', function () { + var b = new app.App(); + var s = sandbox.stub().returns('storage'); + sandbox.stub(b, 'runHook'); + sandbox.stub(storage, 'StorageAdapter').returns('adapter'); + b.registry.registerUtility(s, 'storage'); + + b.start(); + + var hookRunner = storage.StorageAdapter.firstCall.args[1]; + hookRunner('foo', [1, 2, 3]); + + sinon.assert.calledWith(b.runHook, 'foo', [1, 2, 3]); + }); + + it("should run the module 'start' hook", function () { + var b = new app.App(); + var start = sandbox.stub(); + var mod = function () { return {start: start}; }; + + b.include(mod); + b.start(); + + sinon.assert.calledWith(start, b); + }); + }); +}); diff --git a/test/spec/authz_spec.js b/test/spec/authz_spec.js new file mode 100644 index 000000000..d213772b0 --- /dev/null +++ b/test/spec/authz_spec.js @@ -0,0 +1,48 @@ +var assert = require('assertive-chai').assert; + +var authz = require('../../src/authz'); + + +describe('authz.AclAuthzPolicy', function () { + var p; + + beforeEach(function () { + p = new authz.AclAuthzPolicy(); + }); + + describe('.permits(...)', function () { + it('permits any action if there is no permission info', function () { + assert.isTrue(p.permits('foo', {}, null)); + assert.isTrue(p.permits('foo', {}, 'alice')); + }); + + it('refuses any action if an context has a user and no identity is set', function () { + assert.isFalse(p.permits('foo', {user: 'alice'}, null)); + }); + + it('permits any action if context has a user which matches the identity', function () { + assert.isTrue(p.permits('foo', {user: 'alice'}, 'alice')); + }); + + it('refuses any action if context has a user which does not match the identity', function () { + assert.isFalse(p.permits('foo', {user: 'alice'}, 'bob')); + }); + + it('permits any action if permissions are undefined or null', function () { + var a = {permissions: {}}; + assert.isTrue(p.permits('foo', a, null)); + assert.isTrue(p.permits('foo', a, 'alice')); + }); + + it('refuses an action if permissions[action] == []', function () { + var a = {permissions: {'foo': []}}; + assert.isFalse(p.permits('foo', a, null)); + assert.isFalse(p.permits('foo', a, 'bob')); + }); + + it('permits an action if permissions[action] contains >0 tokens which match the identity', function () { + var a = {permissions: {'foo': ['alice']}}; + assert.isTrue(p.permits('foo', a, 'alice')); + }); + }); +}); diff --git a/test/spec/bootstrap_spec.coffee b/test/spec/bootstrap_spec.coffee deleted file mode 100644 index 69a9275f2..000000000 --- a/test/spec/bootstrap_spec.coffee +++ /dev/null @@ -1,213 +0,0 @@ -window._annotatorConfig = - test: true - target: "#fixtures" - externals: - jQuery: "../../../lib/vendor/jquery.js" - source: "../pkg/annotator.min.js" - styles: "../pkg/annotator.min.css" - - auth: - headers: - "X-Annotator-Account-Id": "39fc339cf058bd22176771b3e30155a8" - "X-Annotator-User-Id": "aron" - "X-Annotator-Auth-Token": "65b4e7d823c91d9b18e649e4067f11c3eb29c3cb504ff965760d737ae6dbcdd3" - - tags: true - store: - prefix: "" - - annotateItPermissions: - showViewPermissionsCheckbox: true - showEditPermissionsCheckbox: true - user: - id: "Aron" - name: "Aron" - - permissions: - read: ["Aron"] - update: ["Aron"] - delete: ["Aron"] - admin: ["Aron"] - - -Annotator = require('annotator') -require('../../src/bootstrap') - - -describe "bookmarklet", -> - bookmarklet = null - head = document.getElementsByTagName('head')[0] - - beforeEach -> - window.Annotator = Annotator - bookmarklet = window._annotator.bookmarklet - - # Prevent Notifications from being fired. - sinon.stub bookmarklet.notification, "show" - sinon.stub bookmarklet.notification, "message" - sinon.stub bookmarklet.notification, "hide" - sinon.stub bookmarklet.notification, "remove" - - sinon.spy bookmarklet, "config" - sinon.stub bookmarklet, "loadjQuery" - - sinon.stub jQuery, "getScript", (_src, callback) -> - callback() - error: -> - sinon.stub head, "appendChild" - - afterEach -> - delete window.Annotator - - bookmarklet.notification.show.restore() - bookmarklet.notification.message.restore() - bookmarklet.notification.hide.restore() - bookmarklet.notification.remove.restore() - - bookmarklet.config.restore() - bookmarklet.loadjQuery.restore() - - jQuery.getScript.restore() - head.appendChild.restore() - - jQuery(".annotator-bm-status, .annotator-notice").remove() - - describe "init()", -> - beforeEach -> - sinon.spy bookmarklet, "load" - sinon.spy window.jQuery, "proxy" - - afterEach -> - bookmarklet.load.restore() - window.jQuery.proxy.restore() - - it "should display a notification telling the user the page is loading", -> - bookmarklet.init() - assert(bookmarklet.notification.show.called) - - it "should call jQueryLoad()", -> - bookmarklet.init() - assert(bookmarklet.loadjQuery.called) - - it "should display a notification if the bookmarklet has loaded", -> - window._annotator.instance = {} - window._annotator.Annotator = showNotification: sinon.spy() - bookmarklet.init() - assert(window._annotator.Annotator.showNotification.called) - - describe "load()", -> - it "should append the stylesheet to the head", (done) -> - bookmarklet.load -> - assert(head.appendChild.called) - done() - - it "should load the annotator script and call the callback", (done) -> - bookmarklet.load -> - assert(jQuery.getScript.called) - done() - - describe "setup()", -> - beforeEach -> - bookmarklet.setup() - - afterEach -> - window._annotator.jQuery("#fixtures").empty().removeData("annotator").removeData "annotator:headers" - - it "should export useful values to window._annotator", -> - assert.isFunction(window._annotator.Annotator) - assert.isObject(window._annotator.instance) - assert.isFunction(window._annotator.jQuery) - assert.isObject(window._annotator.element) - - it "should add the plugins to the annotator instance", -> - instance = window._annotator.instance - plugins = instance.plugins - assert.isObject(plugins.Auth) - assert.isObject(plugins.Store) - assert.isObject(plugins.AnnotateItPermissions) - assert.isObject(plugins.Unsupported) - - it "should add the tags plugin if options.tags is true", -> - instance = window._annotator.instance - plugins = instance.plugins - assert.isObject(plugins.Tags) - - it "should display a loaded notification", -> - assert(bookmarklet.notification.message.called) - - describe "annotateItPermissionsOptions()", -> - it "should return an object literal", -> - assert.isObject(bookmarklet.annotateItPermissionsOptions()) - - it "should retrieve user and permissions from config", -> - bookmarklet.annotateItPermissionsOptions() - assert(bookmarklet.config.calledWith "annotateItPermissions") - - describe "storeOptions()", -> - it "should return an object literal", -> - assert.isObject(bookmarklet.storeOptions()) - - it "should retrieve store prefix from config", -> - bookmarklet.storeOptions() - assert(bookmarklet.config.calledWith "store.prefix") - - it "should have set a uri property", -> - uri = bookmarklet.storeOptions().annotationData.uri - assert(uri) - -describe "bookmarklet.notification", -> - bookmarklet = null - notification = undefined - - beforeEach -> - bookmarklet = window._annotator.bookmarklet - bookmarklet.init() - notification = bookmarklet.notification - - sinon.spy bookmarklet.notification, "show" - sinon.spy bookmarklet.notification, "message" - sinon.spy bookmarklet.notification, "hide" - sinon.spy bookmarklet.notification, "remove" - - afterEach -> - bookmarklet.notification.show.restore() - bookmarklet.notification.message.restore() - bookmarklet.notification.hide.restore() - bookmarklet.notification.remove.restore() - - it "should have an Element property", -> - assert.isObject(notification.element) - - describe "show", -> - it "should set the top style of the element", -> - notification.show() - assert.equal(notification.element.style.top, "0px") - - it "should call notification.message", -> - notification.show "hello", "red" - assert(notification.message.calledWith "hello", "red") - - describe "hide", -> - it "should set the top style of the element", -> - notification.hide() - assert.notEqual(notification.element.style.top, "0px") - - describe "message", -> - it "should set the innerHTML of the element", -> - notification.message "hello" - assert.equal(notification.element.innerHTML, "hello") - - it "should set the bottomBorderColor of the element", -> - current = notification.element.style.borderBottomColor - notification.message "hello", "#fff" - assert.notEqual(notification.element.style.borderBottomColor, current) - - describe "append", -> - it "should append the element to the document.body", -> - notification.append() - assert.equal(notification.element.parentNode, document.body) - - describe "remove", -> - it "should remove the element from the document.body", -> - notification.remove() - assert.isNull(notification.element.parentNode) diff --git a/test/spec/browser_spec.js b/test/spec/browser_spec.js new file mode 100644 index 000000000..28274f805 --- /dev/null +++ b/test/spec/browser_spec.js @@ -0,0 +1,26 @@ +var assert = require('assertive-chai').assert; + +var annotator = require('../../browser'); + +describe("noConflict()", function () { + var _annotator = null; + + beforeEach(function () { + _annotator = annotator; + }); + + afterEach(function () { + window.annotator = _annotator; + }); + + it("should restore the value previously occupied by window.annotator", function () { + annotator.noConflict(); + assert.isUndefined(window.annotator); + }); + + it("should return the annotator object", function () { + var result = annotator.noConflict(); + assert.equal(result, _annotator); + }); +}); + diff --git a/test/spec/class_spec.coffee b/test/spec/class_spec.coffee deleted file mode 100644 index e79e9c81d..000000000 --- a/test/spec/class_spec.coffee +++ /dev/null @@ -1,124 +0,0 @@ -h = require('helpers') -Delegator = require('../../src/class') - -class DelegatedExample extends Delegator - events: - 'div click': 'pushA' - 'mousedown': 'pushB' - 'li click': 'pushC' - 'wibble': 'pushD' - - options: - foo: "bar" - bar: (a) -> a - - constructor: (elem) -> - super - @returns = [] - - pushA: -> @returns.push("A") - pushB: -> @returns.push("B") - pushC: -> @returns.push("C") - pushD: -> @returns.push("D") - - -describe 'Delegator', -> - delegator = null - $fix = null - - beforeEach -> - h.addFixture('delegator') - - delegator = new DelegatedExample(h.fix()) - $fix = $(h.fix()) - - afterEach -> h.clearFixtures() - - it "should provide access to an options object", -> - assert.equal(delegator.options.foo, "bar") - delegator.options.bar = (a) -> "<#{a}>" - - it "should be unique to an instance", -> - assert.equal(delegator.options.bar("hello"), "hello") - - it "automatically binds events described in its events property", -> - $fix.find('p').click() - assert.deepEqual(delegator.returns, ['A']) - - it "will bind non-custom events to its root element if no selector is specified", -> - $fix.trigger('mousedown') - assert.deepEqual(delegator.returns, ['B']) - - it "will bind custom events to itself if no selector is specified", -> - $fix.trigger('wibble') - assert.deepEqual(delegator.returns, []) - delegator.publish('wibble') - assert.deepEqual(delegator.returns, ['D']) - - it "uses event delegation to bind the events", -> - $fix.find('ol').append("
  • Hi there, I'm new round here.
  • ") - $fix.find('li').click() - - assert.deepEqual(delegator.returns, ['C', 'A', 'C', 'A']) - - it "should not bubble custom events", -> - callback = sinon.spy() - $('body').bind('custom', callback) - - delegator.element = $('
    ').appendTo('body') - delegator.publish('custom') - - assert.isFalse(callback.called) - - it ".removeEvents() should remove all events previously bound by addEvents", -> - delegator.removeEvents() - - $fix.find('ol').append("
  • Hi there, I'm new round here.
  • ") - $fix.find('li').click() - $fix.trigger('baz') - - assert.deepEqual(delegator.returns, []) - - it ".subscribe() subscribes listeners", -> - res = [] - delegator.subscribe('foo', -> res.push('bar')) - assert.deepEqual(res, []) - delegator.publish('foo') - assert.deepEqual(res, ['bar']) - - it "passes args from .publish() to listeners", -> - res = [] - delegator.subscribe('foo', (x, y, z) -> res.push(z, y, x)) - assert.deepEqual(res, []) - delegator.publish('foo', [1, 2, 3]) - assert.deepEqual(res, [3, 2, 1]) - - it "invokes the callback in the context of the object by default", -> - res = null - delegator.subscribe('foo', (-> res = this)) - delegator.publish('foo') - assert.equal(res, delegator) - - it "invokes the callback with a context if provided", -> - res = null - sentinel = {} - delegator.subscribe('foo', (-> res = this), sentinel) - delegator.publish('foo') - assert.equal(res, sentinel) - - it ".unsubscribe() unsubscribes listeners", -> - res = [] - cbk = -> res.push('bar') - delegator.subscribe('foo', cbk) - delegator.unsubscribe('foo', cbk) - delegator.publish('foo') - assert.deepEqual(res, []) - - it ".unsubscribe() only unsubscribes listeners passed", -> - res = [] - cbk = -> res.push('bar') - delegator.subscribe('foo', -> res.push('baz')) - delegator.subscribe('foo', cbk) - delegator.unsubscribe('foo', cbk) - delegator.publish('foo') - assert.deepEqual(res, ['baz']) diff --git a/test/spec/console_spec.coffee b/test/spec/console_spec.coffee deleted file mode 100644 index e69de29bb..000000000 diff --git a/test/spec/editor_spec.coffee b/test/spec/editor_spec.coffee deleted file mode 100644 index 182b60cb9..000000000 --- a/test/spec/editor_spec.coffee +++ /dev/null @@ -1,223 +0,0 @@ -Editor = require('../../src/editor') - - -describe 'Editor', -> - editor = null - - beforeEach -> - editor = new Editor() - - afterEach -> - editor.element.remove() - - it "should have an element property", -> - assert.ok(editor.element) - assert.isTrue(editor.element.hasClass('annotator-editor')) - - describe "events", -> - it "should call Editor#submit() when the form is submitted", -> - sinon.spy(editor, 'submit') - # Prevent the default form submission in the browser. - editor.element.find('form').submit((e) -> e.preventDefault()).submit() - assert(editor.submit.calledOnce) - - it "should call Editor#submit() when the save button is clicked", -> - sinon.spy(editor, 'submit') - editor.element.find('.annotator-save').click() - assert(editor.submit.calledOnce) - - it "should call Editor#hide() when the cancel button is clicked", -> - sinon.spy(editor, 'hide') - editor.element.find('.annotator-cancel').click() - assert(editor.hide.calledOnce) - - it "should call Editor#onCancelButtonMouseover() when mouse moves over cancel", -> - sinon.spy(editor, 'onCancelButtonMouseover') - editor.element.find('.annotator-cancel').mouseover() - assert(editor.onCancelButtonMouseover.calledOnce) - - it "should call Editor#processKeypress() when a key is pressed in a textarea", -> - # Editor needs a text area field. - editor.element.find('ul').append('
  • ') - - sinon.spy(editor, 'processKeypress') - editor.element.find('textarea').keydown() - assert(editor.processKeypress.calledOnce) - - describe "show", -> - it "should make the editor visible", -> - editor.show() - assert.isFalse(editor.element.hasClass('annotator-hide')) - - it "should publish the 'show' event", -> - sinon.spy(editor, 'publish') - editor.show() - assert.isTrue(editor.publish.calledWith('show')) - - describe "hide", -> - it "should hide the editor from view", -> - editor.hide() - assert.isTrue(editor.element.hasClass('annotator-hide')) - - it "should publish the 'show' event", -> - sinon.spy(editor, 'publish') - editor.hide() - assert.isTrue(editor.publish.calledWith('hide')) - - describe "load", -> - beforeEach -> - editor.annotation = {text: 'test'} - editor.fields = [ - { - element: 'element0', - load: sinon.spy() - }, - { - element: 'element1', - load: sinon.spy() - } - ] - - # TODO: investigate why the following tests fail (editor.load blocks) - # unless the following has been called. - # sinon.spy(editor, 'show') - - it "should call #show()", -> - sinon.spy(editor, 'show') - editor.load() - assert(editor.show.calledOnce) - - it "should set the current annotation", -> - editor.load({text: 'Hello there'}) - assert.equal(editor.annotation.text, 'Hello there') - - it "should call the load callback on each field in the group", -> - editor.load() - assert(editor.fields[0].load.calledOnce) - assert(editor.fields[1].load.calledOnce) - - it "should pass the field element and an annotation to the callback", -> - editor.load() - assert(editor.fields[0].load.calledWith(editor.fields[0].element, editor.annotation)) - - it "should publish the 'load' event", -> - sinon.spy(editor, 'publish') - editor.load() - assert.isTrue(editor.publish.calledWith('load', [editor.annotation])) - - describe "submit", -> - beforeEach -> - editor.annotation = {text: 'test'} - editor.fields = [ - { - element: 'element0', - submit: sinon.spy() - }, - { - element: 'element1', - submit: sinon.spy() - } - ] - - it "should call #hide()", -> - sinon.spy(editor, 'hide') - editor.submit() - assert(editor.hide.calledOnce) - - it "should call the submit callback on each field in the group", -> - editor.submit() - assert(editor.fields[0].submit.calledOnce) - assert(editor.fields[1].submit.calledOnce) - - it "should pass the field element and an annotation to the callback", -> - editor.submit() - assert(editor.fields[0].submit.calledWith(editor.fields[0].element, editor.annotation)) - - it "should publish the 'save' event", -> - sinon.spy(editor, 'publish') - editor.submit() - assert.isTrue(editor.publish.calledWith('save', [editor.annotation])) - - describe "addField", -> - content = null - - beforeEach -> content = editor.element.children() - - afterEach -> - editor.element.empty().append(content) - editor.fields = [] - - it "should append a new field to the @fields property", -> - length = editor.fields.length - - editor.addField() - assert.lengthOf(editor.fields, length + 1) - - editor.addField() - assert.lengthOf(editor.fields, length + 2) - - it "should append a new list element to the editor", -> - length = editor.element.find('li').length - - editor.addField() - assert.lengthOf(editor.element.find('li'), length + 1) - - editor.addField() - assert.lengthOf(editor.element.find('li'), length + 2) - - it "should append an input element if no type is specified", -> - editor.addField() - assert.equal(editor.element.find('li:last :input').prop('type'), 'text') - - it "should give each element a new id", -> - editor.addField() - firstID = editor.element.find('li:last :input').attr('id') - - editor.addField() - secondID = editor.element.find('li:last :input').attr('id') - assert.notEqual(firstID, secondID) - - it "should append a textarea element if 'textarea' type is specified", -> - editor.addField({type: 'textarea'}) - assert.equal(editor.element.find('li:last :input').prop('type'), 'textarea') - - it "should append a checkbox element if 'checkbox' type is specified", -> - editor.addField({type: 'checkbox'}) - assert.equal(editor.element.find('li:last :input').prop('type'), 'checkbox') - - it "should append a label element with a for attribute matching the checkbox id", -> - editor.addField({type: 'checkbox'}) - assert.equal( - editor.element.find('li:last :input').attr('id'), - editor.element.find('li:last label').attr('for') - ) - - it "should set placeholder text if a label is provided", -> - editor.addField({type: 'textarea', label: 'Tags…'}) - assert.equal(editor.element.find('li:last :input').attr('placeholder'), 'Tags…') - - it "should return the created list item", -> - assert.equal(editor.addField().tagName, 'LI') - - describe "processKeypress", -> - beforeEach -> - sinon.spy(editor, 'hide') - sinon.spy(editor, 'submit') - - it "should call Editor#hide() if the escape key is pressed", -> - editor.processKeypress({keyCode: 27}) - assert(editor.hide.calledOnce) - - it "should call Editor#submit() if the enter key is pressed", -> - editor.processKeypress({keyCode: 13}) - assert(editor.submit.calledOnce) - - it "should NOT call Editor#submit() if the shift key is held down", -> - editor.processKeypress({keyCode: 13, shiftKey: true}) - assert.isFalse(editor.submit.called) - - describe "onCancelButtonMouseover", -> - it "should remove the focus class from submit when cancel is hovered", -> - editor.element.find('.annotator-save').addClass('annotator-focus') - editor.onCancelButtonMouseover() - assert.lengthOf(editor.element.find('.annotator-focus'), 0) diff --git a/test/spec/identity_spec.js b/test/spec/identity_spec.js new file mode 100644 index 000000000..8046830e9 --- /dev/null +++ b/test/spec/identity_spec.js @@ -0,0 +1,65 @@ +var assert = require('assertive-chai').assert; + +var identity = require('../../src/identity'); + + +describe('identity.simple', function () { + var ext; + + beforeEach(function () { + ext = new identity.simple(); + }); + + describe('configure hook', function () { + it('registers an identity policy', function () { + var policy = { + identity: sinon.match.any, + who: sinon.match.func + }; + var register = sinon.stub(); + ext.configure({registerUtility: register}); + sinon.assert.calledOnce(register); + sinon.assert.calledWithMatch(register, policy, 'identityPolicy'); + }); + }); + + describe('beforeAnnotationCreatedHook', function () { + var sandbox; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('sets the user property of the annotation', function () { + var policyProto = identity.SimpleIdentityPolicy.prototype; + sandbox.stub(policyProto, 'who').returns('alice'); + + var annotation = {}; + ext.beforeAnnotationCreated(annotation); + assert.equal(annotation.user, 'alice'); + }); + }); +}); + +describe('identity.SimpleIdentityPolicy', function () { + var ident; + + beforeEach(function () { + ident = new identity.SimpleIdentityPolicy(); + }); + + describe('.who()', function () { + it('returns null', function () { + assert.isNull(ident.who()); + }); + + it('returns .identity if set', function () { + ident.identity = 'alice'; + assert.equal('alice', ident.who()); + }); + }); +}); diff --git a/test/spec/notification_spec.coffee b/test/spec/notification_spec.coffee deleted file mode 100644 index 1ca82f4dd..000000000 --- a/test/spec/notification_spec.coffee +++ /dev/null @@ -1,41 +0,0 @@ -Notification = require('../../src/notification') - - -describe 'Notification', -> - notification = null - - beforeEach -> - notification = new Notification() - - afterEach -> - notification.element.remove() - - it 'should be appended to the document body when needed', -> - notification.show('test') - assert.equal(notification.element[0].parentNode, document.body) - - describe '.show()', -> - message = 'This is a notification message' - - beforeEach -> - notification.show(message) - - it 'should have a class named "annotator-notice-show"', -> - assert.isTrue(notification.element.hasClass('annotator-notice-show')) - - it 'should have a class named "annotator-notice-info"', -> - assert.isTrue(notification.element.hasClass('annotator-notice-info')) - - it 'should update the notification message', -> - assert.equal(notification.element.html(), message) - - describe '.hide()', -> - beforeEach -> - notification.show() - notification.hide() - - it 'should not have a class named "annotator-notice-show"', -> - assert.isFalse(notification.element.hasClass('annotator-notice-show')) - - it 'should not have a class named "annotator-notice-info"', -> - assert.isFalse(notification.element.hasClass('annotator-notice-info')) diff --git a/test/spec/notification_spec.js b/test/spec/notification_spec.js new file mode 100644 index 000000000..c0285cd2c --- /dev/null +++ b/test/spec/notification_spec.js @@ -0,0 +1,55 @@ +var assert = require('assertive-chai').assert; + +var notification = require('../../src/notification'); + +describe('notification.banner', function () { + afterEach(function () { + var el = document.querySelector('.annotator-notice'); + if (el) { + el.parentNode.removeChild(el); + } + }); + + it('creates a new banner object', function () { + var b = notification.banner('hello world'); + assert.ok(b); + }); + + describe('the banner element', function () { + it('has the correct notifier message', function () { + notification.banner('This is a notification message'); + var el = document.querySelector('.annotator-notice'); + assert.equal(el.innerHTML, 'This is a notification message'); + }); + + it('has the annotator-notice-info class by default', function () { + notification.banner('normal'); + var el = document.querySelector('.annotator-notice'); + assert.match(el.className, /\bannotator-notice-info\b/); + }); + + it('has the annotator-notice-success class if the severity was notification.SUCCESS', function () { + notification.banner('yay!', notification.SUCCESS); + var el = document.querySelector('.annotator-notice'); + assert.match(el.className, /\bannotator-notice-success\b/); + }); + + it('has the annotator-notice-error class if the severity was notification.ERROR', function () { + notification.banner('oops!', notification.ERROR); + var el = document.querySelector('.annotator-notice'); + assert.match(el.className, /\bannotator-notice-error\b/); + }); + }); + + describe('the banner object', function () { + it('has a close method which hides the notifier', function () { + var clock = sinon.useFakeTimers(); + var b = notification.banner('This is a notification message'); + b.close(); + clock.tick(600); + var el = document.querySelector('.annotator-notice'); + assert.isNull(el); + clock.restore(); + }); + }); +}); diff --git a/test/spec/plugin/annotateitpermissions_spec.coffee b/test/spec/plugin/annotateitpermissions_spec.coffee deleted file mode 100644 index 9abaf2f36..000000000 --- a/test/spec/plugin/annotateitpermissions_spec.coffee +++ /dev/null @@ -1,121 +0,0 @@ -Annotator = require('annotator') -AnnotateItPermissions = require('../../../src/plugin/annotateitpermissions') - - -describe 'Annotator.Plugin.AnnotateItPermissions', -> - el = null - permissions = null - annotator = null - - beforeEach -> - el = $("
    ").appendTo('body')[0] - permissions = new AnnotateItPermissions(el) - annotator = new Annotator($('
    ')[0]) - permissions.annotator = annotator - permissions.pluginInit() - - afterEach -> $(el).remove() - - it "it should set user for newly created annotations on beforeAnnotationCreated", -> - ann = {} - permissions.setUser({userId: 'alice', consumerKey: 'fookey'}) - annotator.publish('beforeAnnotationCreated', [ann]) - assert.equal(ann.user, 'alice') - - it "it should set consumer for newly created annotations on beforeAnnotationCreated", -> - ann = {} - permissions.setUser({userId: 'alice', consumerKey: 'fookey'}) - annotator.publish('beforeAnnotationCreated', [ann]) - assert.equal(ann.consumer, 'fookey') - - describe 'authorize', -> - annotations = null - - beforeEach -> - annotations = [ - {} # 0 - { user: 'alice' } # 1 - { user: 'alice', consumer: 'annotateit' } # 2 - { permissions: {} } # 3 - { # 4 - permissions: { - 'read': ['group:__world__'] - } - } - { # 5 - permissions: { - 'update': ['group:__authenticated__'] - } - } - { # 6 - consumer: 'annotateit' - permissions: { - 'read': ['group:__consumer__'] - } - } - ] - - it 'should NOT allow any action for an annotation with no owner info and no permissions', -> - a = annotations[0] - assert.isFalse(permissions.authorize(null, a)) - assert.isFalse(permissions.authorize('foo', a)) - permissions.setUser({userId: 'alice', consumerKey: 'annotateit'}) - assert.isFalse(permissions.authorize(null, a)) - assert.isFalse(permissions.authorize('foo', a)) - - it 'should NOT allow any action if an annotation has only user set (but no consumer)', -> - a = annotations[1] - assert.isFalse(permissions.authorize(null, a)) - assert.isFalse(permissions.authorize('foo', a)) - permissions.setUser({userId: 'alice', consumerKey: 'annotateit'}) - assert.isFalse(permissions.authorize(null, a)) - assert.isFalse(permissions.authorize('foo', a)) - - it 'should allow any action if the current auth info identifies the owner of the annotation', -> - a = annotations[2] - permissions.setUser({userId: 'alice', consumerKey: 'annotateit'}) - assert.isTrue(permissions.authorize(null, a)) - assert.isTrue(permissions.authorize('foo', a)) - - it 'should NOT allow any action for an annotation with no owner info and empty permissions field', -> - a = annotations[3] - assert.isFalse(permissions.authorize(null, a)) - assert.isFalse(permissions.authorize('foo', a)) - permissions.setUser({userId: 'alice', consumerKey: 'annotateit'}) - assert.isFalse(permissions.authorize(null, a)) - assert.isFalse(permissions.authorize('foo', a)) - - it 'should allow an action when the action field contains the world group', -> - a = annotations[4] - assert.isTrue(permissions.authorize('read', a)) - permissions.setUser({userId: 'alice', consumerKey: 'annotateit'}) - assert.isTrue(permissions.authorize('read', a)) - - it 'should allow an action when the action field contains the authenticated group and the plugin has auth info', -> - a = annotations[5] - assert.isFalse(permissions.authorize('update', a)) - permissions.setUser({userId: 'anyone', consumerKey: 'anywhere'}) - assert.isTrue(permissions.authorize('update', a)) - - it 'should allow an action when the action field contains the consumer group and the plugin has auth info with a matching consumer', -> - a = annotations[6] - assert.isFalse(permissions.authorize('read', a)) - permissions.setUser({userId: 'anyone', consumerKey: 'anywhere'}) - assert.isFalse(permissions.authorize('read', a)) - permissions.setUser({userId: 'anyone', consumerKey: 'annotateit'}) - assert.isTrue(permissions.authorize('read', a)) - - it 'should allow an action when the action field contains the consumer group and the plugin has auth info with a matching consumer', -> - a = annotations[6] - assert.isFalse(permissions.authorize('read', a)) - permissions.setUser({userId: 'anyone', consumerKey: 'anywhere'}) - assert.isFalse(permissions.authorize('read', a)) - permissions.setUser({userId: 'anyone', consumerKey: 'annotateit'}) - assert.isTrue(permissions.authorize('read', a)) - - it 'should allow an action when the user is an admin of the annotation\'s consumer', -> - a = annotations[2] - permissions.setUser({userId: 'anyone', consumerKey: 'anywhere', admin: true}) - assert.isFalse(permissions.authorize('read', a)) - permissions.setUser({userId: 'anyone', consumerKey: 'annotateit', admin: true}) - assert.isTrue(permissions.authorize('read', a)) diff --git a/test/spec/plugin/auth_spec.coffee b/test/spec/plugin/auth_spec.coffee deleted file mode 100644 index c06ca1333..000000000 --- a/test/spec/plugin/auth_spec.coffee +++ /dev/null @@ -1,123 +0,0 @@ -h = require('helpers') -Auth = require('../../../src/plugin/auth') - -Date::toISO8601String = h.DateToISO8601String - -B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" - -base64Encode = (data) -> - if btoa? - # Gecko and Webkit provide native code for this - btoa(data) - else - # Adapted from MIT/BSD licensed code at http://phpjs.org/functions/base64_encode - # version 1109.2015 - i = 0 - ac = 0 - enc = "" - tmp_arr = [] - - if not data - return data - - data += '' - - while i < data.length - # pack three octets into four hexets - o1 = data.charCodeAt(i++) - o2 = data.charCodeAt(i++) - o3 = data.charCodeAt(i++) - - bits = o1 << 16 | o2 << 8 | o3 - - h1 = bits >> 18 & 0x3f - h2 = bits >> 12 & 0x3f - h3 = bits >> 6 & 0x3f - h4 = bits & 0x3f - - # use hexets to index into b64, and append result to encoded string - tmp_arr[ac++] = B64.charAt(h1) + B64.charAt(h2) + B64.charAt(h3) + B64.charAt(h4) - - enc = tmp_arr.join('') - - r = data.length % 3 - return (if r then enc.slice(0, r - 3) else enc) + '==='.slice(r or 3) - -base64UrlEncode = (data) -> - data = base64Encode(data) - chop = data.indexOf('=') - data = data[...chop] if chop isnt -1 - data = data.replace(/\+/g, '-') - data = data.replace(/\//g, '_') - data - -makeToken = () -> - rawToken = { - consumerKey: "key" - issuedAt: new Date().toISO8601String() - ttl: 300 - userId: "testUser" - } - { - rawToken: rawToken - encodedToken: 'header.' + base64UrlEncode(JSON.stringify(rawToken)) + '.signature' - } - -describe 'Annotator.Plugin.Auth', -> - - mock = null - rawToken = null - encodedToken = null - - mockAuth = (options) -> - el = $('
    ')[0] - a = new Auth(el, options) - - { - elem: el, - auth: a - } - - beforeEach -> - {rawToken, encodedToken} = makeToken() - mock = mockAuth({token: encodedToken, autoFetch: false}) - - it "uses token supplied in options by default", -> - assert.equal(mock.auth.token, encodedToken) - - xit "makes an ajax request to tokenUrl to retrieve token otherwise" - - it "sets annotator:headers data on its element with token data", -> - data = $(mock.elem).data('annotator:headers') - assert.isNotNull(data) - assert.equal(data['x-annotator-auth-token'], encodedToken) - - it "should call callbacks given to #withToken immediately if it has a valid token", -> - callback = sinon.spy() - mock.auth.withToken(callback) - assert.isTrue(callback.calledWith(rawToken)) - - xit "should call callbacks given to #withToken after retrieving a token" - - describe "#haveValidToken", -> - it "returns true when the current token is valid", -> - assert.isTrue(mock.auth.haveValidToken()) - - it "returns false when the current token is missing a consumerKey", -> - delete mock.auth._unsafeToken.consumerKey - assert.isFalse(mock.auth.haveValidToken()) - - it "returns false when the current token is missing an issuedAt", -> - delete mock.auth._unsafeToken.issuedAt - assert.isFalse(mock.auth.haveValidToken()) - - it "returns false when the current token is missing a ttl", -> - delete mock.auth._unsafeToken.ttl - assert.isFalse(mock.auth.haveValidToken()) - - it "returns false when the current token expires in the past", -> - mock.auth._unsafeToken.ttl = 0 - assert.isFalse(mock.auth.haveValidToken()) - mock.auth._unsafeToken.ttl = 86400 - mock.auth._unsafeToken.issuedAt = "1970-01-01T00:00" - assert.isFalse(mock.auth.haveValidToken()) diff --git a/test/spec/plugin/document_spec.coffee b/test/spec/plugin/document_spec.coffee deleted file mode 100644 index 60875736f..000000000 --- a/test/spec/plugin/document_spec.coffee +++ /dev/null @@ -1,97 +0,0 @@ -Document = require('../../../src/plugin/document') - - -describe 'Annotator.Plugin.Document', -> - $fix = null - plugin = null - metadata = null - - beforeEach -> - plugin = new Document($('
    ')[0]) - plugin.pluginInit() - - describe '#beforeAnnotationCreated', -> - - it 'should add a document field to the annotation', -> - annotation = {} - plugin.beforeAnnotationCreated(annotation) - assert.property(annotation, 'document') - - describe '#getDocumentMetadata()', -> - # add some metadata to the page - head = $("head") - head.append('') - head.append('') - head.append('') - head.append('') - head.append('') - head.append('') - head.append('') - head.append('') - head.append('') - head.append('') - head.append('') - head.append('') - head.append('') - head.append('') - - beforeEach -> - metadata = plugin.getDocumentMetadata() - - it 'should have a title, derived from highwire metadata if possible', -> - assert.equal(metadata.title, 'Foo') - - it 'should have links with absoulte hrefs and types', -> - assert.ok(metadata.link) - assert.equal(metadata.link.length, 7) - assert.match(metadata.link[0].href, /^.+runner.html#?(\?.*)?$/) - assert.equal(metadata.link[1].rel, "alternate") - assert.match(metadata.link[1].href, /^.+foo\.pdf$/) - assert.equal(metadata.link[1].type, "application/pdf") - assert.equal(metadata.link[2].rel, "alternate") - assert.match(metadata.link[2].href, /^.+foo\.doc$/) - assert.equal(metadata.link[2].type, "application/msword") - assert.equal(metadata.link[3].rel, "bookmark") - assert.equal(metadata.link[3].href, "http://example.com/bookmark") - assert.equal(metadata.link[4].href, "doi:10.1175/JCLI-D-11-00015.1") - assert.match(metadata.link[5].href, /.+foo\.pdf$/) - assert.equal(metadata.link[5].type, "application/pdf") - assert.equal(metadata.link[6].href, "doi:10.1175/JCLI-D-11-00015.1") - - it 'should have highwire metadata', -> - assert.ok(metadata.highwire) - assert.deepEqual(metadata.highwire.pdf_url, ['foo.pdf']) - assert.deepEqual(metadata.highwire.doi, ['10.1175/JCLI-D-11-00015.1']) - assert.deepEqual(metadata.highwire.title, ['Foo']) - - it 'should have dublincore metadata', -> - assert.ok(metadata.dc) - assert.deepEqual(metadata.dc.identifier, ["doi:10.1175/JCLI-D-11-00015.1", "isbn:123456789"]) - assert.deepEqual(metadata.dc.type, ["Article"]) - - it 'should have facebook metadata', -> - assert.ok(metadata.facebook) - assert.deepEqual(metadata.facebook.url, ["http://example.com/"]) - - it 'should have eprints metadata', -> - assert.ok(metadata.eprints) - assert.deepEqual(metadata.eprints.title, ['Computer Lib / Dream Machines']) - - it 'should have prism metadata', -> - assert.ok(metadata.prism) - assert.deepEqual(metadata.prism.title, ['Literary Machines']) - - it 'should have twitter card metadata', -> - assert.ok(metadata.twitter) - assert.deepEqual(metadata.twitter.site, ['@okfn']) - - it 'should have a favicon', -> - assert.equal( - metadata.favicon - 'http://example.com/images/icon.ico' - ) - - describe '#uris()', -> - it 'should de-duplicate uris', -> - uris = plugin.uris() - assert.equal(uris.length, 5) diff --git a/test/spec/plugin/filter_spec.coffee b/test/spec/plugin/filter_spec.coffee deleted file mode 100755 index 872b30062..000000000 --- a/test/spec/plugin/filter_spec.coffee +++ /dev/null @@ -1,412 +0,0 @@ -Filter = require('../../../src/plugin/filter') - -describe "Annotator.Plugins.Filter", -> - plugin = null - element = null - - beforeEach -> - element = $('
    ') - annotator = { - subscribe: sinon.spy() - element: { - find: sinon.stub().returns($()) - } - } - plugin = new Filter(element[0]) - plugin.annotator = annotator - - afterEach -> - plugin.element.remove() - - describe "events", -> - filterElement = null - - beforeEach -> - filterElement = $(plugin.html.filter) - plugin.element.append(filterElement) - - afterEach -> - filterElement.remove() - - it "should call Filter#_onFilterFocus when a filter input is focussed", -> - sinon.spy(plugin, '_onFilterFocus') - filterElement.find('input').focus() - assert(plugin._onFilterFocus.calledOnce) - - it "should call Filter#_onFilterBlur when a filter input is blurred", -> - sinon.spy(plugin, '_onFilterBlur') - filterElement.find('input').blur() - assert(plugin._onFilterBlur.calledOnce) - - it "should call Filter#_onFilterKeyup when a key is pressed in an input", -> - sinon.spy(plugin, '_onFilterKeyup') - filterElement.find('input').keyup() - assert(plugin._onFilterKeyup.calledOnce) - - describe "constructor", -> - it "should have an empty filters array", -> - assert.deepEqual(plugin.filters, []) - - it "should have an filter element wrapped in jQuery", -> - assert.isTrue(plugin.filter instanceof jQuery) - assert.lengthOf(plugin.filter, 1) - - it "should append the toolbar to the @options.appendTo selector", -> - assert.isTrue(plugin.element instanceof jQuery) - assert.lengthOf(plugin.element, 1) - - parent = $(plugin.options.appendTo) - assert.equal(plugin.element.parent()[0], parent[0]) - - describe "pluginInit", -> - beforeEach -> - sinon.stub(plugin, 'updateHighlights') - sinon.stub(plugin, '_setupListeners').returns(plugin) - sinon.stub(plugin, '_insertSpacer').returns(plugin) - sinon.stub(plugin, 'addFilter') - - it "should call Filter#updateHighlights()", -> - plugin.pluginInit() - assert(plugin.updateHighlights.calledOnce) - - it "should call Filter#_setupListeners()", -> - plugin.pluginInit() - assert(plugin._setupListeners.calledOnce) - - it "should call Filter#_insertSpacer()", -> - plugin.pluginInit() - assert(plugin._insertSpacer.calledOnce) - - it "should load any filters in the Filter#options.filters array", -> - filters = [ - {label: 'filter1'} - {label: 'filter2'} - {label: 'filter3'} - ] - - plugin.options.filters = filters - plugin.pluginInit() - - for filter in filters - assert.isTrue(plugin.addFilter.calledWith(filter)) - - describe "_setupListeners", -> - it "should subscribe to all relevant events on the annotator", -> - plugin._setupListeners() - events = [ - 'annotationsLoaded', 'annotationCreated', - 'annotationUpdated', 'annotationDeleted' - ] - for event in events - assert.isTrue(plugin.annotator.subscribe.calledWith(event, plugin.updateHighlights)) - - describe "addFilter", -> - filter = null - - beforeEach -> - filter = { label: 'Tag', property: 'tags' } - plugin.addFilter(filter) - - it "should add a filter object to Filter#plugins", -> - assert.ok(plugin.filters[0]) - - it "should append the html to Filter#toolbar", -> - filter = plugin.filters[0] - assert.equal(filter.element[0], plugin.element.find('#annotator-filter-tags').parent()[0]) - - it "should store the filter in the elements data store under 'filter'", -> - filter = plugin.filters[0] - assert.equal(filter.element.data('filter'), filter) - - it "should not add a filter for a property that has already been loaded", -> - plugin.addFilter { label: 'Tag', property: 'tags' } - assert.lengthOf(plugin.filters, 1) - - describe "updateFilter", -> - filter = null - annotations = null - - beforeEach -> - filter = { - id: 'text' - label: 'Annotation' - property: 'text' - element: $('') - annotations: [], - isFiltered: (value, text) -> - text.indexOf('ca') != -1 - } - annotations = [ - {text: 'cat'} - {text: 'dog'} - {text: 'car'} - ] - - plugin.filters = {'text': filter} - plugin.highlights = { - map: -> annotations - } - - sinon.stub(plugin, 'updateHighlights') - sinon.stub(plugin, 'resetHighlights') - sinon.stub(plugin, 'filterHighlights') - - it "should call Filter#updateHighlights()", -> - plugin.updateFilter(filter) - assert(plugin.updateHighlights.calledOnce) - - it "should call Filter#resetHighlights()", -> - plugin.updateFilter(filter) - assert(plugin.resetHighlights.calledOnce) - - it "should filter the cat and car annotations", -> - plugin.updateFilter(filter) - assert.deepEqual(filter.annotations, [ - annotations[0], annotations[2] - ]) - - it "should call Filter#filterHighlights()", -> - plugin.updateFilter(filter) - assert(plugin.filterHighlights.calledOnce) - - it "should NOT call Filter#filterHighlights() if there is no input", -> - filter.element.find('input').val('') - plugin.updateFilter(filter) - assert.isFalse(plugin.filterHighlights.called) - - describe "updateHighlights", -> - beforeEach -> - plugin.highlights = null - plugin.updateHighlights() - - it "should fetch the visible highlights from the Annotator#element", -> - assert.isTrue(plugin.annotator.element.find.calledWith('.annotator-hl:visible')) - - it "should set the Filter#highlights property", -> - assert.ok(plugin.highlights) - - describe "filterHighlights", -> - div = null - - beforeEach -> - plugin.highlights = $('') - - # This annotation appears in both filters. - match = {highlights: [plugin.highlights[1]]} - plugin.filters = [ - { - annotations: [ - {highlights: [plugin.highlights[0]]} - match - ] - } - { - annotations: [ - {highlights: [plugin.highlights[4]]} - match - {highlights: [plugin.highlights[2]]} - ] - } - ] - div = $('
    ').append(plugin.highlights) - - it "should hide all highlights not whitelisted by _every_ filter", -> - plugin.filterHighlights() - - #Only index 1 should remain. - assert.lengthOf(div.find('.' + plugin.classes.hl.hide), 4) - - it "should hide all highlights not whitelisted by _every_ filter if every filter is active", -> - plugin.filters[1].annotations = [] - plugin.filterHighlights() - - assert.lengthOf(div.find('.' + plugin.classes.hl.hide), 3) - - it "should hide all highlights not whitelisted if only one filter", -> - plugin.filters = plugin.filters.slice(0, 1) - plugin.filterHighlights() - - assert.lengthOf(div.find('.' + plugin.classes.hl.hide), 3) - - describe "resetHighlights", -> - it "should remove the filter-hide class from all highlights", -> - plugin.highlights = $('').addClass(plugin.classes.hl.hide) - plugin.resetHighlights() - assert.lengthOf(plugin.highlights.filter('.' + plugin.classes.hl.hide), 0) - - describe "group: filter input actions", -> - filterElement = null - - beforeEach -> - filterElement = $(plugin.html.filter) - plugin.element.append(filterElement) - - describe "_onFilterFocus", -> - it "should add an active class to the element", -> - plugin._onFilterFocus({ - target: filterElement.find('input')[0] - }) - assert.isTrue(filterElement.hasClass(plugin.classes.active)) - - describe "_onFilterBlur", -> - it "should remove the active class from the element", -> - filterElement.addClass(plugin.classes.active) - plugin._onFilterBlur({ - target: filterElement.find('input')[0] - }) - assert.isFalse(filterElement.hasClass(plugin.classes.active)) - - it "should NOT remove the active class from the element if it has a value", -> - filterElement.addClass(plugin.classes.active) - plugin._onFilterBlur({ - target: filterElement.find('input').val('filtered')[0] - }) - assert.isTrue(filterElement.hasClass(plugin.classes.active)) - - describe "_onFilterKeyup", -> - beforeEach -> - plugin.filters = [{label: 'My Filter'}] - sinon.stub(plugin, 'updateFilter') - - it "should call Filter#updateFilter() with the relevant filter", -> - filterElement.data('filter', plugin.filters[0]) - plugin._onFilterKeyup({ - target: filterElement.find('input')[0] - }) - assert.isTrue(plugin.updateFilter.calledWith(plugin.filters[0])) - - it "should NOT call Filter#updateFilter() if no filter is found", -> - plugin._onFilterKeyup({ - target: filterElement.find('input')[0] - }) - assert.isFalse(plugin.updateFilter.called) - - describe "navigation", -> - element1 = null - element2 = null - element3 = null - annotation1 = null - annotation2 = null - annotation3 = null - - beforeEach -> - element1 = $('') - annotation1 = {text: 'annotation1', highlights: [element1[0]]} - element1.data('annotation', annotation1) - - element2 = $('') - annotation2 = {text: 'annotation2', highlights: [element2[0]]} - element2.data('annotation', annotation2) - - element3 = $('') - annotation3 = {text: 'annotation3', highlights: [element3[0]]} - element3.data('annotation', annotation3) - - plugin.highlights = $([element1[0],element2[0],element3[0]]) - sinon.spy(plugin, '_scrollToHighlight') - - describe "_onNextClick", -> - it "should advance to the next element", -> - element2.addClass(plugin.classes.hl.active) - plugin._onNextClick() - assert.isTrue(plugin._scrollToHighlight.calledWith([element3[0]])) - - it "should loop back to the start once it gets to the end", -> - element3.addClass(plugin.classes.hl.active) - plugin._onNextClick() - assert.isTrue(plugin._scrollToHighlight.calledWith([element1[0]])) - - it "should use the first element if there is no current element", -> - plugin._onNextClick() - assert.isTrue(plugin._scrollToHighlight.calledWith([element1[0]])) - - it "should only navigate through non hidden elements", -> - element1.addClass(plugin.classes.hl.active) - element2.addClass(plugin.classes.hl.hide) - plugin._onNextClick() - assert.isTrue(plugin._scrollToHighlight.calledWith([element3[0]])) - - it "should do nothing if there are no annotations", -> - plugin.highlights = $() - plugin._onNextClick() - assert.isFalse(plugin._scrollToHighlight.called) - - describe "_onPreviousClick", -> - it "should advance to the previous element", -> - element3.addClass(plugin.classes.hl.active) - plugin._onPreviousClick() - assert.isTrue(plugin._scrollToHighlight.calledWith([element2[0]])) - - it "should loop to the end once it gets to the beginning", -> - element1.addClass(plugin.classes.hl.active) - plugin._onPreviousClick() - assert.isTrue(plugin._scrollToHighlight.calledWith([element3[0]])) - - it "should use the last element if there is no current element", -> - plugin._onPreviousClick() - assert.isTrue(plugin._scrollToHighlight.calledWith([element3[0]])) - - it "should only navigate through non hidden elements", -> - element3.addClass(plugin.classes.hl.active) - element2.addClass(plugin.classes.hl.hide) - plugin._onPreviousClick() - assert.isTrue(plugin._scrollToHighlight.calledWith([element1[0]])) - - it "should do nothing if there are no annotations", -> - plugin.highlights = $() - plugin._onPreviousClick() - assert.isFalse(plugin._scrollToHighlight.called) - - describe "_scrollToHighlight", -> - mockjQuery = null - - beforeEach -> - plugin.highlights = $() - mockjQuery = { - addClass: sinon.spy() - animate: sinon.spy() - offset: sinon.stub().returns({top: 0}) - } - sinon.spy(plugin.highlights, 'removeClass') - sinon.stub(jQuery.prototype, 'init').returns(mockjQuery) - - afterEach -> - jQuery.prototype.init.restore() - - it "should remove active class from currently active element", -> - plugin._scrollToHighlight({}) - assert.isTrue(plugin.highlights.removeClass.calledWith(plugin.classes.hl.active)) - - it "should add active class to provided elements", -> - plugin._scrollToHighlight({}) - assert.isTrue(mockjQuery.addClass.calledWith(plugin.classes.hl.active)) - - it "should animate the scrollbar to the highlight offset", -> - plugin._scrollToHighlight({}) - assert(mockjQuery.offset.calledOnce) - assert(mockjQuery.animate.calledOnce) - - describe "_onClearClick", -> - mockjQuery = null - - beforeEach -> - mockjQuery = {} - mockjQuery.val = sinon.stub().returns(mockjQuery) - mockjQuery.prev = sinon.stub().returns(mockjQuery) - mockjQuery.keyup = sinon.stub().returns(mockjQuery) - mockjQuery.blur = sinon.stub().returns(mockjQuery) - - sinon.stub(jQuery.prototype, 'init').returns(mockjQuery) - plugin._onClearClick({target: {}}) - - afterEach -> - jQuery.prototype.init.restore() - - it "should clear the input", -> - assert.isTrue(mockjQuery.val.calledWith('')) - - it "should trigger the blur event", -> - assert(mockjQuery.blur.calledOnce) - - it "should trigger the keyup event", -> - assert(mockjQuery.keyup.calledOnce) diff --git a/test/spec/plugin/kitchensink_spec.coffee b/test/spec/plugin/kitchensink_spec.coffee deleted file mode 100644 index 12484a458..000000000 --- a/test/spec/plugin/kitchensink_spec.coffee +++ /dev/null @@ -1,94 +0,0 @@ -h = require('helpers') -Annotator = require('annotator') - -_ = require('../../../src/plugin/kitchensink') -Filter = require('../../../src/plugin/filter') - -class MockPlugin - constructor: -> - pluginInit: -> - -describe 'Annotator::setupPlugins', -> - annotator = null - $fix = null - - beforeEach -> - for p in ['AnnotateItPermissions', 'Auth', 'Markdown', 'Store', 'Tags', 'Unsupported'] - Annotator.Plugin[p] = MockPlugin - - h.addFixture('kitchensink') - $fix = $(h.fix()) - - afterEach -> h.clearFixtures() - - it 'should added to the Annotator prototype', -> - assert.equal(typeof Annotator::setupPlugins, 'function') - - describe 'called with no parameters', -> - _Showdown = null - - beforeEach -> - _Showdown = window.Showdown - annotator = new Annotator(h.fix()) - annotator.setupPlugins() - - afterEach -> window.Showdown = _Showdown - - describe 'it includes the Unsupported plugin', -> - it 'should add the Unsupported plugin by default', -> - assert.isDefined(annotator.plugins.Unsupported) - - describe 'it includes the Tags plugin', -> - it 'should add the Tags plugin by default', -> - assert.isDefined(annotator.plugins.Tags) - - describe 'it includes the Filter plugin', -> - filterPlugin = null - - beforeEach -> filterPlugin = annotator.plugins.Filter - - it 'should add the Filter plugin by default', -> - assert.isDefined(filterPlugin) - - it 'should have filters for annotations, tags and users', -> - expectedFilters = ['text', 'user', 'tags'] - for filter in expectedFilters - assert.isTrue(filter in (f.property for f in filterPlugin.filters)) - - describe 'and with Showdown loaded in the page', -> - it 'should add the Markdown plugin', -> - assert.isDefined(annotator.plugins.Markdown) - - describe 'called with AnnotateIt config', -> - beforeEach -> - annotator = new Annotator(h.fix()) - annotator.setupPlugins {}, - Filter: - appendTo: h.fix() - - it 'should add the Store plugin', -> - assert.isDefined(annotator.plugins.Store) - - it 'should add the AnnotateItPermissions plugin', -> - assert.isDefined(annotator.plugins.AnnotateItPermissions) - - it 'should add the Auth plugin', -> - assert.isDefined(annotator.plugins.Auth) - - describe 'called with plugin options', -> - beforeEach -> annotator = new Annotator(h.fix()) - - it 'should override default plugin options', -> - annotator.setupPlugins null, - AnnotateItPermissions: false - Filter: - filters: null - addAnnotationFilter: false - appendTo: h.fix() - - assert.lengthOf(annotator.plugins.Filter.filters, 0) - - it 'should NOT load a plugin if its key is set to null OR false', -> - annotator.setupPlugins null, {Filter: false, Tags: null} - assert.isUndefined(annotator.plugins.Tags) - assert.isUndefined(annotator.plugins.Filter) diff --git a/test/spec/plugin/markdown_spec.coffee b/test/spec/plugin/markdown_spec.coffee deleted file mode 100644 index f4b67d53c..000000000 --- a/test/spec/plugin/markdown_spec.coffee +++ /dev/null @@ -1,65 +0,0 @@ -Annotator = require('annotator') -Markdown = require('../../../src/plugin/markdown') - - -describe 'Annotator.Plugin.Markdown', -> - input = 'Is **this** [Markdown](http://daringfireball.com)?' - output = '

    Is this Markdown?

    ' - plugin = null - - - beforeEach -> - plugin = new Markdown($('
    ')[0]) - - describe "events", -> - it "should call Markdown#updateTextField() when annotationViewerTextField event is fired", -> - field = $('
    ')[0] - annotation = {text: 'test'} - - sinon.spy(plugin, 'updateTextField') - plugin.publish('annotationViewerTextField', [field, annotation]) - assert.isTrue(plugin.updateTextField.calledWith(field, annotation)) - - describe "constructor", -> - it "should create a new instance of Showdown", -> - assert.ok(plugin.converter) - - it "should log an error if Showdown is not loaded", -> - sinon.stub(console, 'error') - - converter = Showdown.converter - Showdown.converter = null - - plugin = new Markdown($('
    ')[0]) - assert(console.error.calledOnce) - - Showdown.converter = converter - console.error.restore() - - describe "updateTextField", -> - field = null - annotation = null - - beforeEach -> - field = $('
    ')[0] - annotation = {text: input} - sinon.stub(plugin, 'convert').returns(output) - sinon.stub(Annotator.Util, 'escape').returns(input) - - plugin.updateTextField(field, annotation) - - afterEach -> - Annotator.Util.escape.restore() - - it 'should process the annotation text as Markdown', -> - assert.isTrue(plugin.convert.calledWith(input)) - - it 'should update the content in the field', -> - assert.equal($(field).html(), output) - - it "should escape any existing HTML to prevent XSS", -> - assert.isTrue(Annotator.Util.escape.calledWith(input)) - - describe "convert", -> - it "should convert the provided text into markdown", -> - assert.equal(plugin.convert(input), output) diff --git a/test/spec/plugin/permissions_spec.coffee b/test/spec/plugin/permissions_spec.coffee deleted file mode 100644 index 996b4e1bf..000000000 --- a/test/spec/plugin/permissions_spec.coffee +++ /dev/null @@ -1,405 +0,0 @@ -Annotator = require('annotator') -Permissions = require('../../../src/plugin/permissions') - - -describe 'Annotator.Plugin.Permissions', -> - el = null - annotator = null - permissions = null - - beforeEach -> - el = $("
    ").appendTo('body')[0] - annotator = new Annotator($('
    ')[0]) - permissions = new Permissions(el) - permissions.annotator = annotator - permissions.pluginInit() - - afterEach -> $(el).remove() - - it "it should add the current user object to newly created annotations on beforeAnnotationCreated", -> - ann = {} - annotator.publish('beforeAnnotationCreated', [ann]) - assert.isUndefined(ann.user) - - ann = {} - permissions.setUser('alice') - annotator.publish('beforeAnnotationCreated', [ann]) - assert.equal(ann.user, 'alice') - - ann = {} - permissions.setUser({id: 'alice'}) - permissions.options.userId = (user) -> user.id - annotator.publish('beforeAnnotationCreated', [ann]) - assert.deepEqual(ann.user, {id: 'alice'}) - - it "it should add permissions to newly created annotations on beforeAnnotationCreated", -> - ann = {} - annotator.publish('beforeAnnotationCreated', [ann]) - assert.ok(ann.permissions) - - ann = {} - permissions.options.permissions = {} - annotator.publish('beforeAnnotationCreated', [ann]) - assert.deepEqual(ann.permissions, {}) - - describe 'pluginInit', -> - beforeEach -> - sinon.stub(annotator.viewer, 'addField') - sinon.stub(annotator.editor, 'addField') - - afterEach -> - annotator.viewer.addField.reset() - annotator.editor.addField.reset() - - it "should register a field with the Viewer", -> - permissions.pluginInit() - assert(annotator.viewer.addField.calledOnce) - - it "should register an two checkbox fields with the Editor", -> - permissions.pluginInit() - assert.equal(annotator.editor.addField.callCount, 2) - - it "should register an 'anyone can view' field with the Editor if showEditPermissionsCheckbox is true", -> - permissions.options.showViewPermissionsCheckbox = true - permissions.options.showEditPermissionsCheckbox = false - permissions.pluginInit() - assert.equal(annotator.editor.addField.callCount, 1) - - it "should register an 'anyone can edit' field with the Editor if showViewPermissionsCheckbox is true", -> - permissions.options.showViewPermissionsCheckbox = false - permissions.options.showEditPermissionsCheckbox = true - permissions.pluginInit() - assert.equal(permissions.annotator.editor.addField.callCount, 1) - - it "should register a filter if the Filter plugin is loaded", -> - permissions.annotator.plugins.Filter = {addFilter: sinon.spy()} - permissions.pluginInit() - assert(permissions.annotator.plugins.Filter.addFilter.calledOnce) - - describe 'authorize', -> - annotations = null - - describe 'Basic usage', -> - - beforeEach -> - annotations = [ - {} # Everything should be allowed - - { user: 'alice' } # Only alice should be allowed to edit/delete. - - { permissions: {} } # Everything should be allowed. - - { permissions: { # Anyone can read/edit/delete. - 'update': [] - } } - ] - - it 'should allow any action for an annotation with no authorisation info', -> - a = annotations[0] - assert.isTrue(permissions.authorize(null, a)) - assert.isTrue(permissions.authorize('foo', a)) - permissions.setUser('alice') - assert.isTrue(permissions.authorize(null, a)) - assert.isTrue(permissions.authorize('foo', a)) - - it 'should NOT allow any action if annotation.user and no @user is set', -> - a = annotations[1] - assert.isFalse(permissions.authorize(null, a)) - assert.isFalse(permissions.authorize('foo', a)) - - it 'should allow any action if @options.userId(@user) == annotation.user', -> - a = annotations[1] - permissions.setUser('alice') - assert.isTrue(permissions.authorize(null, a)) - assert.isTrue(permissions.authorize('foo', a)) - - it 'should NOT allow any action if @options.userId(@user) != annotation.user', -> - a = annotations[1] - permissions.setUser('bob') - assert.isFalse(permissions.authorize(null, a)) - assert.isFalse(permissions.authorize('foo', a)) - - it 'should allow any action if annotation.permissions == {}', -> - a = annotations[2] - assert.isTrue(permissions.authorize(null, a)) - assert.isTrue(permissions.authorize('foo', a)) - permissions.setUser('alice') - assert.isTrue(permissions.authorize(null, a)) - assert.isTrue(permissions.authorize('foo', a)) - - it 'should allow an action if annotation.permissions[action] == []', -> - a = annotations[3] - assert.isTrue(permissions.authorize('update', a)) - permissions.setUser('bob') - assert.isTrue(permissions.authorize('update', a)) - - describe 'Custom options.userAuthorize() callback', -> - - beforeEach -> - permissions.setUser(null) - - # Define a custom userAuthorize method to allow a more complex system - # - # This test is to ensure that the Permissions plugin can still handle - # users and groups as it did in a legacy version (commit fc22b76 and - # earlier). - # - # Here we allow custom permissions tokens that can handle both users - # and groups in the form "user:username" and "group:groupname". We - # then proved an options.userAuthorize() method that recieves a user - # and token and returns true if the current user meets the requirements - # set by the token. - # - # In this example it is assumed that all users (if present) are objects - # with an "id" and optional "groups" property. The group will default - # to "public" which means anyone can edit it. - permissions.options.userAuthorize = (action, annotation, user) -> - userGroups = (user) -> user?.groups || ['public'] - - tokenTest = (token, user) -> - if /^(?:group|user):/.test(token) - [key, values...] = token.split(':') - value = values.join(':') - - if key == 'group' - groups = userGroups(user) - return value in groups - - else if user and key == 'user' - return value == user.id - - if annotation.permissions - tokens = annotation.permissions[action] || [] - - for token in tokens - if tokenTest(token, user) - return true - - return false - - annotations = [ - { permissions: { # Anyone can update, assuming default @options.userGroups. - 'update': ['group:public'] - } } - - { permissions: { # Only alice can update. - 'update': ['user:alice'] - } } - - { permissions: { # alice and bob can both update. - 'update': ['user:alice', 'user:bob'] - } } - - { permissions: { # alice and bob can both update. Anyone for whom - # @options.userGroups(user) includes 'admin' can - # also update. - 'update': ['user:alice', 'user:bob', 'group:admin'] - } } - ] - - afterEach -> - delete permissions.options.userAuthorize - - it 'should (by default) allow an action if annotation.permissions[action] includes "group:public"', -> - a = annotations[0] - assert.isTrue(permissions.authorize('update', a)) - permissions.setUser({id: 'bob'}) - assert.isTrue(permissions.authorize('update', a)) - - it 'should (by default) allow an action if annotation.permissions[action] includes "user:@user"', -> - a = annotations[1] - assert.isFalse(permissions.authorize('update', a)) - permissions.setUser({id: 'bob'}) - assert.isFalse(permissions.authorize('update', a)) - permissions.setUser({id: 'alice'}) - assert.isTrue(permissions.authorize('update', a)) - - a = annotations[2] - permissions.setUser(null) - assert.isFalse(permissions.authorize('update', a)) - permissions.setUser({id: 'bob'}) - assert.isTrue(permissions.authorize('update', a)) - permissions.setUser({id: 'alice'}) - assert.isTrue(permissions.authorize('update', a)) - - it 'should allow an action if annotation.permissions[action] includes "user:@options.userId(@user)"', -> - a = annotations[1] - permissions.options.userId = (user) -> user?.id or null - - assert.isFalse(permissions.authorize('update', a)) - permissions.setUser({id: 'alice'}) - assert.isTrue(permissions.authorize('update', a)) - - it 'should allow an action if annotation.permissions[action] includes "user:@options.userId(@user)"', -> - a = annotations[3] - - assert.isFalse(permissions.authorize('update', a)) - permissions.setUser({id: 'foo', groups: ['other']}) - assert.isFalse(permissions.authorize('update', a)) - permissions.setUser({id: 'charlie', groups: ['admin']}) - assert.isTrue(permissions.authorize('update', a)) - - describe 'updateAnnotationPermissions', -> - field = null - checkbox = null - annotation = null - - beforeEach -> - checkbox = $('') - field = $('
  • ').append(checkbox)[0] - - annotation = {permissions: {'update': ['Alice']}} - - it "should NOT be world editable when 'Anyone can edit' checkbox is unchecked", -> - checkbox.removeAttr('checked') - permissions.updateAnnotationPermissions('update', field, annotation) - assert.isFalse(permissions.authorize('update', annotation, null)) - - it "should be world editable when 'Anyone can edit' checkbox is checked", -> - checkbox.attr('checked', 'checked') - permissions.updateAnnotationPermissions('update', field, annotation) - assert.isTrue(permissions.authorize('update', annotation, null)) - - it "should NOT be world editable when 'Anyone can edit' checkbox is unchecked for a second time", -> - checkbox.attr('checked', 'checked') - permissions.updateAnnotationPermissions('update', field, annotation) - assert.isTrue(permissions.authorize('update', annotation, null)) - - checkbox.removeAttr('checked') - permissions.updateAnnotationPermissions('update', field, annotation) - assert.isFalse(permissions.authorize('update', annotation, null)) - - it 'should consult the userId option when updating permissions', -> - annotation = {permissions: {}} - permissions.options.userId = (user) -> user.id - permissions.setUser({id: 3, name: 'Alice'}) - permissions.updateAnnotationPermissions('update', field, annotation); - assert.deepEqual(annotation.permissions, {'update': [3]}) - - describe 'updatePermissionsField', -> - field = null - checkbox = null - annotations = [ - {}, - {permissions: {'update': ['user:Alice']}}, - {permissions: {'update': ['user:Alice']}}, - {permissions: {'update': ['Alice'], 'admin': ['Alice']}} - {permissions: {'update': ['Alice'], 'admin': ['Bob']}} - ] - - beforeEach -> - checkbox = $('') - field = $('
  • ').append(checkbox).appendTo(permissions.element) - - permissions.setUser('Alice') - permissions.updatePermissionsField('update', field, annotations.shift()) - - afterEach -> field.remove() - - it "should have a checked checkbox when there are no permissions", -> - assert.isTrue(checkbox.is(':checked')) - - it "should have an unchecked checkbox when there are permissions", -> - assert.isFalse(checkbox.is(':checked')) - - it "should enable the checkbox by default", -> - assert.isTrue(checkbox.is(':enabled')) - - it "should display the field if the current user has 'admin' permissions", -> - assert.isTrue(field.is(':visible')) - - it "should NOT display the field if the current user does not have 'admin' permissions", -> - assert.isFalse(field.is(':visible')) - - describe 'updateViewer', -> - controls = null - field = null - - beforeEach -> - field = $('
    ').appendTo('
    ')[0] - controls = { - showEdit: sinon.spy() - hideEdit: sinon.spy() - showDelete: sinon.spy() - hideDelete: sinon.spy() - } - - describe 'coarse grained updates based on user', -> - annotations = null - - beforeEach -> - permissions.setUser('alice') - annotations = [{user: 'alice'}, {user: 'bob'}, {}] - - it "it should display annotations' users in the viewer element", -> - permissions.updateViewer(field, annotations[0], controls) - assert.equal($(field).html(), 'alice') - assert.lengthOf($(field).parent(), 1) - - it "it should remove the field if annotation has no user", -> - permissions.updateViewer(field, {}, controls) - assert.lengthOf($(field).parent(), 0) - - it "it should remove the field if annotation has no user string", -> - permissions.options.userString = -> null - - permissions.updateViewer(field, annotations[1], controls) - assert.lengthOf($(field).parent(), 0) - - it "it should remove the field if annotation has empty user string", -> - permissions.options.userString = -> '' - permissions.updateViewer(field, annotations[1], controls) - assert.lengthOf($(field).parent(), 0) - - it "should hide controls for users other than the current user", -> - permissions.updateViewer(field, annotations[0], controls) - assert.isFalse(controls.hideEdit.called) - assert.isFalse(controls.hideDelete.called) - - permissions.updateViewer(field, annotations[1], controls) - assert(controls.hideEdit.calledOnce) - assert(controls.hideDelete.calledOnce) - - it "should show controls for annotations without a user", -> - permissions.updateViewer(field, annotations[2], controls) - assert.isFalse(controls.hideEdit.called) - assert.isFalse(controls.hideDelete.called) - - describe 'fine-grained use (user and permissions)', -> - annotations = null - - beforeEach -> - annotations = [ - { - user: 'alice' - permissions: { - 'update': ['alice'] - 'delete': ['alice'] - } - }, - { - user: 'bob' - permissions: { - 'update': ['bob'], - 'delete': ['bob'] - } - } - ] - - permissions.setUser('bob') - - it "it should should hide edit button if user cannot update", -> - permissions.updateViewer(field, annotations[0], controls) - assert(controls.hideEdit.calledOnce) - - it "it should should show edit button if user can update", -> - permissions.updateViewer(field, annotations[1], controls) - assert.isFalse(controls.hideEdit.called) - - it "it should should hide delete button if user cannot delete", -> - permissions.updateViewer(field, annotations[0], controls) - assert(controls.hideDelete.calledOnce) - - it "it should should show delete button if user can delete", -> - permissions.updateViewer(field, annotations[1], controls) - assert.isFalse(controls.hideDelete.called) diff --git a/test/spec/plugin/store_spec.coffee b/test/spec/plugin/store_spec.coffee deleted file mode 100644 index 075021868..000000000 --- a/test/spec/plugin/store_spec.coffee +++ /dev/null @@ -1,200 +0,0 @@ -Annotator = require('annotator') -Store = require('../../../src/plugin/store') - -describe "Annotator.Plugin.Store", -> - store = null - server = null - - beforeEach -> - store = new Annotator.Plugin.Store() - sinon.stub($, 'ajax').returns({}) - - afterEach -> - $.ajax.restore() - - xit "should somehow ensure that it sends auth tokens if necessary" - # authMock = { - # withToken: sinon.spy() - # } - # store.annotator.plugins.Auth = authMock - - # store.pluginInit() - # assert.isTrue(authMock.withToken.calledWith(store._getAnnotations)) - - it "create should trigger a POST request", -> - store.create({text: "Donkeys on giraffes"}) - [_, opts] = $.ajax.args[0] - assert.equal("POST", opts.type) - - it "update should trigger a PUT request", -> - store.update({text: "Donkeys on giraffes", id: 123}) - [_, opts] = $.ajax.args[0] - assert.equal("PUT", opts.type) - - it "delete should trigger a DELETE request", -> - store.delete({text: "Donkeys on giraffes", id: 123}) - [_, opts] = $.ajax.args[0] - assert.equal("DELETE", opts.type) - - it "create URL should be /store/annotations by default", -> - store.create({text: "Donkeys on giraffes"}) - [url, _] = $.ajax.args[0] - assert.equal("/store/annotations", url) - - it "update URL should be /store/annotations/:id by default", -> - store.update({text: "Donkeys on giraffes", id: 123}) - [url, _] = $.ajax.args[0] - assert.equal("/store/annotations/123", url) - - it "delete URL should be /store/annotations/:id by default", -> - store.delete({text: "Donkeys on giraffes", id: 123}) - [url, _] = $.ajax.args[0] - assert.equal("/store/annotations/123", url) - - it "should request custom URLs as specified by its options", -> - store.options.prefix = '/some/prefix' - store.options.urls.create = '/createMe' - store.options.urls.update = '/:id/updateMe' - store.options.urls.destroy = '/:id/destroyMe' - - store.create({text: "Donkeys on giraffes"}) - store.update({text: "Donkeys on giraffes", id: 123}) - store.delete({text: "Donkeys on giraffes", id: 123}) - - [url, _] = $.ajax.args[0] - assert.equal('/some/prefix/createMe', url) - - [url, _] = $.ajax.args[1] - assert.equal('/some/prefix/123/updateMe', url) - - [url, _] = $.ajax.args[2] - assert.equal('/some/prefix/123/destroyMe', url) - - it "should generate URLs correctly with an empty prefix", -> - store.options.prefix = '' - store.options.urls.create = '/createMe' - store.options.urls.update = '/:id/updateMe' - store.options.urls.destroy = '/:id/destroyMe' - - store.create({text: "Donkeys on giraffes"}) - store.update({text: "Donkeys on giraffes", id: 123}) - store.delete({text: "Donkeys on giraffes", id: 123}) - - [url, _] = $.ajax.args[0] - assert.equal('/createMe', url) - - [url, _] = $.ajax.args[1] - assert.equal('/123/updateMe', url) - - [url, _] = $.ajax.args[2] - assert.equal('/123/destroyMe', url) - - it "should generate URLs with substitution markers in query strings", -> - store.options.prefix = '/some/prefix' - store.options.urls.update = '/update?foo&id=:id' - store.options.urls.destroy = '/delete?id=:id&foo' - - store.update({text: "Donkeys on giraffes", id: 123}) - store.delete({text: "Donkeys on giraffes", id: 123}) - - [url, _] = $.ajax.args[0] - assert.equal('/some/prefix/update?foo&id=123', url) - - [url, _] = $.ajax.args[1] - assert.equal('/some/prefix/delete?id=123&foo', url) - - xit "should allow plugins to set custom headers (...from the data property 'annotator:headers'?)" - # sinon.stub(store, '_methodFor').returns('GET') - # sinon.stub(store.element, 'data').returns({ - # 'x-custom-header-one': 'mycustomheader' - # 'x-custom-header-two': 'mycustomheadertwo' - # 'x-custom-header-three': 'mycustomheaderthree' - # }) - - # action = 'read' - # data = {} - - # options = store._apiRequestOptions(action, data) - - # assert.deepEqual(options.headers, { - # 'x-custom-header-one': 'mycustomheader' - # 'x-custom-header-two': 'mycustomheadertwo' - # 'x-custom-header-three': 'mycustomheaderthree' - # }) - - - it "should emulate new-fangled HTTP if emulateHTTP is true", -> - store.options.emulateHTTP = true - store.delete({text: "Donkeys on giraffes", id: 123}) - [_, opts] = $.ajax.args[0] - - assert.equal(opts.type, 'POST') - assert.deepEqual(opts.headers, 'X-HTTP-Method-Override': 'DELETE') - - it "should emulate proper JSON handling if emulateJSON is true", -> - store.options.emulateJSON = true - store.delete({id: 123}) - [_, opts] = $.ajax.args[0] - - assert.deepEqual({json: '{"id":123}'}, opts.data) - assert.isUndefined(opts.contentType) - - it "should append _method to the form data if emulateHTTP and emulateJSON are both true", -> - store.options.emulateHTTP = true - store.options.emulateJSON = true - store.delete({id: 123}) - [_, opts] = $.ajax.args[0] - - assert.deepEqual(opts.data, { - _method: 'DELETE', - json: '{"id":123}', - }) - - describe "_onError", -> - message = null - requests = [ - {} - {} - {_action: 'read', _id: 'jim'} - {_action: 'search'} - {_action: 'read'} - {status: 401, _action: 'delete', '_id': 'cake'} - {status: 404, _action: 'delete', '_id': 'cake'} - {status: 500, _action: 'delete', '_id': 'cake'} - ] - - beforeEach -> - sinon.stub(Annotator, 'showNotification') - sinon.stub(console, 'error') - - store._onError requests.shift() - message = Annotator.showNotification.lastCall.args[0] - - afterEach -> - Annotator.showNotification.restore() - console.error.restore() - - it "should call call Annotator.showNotification() with a message and error style", -> - assert(Annotator.showNotification.calledOnce) - assert.equal(Annotator.showNotification.lastCall.args[1], Annotator.Notification.ERROR) - - it "should call console.error with a message", -> - assert(console.error.calledOnce) - - it "should give a default message if xhr.status id not provided", -> - assert.equal(message, "Sorry we could not read this annotation") - - it "should give a default specific message if xhr._action is 'search'", -> - assert.equal(message, "Sorry we could not search the store for annotations") - - it "should give a default specific message if xhr._action is 'read' and there is no xhr._id", -> - assert.equal(message, "Sorry we could not read the annotations from the store") - - it "should give a specific message if xhr.status == 401", -> - assert.equal(message, "Sorry you are not allowed to delete this annotation") - - it "should give a specific message if xhr.status == 404", -> - assert.equal(message, "Sorry we could not connect to the annotations store") - - it "should give a specific message if xhr.status == 500", -> - assert.equal(message, "Sorry something went wrong with the annotation store") diff --git a/test/spec/plugin/tags_spec.coffee b/test/spec/plugin/tags_spec.coffee deleted file mode 100644 index feac70729..000000000 --- a/test/spec/plugin/tags_spec.coffee +++ /dev/null @@ -1,93 +0,0 @@ -Annotator = require('annotator') -Tags = require('../../../src/plugin/tags') - - -describe 'Annotator.Plugin.Tags', -> - annotator = null - plugin = null - - beforeEach -> - el = $("
    ")[0] - annotator = new Annotator($('
    ')[0]) - plugin = new Tags(el) - plugin.annotator = annotator - plugin.pluginInit() - - it "should parse whitespace-delimited tags into an array", -> - str = 'one two three\tfourFive' - assert.deepEqual(plugin.parseTags(str), ['one', 'two', 'three', 'fourFive']) - - it "should stringify a tags array into a space-delimited string", -> - ary = ['one', 'two', 'three'] - assert.equal(plugin.stringifyTags(ary), "one two three") - - describe "pluginInit", -> - it "should add a field to the editor", -> - sinon.spy(annotator.editor, 'addField') - plugin.pluginInit() - assert(annotator.editor.addField.calledOnce) - - it "should register a filter if the Filter plugin is loaded", -> - plugin.annotator.plugins.Filter = {addFilter: sinon.spy()} - plugin.pluginInit() - assert(plugin.annotator.plugins.Filter.addFilter.calledOnce) - - describe "updateField", -> - it "should set the value of the input", -> - annotation = {tags: ['apples', 'oranges', 'pears']} - plugin.updateField(plugin.field, annotation) - - assert.equal(plugin.input.val(), 'apples oranges pears') - - it "should set the clear the value of the input if there are no tags", -> - annotation = {} - plugin.input.val('apples pears oranges') - plugin.updateField(plugin.field, annotation) - - assert.equal(plugin.input.val(), '') - - describe "setAnnotationTags", -> - it "should set the annotation's tags", -> - annotation = {} - plugin.input.val('apples oranges pears') - plugin.setAnnotationTags(plugin.field, annotation) - - assert.deepEqual(annotation.tags, ['apples', 'oranges', 'pears']) - - describe "updateViewer", -> - it "should insert the tags into the field", -> - annotation = { tags: ['foo', 'bar', 'baz'] } - field = $('
    ')[0] - - plugin.updateViewer(field, annotation) - assert.deepEqual($(field).html(), [ - 'foo' - 'bar' - 'baz' - ].join(' ')) - - it "should remove the field if there are no tags", -> - annotation = { tags: [] } - field = $('
    ')[0] - - plugin.updateViewer(field, annotation) - assert.lengthOf($(field).parent(), 0) - - annotation = {} - field = $('
    ')[0] - - plugin.updateViewer(field, annotation) - assert.lengthOf($(field).parent(), 0) - - -describe 'Annotator.Plugin.Tags.filterCallback', -> - filter = null - beforeEach -> filter = Tags.filterCallback - - it 'should return true if all tags are matched by keywords', -> - assert.isTrue(filter('cat dog mouse', ['cat', 'dog', 'mouse'])) - assert.isTrue(filter('cat dog', ['cat', 'dog', 'mouse'])) - - it 'should NOT return true if all tags are NOT matched by keywords', -> - assert.isFalse(filter('cat dog', ['cat'])) - assert.isFalse(filter('cat dog', [])) diff --git a/test/spec/plugin/unsupported_spec.coffee b/test/spec/plugin/unsupported_spec.coffee deleted file mode 100644 index 77788e9c5..000000000 --- a/test/spec/plugin/unsupported_spec.coffee +++ /dev/null @@ -1 +0,0 @@ -Unsupported = require('../../../src/plugin/unsupported') diff --git a/test/spec/range_spec.coffee b/test/spec/range_spec.coffee deleted file mode 100644 index 578a9be4d..000000000 --- a/test/spec/range_spec.coffee +++ /dev/null @@ -1,237 +0,0 @@ -h = require('helpers') - -Range = require('../../src/range') -Util = require('../../src/util') - -testData = [ - [ 0, 13, 0, 27, "habitant morbi", "Partial node contents." ] - [ 0, 0, 0, 37, "Pellentesque habitant morbi tristique", "Full node contents, textNode refs." ] - [ '/p/strong', 0, '/p/strong', 1, "Pellentesque habitant morbi tristique", "Full node contents, elementNode refs." ] - [ 0, 22, 1, 12, "morbi tristique senectus et", "Spanning 2 nodes." ] - [ '/p/strong', 0, 1, 12, "Pellentesque habitant morbi tristique senectus et", "Spanning 2 nodes, elementNode start ref." ] - [ 1, 165, '/p/em', 1, "egestas semper. Aenean ultricies mi vitae est.", "Spanning 2 nodes, elementNode end ref." ] - [ 9, 7, 12, 11, "Level 2\n\n\n Lorem ipsum", "Spanning multiple nodes, textNode refs." ] - [ '/p', 0, '/p', 8, "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi. Aenean fermentum, elit eget tincidunt condimentum, eros ipsum rutrum orci, sagittis tempus lacus enim ac dui. Donec non enim in turpis pulvinar facilisis. Ut felis.", "Spanning multiple nodes, elementNode refs." ] - [ '/p[2]', 0, '/p[2]', 1, "Lorem sed do eiusmod tempor.", "Full node contents with empty node at end."] - [ "/div/text()[2]",0,"/div/text()[2]",28,"Lorem sed do eiusmod tempor.", "Text between br tags, textNode refs"] - [ "/div/text()[2]",0,"/div", 4,"Lorem sed do eiusmod tempor.", "Text between br tags, elementNode ref at end"] - [ "/div/text()[2]",0,"/div", 5,"Lorem sed do eiusmod tempor.", "Text between br tags, with
    at end"] - [ "/div/text()[2]",0,"/div", 6,"Lorem sed do eiusmod tempor.", "Text between br tags, with

    at end"] - [ "/div/text()[2]",0,"/div", 7,"Lorem sed do eiusmod tempor.", "Text between br tags, with


    at end"] - [ "/div", 3,"/div/text()[2]",28,"Lorem sed do eiusmod tempor.", "Text between br tags, elementNode ref at start"] - [ "/div", 2,"/div/text()[2]",28,"Lorem sed do eiusmod tempor.", "Text between br tags, with
    at start"] - [ "/div", 1,"/div/text()[2]",28,"Lorem sed do eiusmod tempor.", "Text between br tags, with

    at start"] - [ "/div[2]/text()[2]",0,"/div[2]/text()[2]",28,"Lorem sed do eiusmod tempor.", "Text between br tags, textNode refs"] - [ "/div[2]/text()[2]",0,"/div[2]",4,"Lorem sed do eiusmod tempor.", "Text between br tags, elementNode ref at end"] - [ "/div[2]/text()[2]",0,"/div[2]",5,"Lorem sed do eiusmod tempor.", "Text between br tags, with
    at end"] - [ "/div[2]/text()[2]",0,"/div[2]",6,"Lorem sed do eiusmod tempor.", "Text between br tags, with


    at end"] - [ "/div[2]/text()[2]",0,"/div[2]",7,"Lorem sed do eiusmod tempor.", "Text between br tags, with



    at end"] - [ "/div[2]", 3,"/div[2]/text()[2]",28,"Lorem sed do eiusmod tempor.", "Text between br tags, elementNode ref at start"] - [ "/div[2]", 2,"/div[2]/text()[2]",28,"Lorem sed do eiusmod tempor.", "Text between br tags, with


    at the start"] - [ "/div[2]", 1,"/div[2]/text()[2]",28,"Lorem sed do eiusmod tempor.", "Text between br tags, with


    at the start"], - [ "/h2[2]", 0,"/p[4]", 0, "Header Level 2\n\n\n Mauris lacinia ipsum nulla, id iaculis quam egestas quis.\n\n\n", "No text node at the end and offset 0"] -] - -describe 'Range', -> - r = null - mockSelection = null - - beforeEach -> - h.addFixture('range') - mockSelection = (ii) -> new h.MockSelection(h.fix(), testData[ii]) - - afterEach -> - delete a - h.clearFixtures() - - describe ".nodeFromXPath()", -> - xpath = "/html/body/div/p/strong" - it "should parse a standard xpath string", -> - node = Range.nodeFromXPath xpath - assert.equal(node, $('strong')[0]) - - it "should parse an standard xpath string for an xml document", -> - $.isXMLDoc = -> true - node = Range.nodeFromXPath xpath - assert.equal(node, $('strong')[0]) - - describe "SerializedRange", -> - beforeEach -> - - # This is needed so that we can read ranges via selection API - $(h.fix()).show() - - r = new Range.SerializedRange - start: "/p/strong" - startOffset: 13 - end: "/p/strong" - endOffset: 27 - - afterEach -> - $(h.fix()).hide() - - describe "normalize", -> - it "should return a normalized range", -> - norm = r.normalize(h.fix()) - assert.isTrue(norm instanceof Range.NormalizedRange) - assert.equal(norm.text(), "habitant morbi") - - it "should return a normalized range with 0 offsets", -> - r.startOffset = 0 - norm = r.normalize(h.fix()) - assert.isTrue(norm instanceof Range.NormalizedRange) - assert.equal(norm.text(), "Pellentesque habitant morbi") - - it "should always find the right text elements, based on offset", -> - - # Create a normalized range to find the text node. - # This will split text nodes. - norm = r.normalize h.fix() - - # We should get the usual text - assert.equal(norm.start.data, "habitant morbi") - assert.equal(norm.text(), "habitant morbi") - assert.equal(Util.readRangeViaSelection(norm), "habitant morbi") - - # Now let's insert a
    tag before and after the text node! - # (Since the
    tag is not a text node, this should not change - # the text nodes and their offsets.) - hr1 = document.createElement "hr" - hr2 = document.createElement "hr" - norm.start.parentNode.insertBefore hr1, norm.start - norm.start.parentNode.insertBefore hr2, norm.start.nextSibling - - # Now let's try to normalize the same range again, - # this time working with the text nodes already split by last action - norm = r.normalize h.fix() - - # We should get the same text as last time: - assert.equal(Util.readRangeViaSelection(norm), "habitant morbi") - assert.equal(norm.text(), "habitant morbi") - - it "should raise Range.RangeError if it cannot normalize the range", -> - check = false - try - r.normalize($('
    ')[0]) - catch e - if e instanceof Range.RangeError - check = true - - assert.isTrue(check) - - it "serialize() returns a serialized range", -> - seri = r.serialize(h.fix()) - assert.equal(seri.start, "/p[1]/strong[1]") - assert.equal(seri.startOffset, 13) - assert.equal(seri.end, "/p[1]/strong[1]") - assert.equal(seri.endOffset, 27) - assert.isTrue(seri instanceof Range.SerializedRange) - - it "toObject() returns a simple object", -> - obj = r.toObject() - assert.equal(obj.start, "/p/strong") - assert.equal(obj.startOffset, 13) - assert.equal(obj.end, "/p/strong") - assert.equal(obj.endOffset, 27) - assert.equal(JSON.stringify(obj), '{"start":"/p/strong","startOffset":13,"end":"/p/strong","endOffset":27}') - - describe "BrowserRange", -> - beforeEach -> - sel = mockSelection(0) - r = new Range.BrowserRange(sel.getRangeAt(0)) - - it "normalize() returns a normalized range", -> - norm = r.normalize() - assert.equal(norm.start, norm.end) - assert.equal(h.textInNormedRange(norm), 'habitant morbi') - - testBrowserRange = (i) -> - -> - sel = mockSelection(i) - range = new Range.BrowserRange(sel.getRangeAt(0)) - norm = range.normalize(h.fix()) - - assert.equal(h.textInNormedRange(norm), sel.expectation) - - for i in [0...testData.length] - it "should parse test range #{i} (#{testData[i][5]})", testBrowserRange(i) - - describe "NormalizedRange", -> - sel = null - - beforeEach -> - sel = mockSelection(7) - browserRange = new Range.BrowserRange(sel.getRangeAt(0)) - r = browserRange.normalize() - - it "textNodes() returns an array of textNodes", -> - textNodes = r.textNodes() - - assert.equal($.type(textNodes), 'array') - assert.lengthOf(textNodes, sel.endOffset) - - # Should contain the contents of the first element. - assert.equal(textNodes[0].nodeValue, 'Pellentesque habitant morbi tristique') - - it "text() returns the textual contents of the range", -> - assert.equal(r.text(), sel.expectation) - - describe "limit", -> - headText = null - paraText = null - paraText2 = null - para = null - root = null - - beforeEach -> - headText = document.createTextNode("My Heading") - paraText = document.createTextNode("My paragraph") - paraText2 = document.createTextNode(" continues") - - head = document.createElement('h1') - head.appendChild(headText) - para = document.createElement('p') - para.appendChild(paraText) - para.appendChild(paraText2) - - root = document.createElement('div') - root.appendChild(head) - root.appendChild(para) - - it "should exclude any nodes not within the bounding element.", -> - range = new Range.NormalizedRange({ - commonAncestor: root - start: headText - end: paraText2 - }) - - range = range.limit(para) - assert.equal(range.commonAncestor, para) - assert.equal(range.start, paraText) - assert.equal(range.end, paraText2) - - it "should return null if no nodes fall within the bounds", -> - otherDiv = document.createElement('div') - range = new Range.NormalizedRange({ - commonAncestor: root - start: headText - end: paraText2 - }) - assert.equal(range.limit(otherDiv), null) - - describe "toRange", -> - it "should return a new Range object", -> - mockRange = - setStartBefore: sinon.spy() - setEndAfter: sinon.spy() - - sinon.stub(document, 'createRange').returns(mockRange) - r.toRange() - - assert(document.createRange.calledOnce) - assert(mockRange.setStartBefore.calledOnce) - assert.isTrue(mockRange.setStartBefore.calledWith(r.start)) - assert(mockRange.setEndAfter.calledOnce) - assert.isTrue(mockRange.setEndAfter.calledWith(r.end)) - - document.createRange.restore() diff --git a/test/spec/registry_spec.coffee b/test/spec/registry_spec.coffee deleted file mode 100644 index 9a9590e28..000000000 --- a/test/spec/registry_spec.coffee +++ /dev/null @@ -1,39 +0,0 @@ -Registry = require('../../src/registry') - - -describe 'Registry', -> - s = {} - r = null - m = null - - beforeEach -> - s = {} - r = new Registry(s) - - m = - configure: sinon.spy((registry) -> registry['foo'] = 'bar') - run: sinon.stub() - - it 'should take a settings Object as its first constructor argument', -> - assert.equal(r.settings, s) - - describe '#include()', -> - - it 'should invoke the configure method of the passed module with itself', -> - r.include(m) - assert(m.configure.calledWith(r)) - - describe '#run()', -> - - it 'should include the application module', -> - sinon.spy(r, 'include') - r.run(m) - assert(r.include.calledWith(m)) - - it 'should extend the application with registry extensions', -> - r.run(m) - assert.equal(m['foo'], 'bar') - - it 'should invoke the run method fo the passed module with itself', -> - r.run(m) - assert(m.run.calledWith(r)) diff --git a/test/spec/registry_spec.js b/test/spec/registry_spec.js new file mode 100644 index 000000000..566606ed0 --- /dev/null +++ b/test/spec/registry_spec.js @@ -0,0 +1,34 @@ +var assert = require('assertive-chai').assert; + +var registry = require('../../src/registry'); + +describe('Registry', function () { + var r; + + beforeEach(function () { + r = new registry.Registry(); + }); + + it('registerUtility registers a object as a named utility', function () { + var o = {}; + r.registerUtility(o, 'foo'); + assert.strictEqual(o, r.getUtility('foo')); + }); + + it('getUtility returns the most recently registered object', function () { + var o = {}, p = {}; + r.registerUtility(o, 'foo'); + r.registerUtility(p, 'foo'); + assert.strictEqual(p, r.getUtility('foo')); + }); + + it('getUtility throws LookupError if no utility is registered', function () { + var fn = function () { r.getUtility('foo'); }; + assert.throws(fn, registry.LookupError); + }); + + it('queryUtility returns null if no utility is registered', function () { + var res = r.queryUtility('foo'); + assert.isNull(res); + }); +}); diff --git a/test/spec/storage/httpstorage_spec.js b/test/spec/storage/httpstorage_spec.js new file mode 100644 index 000000000..f779d3ac1 --- /dev/null +++ b/test/spec/storage/httpstorage_spec.js @@ -0,0 +1,202 @@ +var assert = require('assertive-chai').assert; + +var storage = require('../../../src/storage'); + +describe("storage.HttpStorage", function () { + var store, xhr, lastReq; + + beforeEach(function () { + lastReq = null; + store = new storage.HttpStorage(); + xhr = sinon.useFakeXMLHttpRequest(); + xhr.onCreate = function (r) { + lastReq = r; + }; + }); + + afterEach(function () { + xhr.restore(); + }); + + it("create should trigger a POST request", function () { + store.create({ + text: "Donkeys on giraffes" + }); + assert.equal(lastReq.method, "POST"); + }); + + it("update should trigger a PUT request", function () { + store.update({ + text: "Donkeys on giraffes", + id: 123 + }); + assert.equal(lastReq.method, "PUT"); + }); + + it("delete should trigger a DELETE request", function () { + store["delete"]({ + text: "Donkeys on giraffes", + id: 123 + }); + assert.equal(lastReq.method, "DELETE"); + }); + + it("create URL should be /store/annotations by default", function () { + store.create({ + text: "Donkeys on giraffes" + }); + assert.equal(lastReq.url, "/store/annotations"); + }); + + it("update URL should be /store/annotations/{id} by default", function () { + store.update({ + text: "Donkeys on giraffes", + id: 123 + }); + assert.equal(lastReq.url, "/store/annotations/123"); + }); + + it("delete URL should be /store/annotations/{id} by default", function () { + store["delete"]({ + text: "Donkeys on giraffes", + id: 123 + }); + assert.equal(lastReq.url, "/store/annotations/123"); + }); + + it("should request custom URLs as specified by its options", function () { + store.options.prefix = '/some/prefix'; + store.options.urls.create = '/createMe'; + store.options.urls.update = '/{id}/updateMe'; + store.options.urls.destroy = '/{id}/destroyMe'; + + store.create({ + text: "Donkeys on giraffes" + }); + assert.equal(lastReq.url, '/some/prefix/createMe'); + + store.update({ + text: "Donkeys on giraffes", + id: 123 + }); + assert.equal(lastReq.url, '/some/prefix/123/updateMe'); + + store["delete"]({ + text: "Donkeys on giraffes", + id: 123 + }); + assert.equal(lastReq.url, '/some/prefix/123/destroyMe'); + }); + + it("should generate URLs correctly with an empty prefix", function () { + store.options.prefix = ''; + store.options.urls.create = '/createMe'; + store.options.urls.update = '/{id}/updateMe'; + store.options.urls.destroy = '/{id}/destroyMe'; + + store.create({ + text: "Donkeys on giraffes" + }); + assert.equal(lastReq.url, '/createMe'); + + store.update({ + text: "Donkeys on giraffes", + id: 123 + }); + assert.equal(lastReq.url, '/123/updateMe'); + + store["delete"]({ + text: "Donkeys on giraffes", + id: 123 + }); + assert.equal(lastReq.url, '/123/destroyMe'); + }); + + it("should generate URLs with substitution markers in query strings", function () { + store.options.prefix = '/some/prefix'; + store.options.urls.update = '/update?foo&id={id}'; + store.options.urls.destroy = '/delete?id={id}&foo'; + + store.update({ + text: "Donkeys on giraffes", + id: 123 + }); + assert.equal(lastReq.url, '/some/prefix/update?foo&id=123'); + + store["delete"]({ + text: "Donkeys on giraffes", + id: 123 + }); + assert.equal(lastReq.url, '/some/prefix/delete?id=123&foo'); + }); + + it("should send custom headers added with setHeader", function () { + store.setHeader('Fruit', 'Apple'); + store.setHeader('Colour', 'Green'); + store.create({ + text: "Donkeys on giraffes" + }); + assert.equal(lastReq.requestHeaders.Fruit, 'Apple'); + assert.equal(lastReq.requestHeaders.Colour, 'Green'); + }); + + it("should emulate new-fangled HTTP if emulateHTTP is true", function () { + store.options.emulateHTTP = true; + store["delete"]({ + text: "Donkeys on giraffes", + id: 123 + }); + assert.equal(lastReq.method, 'POST'); + assert.equal( + lastReq.requestHeaders['X-HTTP-Method-Override'], + 'DELETE' + ); + }); + + it("should emulate proper JSON handling if emulateJSON is true", function () { + store.options.emulateJSON = true; + store["delete"]({ + id: 123 + }); + assert.equal( + lastReq.requestBody, + 'json=' + encodeURIComponent('{"id":123}') + ); + assert.equal( + lastReq.requestHeaders['Content-Type'], + 'application/x-www-form-urlencoded;charset=utf-8' + ); + }); + + it("should append _method to the form data if emulateHTTP and emulateJSON are both true", function () { + store.options.emulateHTTP = true; + store.options.emulateJSON = true; + store["delete"]({ + id: 123 + }); + assert.include(lastReq.requestBody, '_method=DELETE'); + }); + + describe("error handling", function () { + var onError; + + beforeEach(function () { + onError = sinon.spy(); + }); + + it("calls the onError handler when an error occurs", function (done) { + store = new storage.HttpStorage({ + onError: onError + }); + var res = store.create({text: "Donkeys on giraffes"}); + + lastReq.respond(400); + + var check = function () { + sinon.assert.calledOnce(onError); + done(); + }; + res.then(check, check); + }); + }); +}); diff --git a/test/spec/storage/storageadapter_spec.js b/test/spec/storage/storageadapter_spec.js new file mode 100644 index 000000000..33798f850 --- /dev/null +++ b/test/spec/storage/storageadapter_spec.js @@ -0,0 +1,391 @@ +var assert = require('assertive-chai').assert; + +var storage = require('../../../src/storage'), + util = require('../../../src/util'); + +var Promise = util.Promise; + + +function MockHookRunner() { + this.calls = []; + this.runHook = (function (_this) { + return function (name, args) { + _this.calls.push({ + name: name, + args: args + }); + return Promise.resolve(); + }; + })(this); + return this; +} + + +function MockStorage() {} + +MockStorage.prototype.create = function (annotation) { + annotation.stored = true; + this._record('create'); + return annotation; +}; + +MockStorage.prototype.update = function (annotation) { + annotation.stored = true; + this._record('update'); + return annotation; +}; + +MockStorage.prototype["delete"] = function (annotation) { + annotation.stored = true; + this._record('delete'); + return annotation; +}; + +MockStorage.prototype.query = function () { + this._record('query'); + return {results: [{id: 'foo'}], meta: {total: 1}}; +}; + +MockStorage.prototype._record = function (name) { + if (typeof this._callRecorder === 'function') { + return this._callRecorder(name); + } +}; + + +function FailingMockStorage() {} + +FailingMockStorage.prototype.create = function () { + return Promise.reject("failure message"); +}; + +FailingMockStorage.prototype.update = function () { + return Promise.reject("failure message"); +}; + +FailingMockStorage.prototype["delete"] = function () { + return Promise.reject("failure message"); +}; + +FailingMockStorage.prototype.query = function () { + return Promise.reject("failure message"); +}; + + +function keyAbsent(key) { + return sinon.match(function (val) { + return !(key in val); + }, String(key) + " was found in object"); +} + +describe('storage.StorageAdapter', function () { + var noop = function () { return Promise.resolve(); }, + a = null, + s = null, + sandbox = null; + + beforeEach(function () { + sandbox = sinon.sandbox.create(); + s = new MockStorage(); + a = new storage.StorageAdapter(s, noop); + sandbox.spy(s, 'create'); + sandbox.spy(s, 'update'); + sandbox.spy(s, 'delete'); + sandbox.spy(s, 'query'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + // Helper function for testing that the correct data is received by the + // store method of the specified name + function assertDataReceived(method, passed, expected, done) { + a[method](passed) + .then(function () { + sinon.assert.calledOnce(s[method]); + sinon.assert.calledWith(s[method], expected); + }) + .then(done, done); + } + + // Helper function for testing that the return value from the adapter is + // a correctly resolved promise + function assertPromiseResolved(method, passed, expected, done) { + a[method](passed) + .then(function (ret) { + // The returned object should be the SAME object as originally + // passed in + assert.strictEqual(ret, passed); + // But its contents may have changed + assert.deepEqual(ret, expected); + }) + .then(done, done); + } + + // Helper function for testing that the return value from the adapter is + // a correctly rejected promise + function assertPromiseRejected(method, passed, expected, done) { + s = new FailingMockStorage(); + a = new storage.StorageAdapter(s, noop); + a[method](passed) + .then(function () { + done(new Error("Promise should not have been resolved!")); + }, function (ret) { + assert.deepEqual(ret, expected); + }) + .then(done, done); + } + + describe('#create()', function () { + it("should pass annotation data to the store's #create()", function (done) { + assertDataReceived( + 'create', + {some: 'data'}, + sinon.match({some: 'data'}), + done + ); + }); + + it("should return a promise resolving to the created annotation", function (done) { + assertPromiseResolved( + 'create', + {some: 'data'}, + {some: 'data', stored: true}, + done + ); + }); + + it("should return a promise that rejects if the store rejects", function (done) { + assertPromiseRejected( + 'create', + {some: 'data'}, + "failure message", + done + ); + }); + + it("should strip _local data before passing to the store", function (done) { + assertDataReceived( + 'create', + {some: 'data', _local: 'nottobepassedon'}, + keyAbsent('_local'), + done + ); + }); + + it("should run the beforeAnnotationCreated/annotationCreated hooks before/after calling the store", function (done) { + var hr = MockHookRunner(); + s = new MockStorage(); + s._callRecorder = hr.runHook; + a = new storage.StorageAdapter(s, hr.runHook); + var ann = { + some: 'data' + }; + a.create(ann) + .then(function () { + assert.deepEqual(hr.calls[0].name, 'beforeAnnotationCreated'); + assert.strictEqual(hr.calls[0].args[0], ann); + assert.deepEqual(hr.calls[1].name, 'create'); + assert.deepEqual(hr.calls[2].name, 'annotationCreated'); + assert.strictEqual(hr.calls[2].args[0], ann); + }) + .then(done, done); + }); + }); + + describe('#update()', function () { + it("should pass annotation data to the store's #update()", function (done) { + assertDataReceived( + 'update', + {id: '123', some: 'data'}, + sinon.match({id: '123', some: 'data'}), + done + ); + }); + + it("should return a promise resolving to the updated annotation", function (done) { + assertPromiseResolved( + 'update', + {id: '123', some: 'data'}, + {id: '123', some: 'data', stored: true}, + done + ); + }); + + it("should return a promise that rejects if the store rejects", function (done) { + assertPromiseRejected( + 'update', + {id: '123', some: 'data'}, + "failure message", + done + ); + }); + + it("should strip _local data before passing to the store", function (done) { + assertDataReceived( + 'update', + {id: '123', some: 'data', _local: 'nottobepassedon'}, + keyAbsent('_local'), + done + ); + }); + + it("should throw a TypeError if the data lacks an id", function () { + var ann = {some: 'data'}; + assert.throws(function () { + a.update(ann); + }, TypeError, ' id '); + }); + + it("should run the beforeAnnotationUpdated/annotationUpdated hooks before/after calling the store", function (done) { + var hr = MockHookRunner(); + s = new MockStorage(); + s._callRecorder = hr.runHook; + a = new storage.StorageAdapter(s, hr.runHook); + var ann = { + id: '123', + some: 'data' + }; + a.update(ann) + .then(function () { + assert.deepEqual(hr.calls[0].name, 'beforeAnnotationUpdated'); + assert.strictEqual(hr.calls[0].args[0], ann); + assert.deepEqual(hr.calls[1].name, 'update'); + assert.deepEqual(hr.calls[2].name, 'annotationUpdated'); + assert.strictEqual(hr.calls[2].args[0], ann); + }) + .then(done, done); + }); + }); + + describe('#delete()', function () { + it("should pass annotation data to the store's #delete()", function (done) { + assertDataReceived( + 'delete', + {id: '123', some: 'data'}, + sinon.match({id: '123', some: 'data'}), + done + ); + }); + + it("should return a promise resolving to the deleted annotation", function (done) { + assertPromiseResolved( + 'delete', + {id: '123', some: 'data'}, + {id: '123', some: 'data', stored: true}, + done + ); + }); + + it("should return a promise that rejects if the store rejects", function (done) { + assertPromiseRejected( + 'delete', + {id: '123', some: 'data'}, + "failure message", + done + ); + }); + + it("should strip _local data before passing to the store", function (done) { + assertDataReceived( + 'delete', + {id: '123', some: 'data', _local: 'nottobepassedon'}, + keyAbsent('_local'), + done + ); + }); + + it("should throw a TypeError if the data lacks an id", function () { + var ann = {some: 'data'}; + + assert.throws(function () { + a["delete"](ann); + }, TypeError, ' id '); + }); + + it("should run the beforeAnnotationDeleted/annotationDeleted hooks before/after calling the store", function (done) { + var hr = MockHookRunner(); + s = new MockStorage(); + s._callRecorder = hr.runHook; + a = new storage.StorageAdapter(s, hr.runHook); + var ann = { + id: '123', + some: 'data' + }; + a["delete"](ann) + .then(function () { + assert.deepEqual(hr.calls[0].name, 'beforeAnnotationDeleted'); + assert.strictEqual(hr.calls[0].args[0], ann); + assert.deepEqual(hr.calls[1].name, 'delete'); + assert.deepEqual(hr.calls[2].name, 'annotationDeleted'); + return assert.strictEqual(hr.calls[2].args[0], ann); + }) + .then(done, done); + }); + }); + + describe('#query()', function () { + it("should invoke the query method on the registered store service", function () { + var query = { + url: 'foo' + }; + a.query(query); + sinon.assert.calledWith(s.query, query); + }); + + it("should return a promise resolving to the query result", function (done) { + var query = { + url: 'foo' + }; + a.query(query) + .then(function (ret) { + assert.deepEqual(ret, {results: [{id: 'foo'}], meta: {total: 1}}); + }) + .then(done, done); + }); + + it("should return a promise that rejects if the store rejects", function (done) { + s = new FailingMockStorage(); + a = new storage.StorageAdapter(s, noop); + var query = { + url: 'foo' + }; + var res = a.query(query); + res + .then(function () { + done(new Error("Promise should not have been resolved!")); + }, function (ret) { + assert.deepEqual(ret, "failure message"); + }) + .then(done, done); + }); + }); + + describe('#load()', function () { + it("should invoke the query method on the registered store service", function () { + var query = { + url: 'foo' + }; + a.load(query); + sinon.assert.calledWith(s.query, query); + }); + + it("should run the annotationsLoaded hook after calling the store", function (done) { + var hr = MockHookRunner(); + s = new MockStorage(); + s._callRecorder = hr.runHook; + a = new storage.StorageAdapter(s, hr.runHook); + var query = { + url: 'foo' + }; + a.load(query) + .then(function () { + assert.deepEqual(hr.calls[0].name, 'query'); + assert.deepEqual(hr.calls[1].name, 'annotationsLoaded'); + assert.deepEqual(hr.calls[1].args, [[{id: 'foo'}]]); + }) + .then(done, done); + }); + }); +}); diff --git a/test/spec/storage_spec.coffee b/test/spec/storage_spec.coffee deleted file mode 100644 index aebb0c422..000000000 --- a/test/spec/storage_spec.coffee +++ /dev/null @@ -1,69 +0,0 @@ -Registry = require('../../src/registry') -AnnotationProvider = require('../../src/annotations') -StorageProvider = require('../../src/storage') - - -describe 'StorageProvider', -> - a = null - m = null - r = null - ann = null - - beforeEach -> - r = new Registry() - .include(AnnotationProvider) - .include(StorageProvider) - a = r['annotations'] - m = r['store'] - ann = {id: 123, some: 'data'} - - describe '#::configure()', -> - - it "should register the base storage implementation by default", -> - assert.instanceOf(m, StorageProvider) - - it "should instantiate a provided implementation with store settings", -> - - MockStore = sinon.spy() - - settings = - store: - type: MockStore - foo: 'bar' - - r = new Registry(settings) - .include(StorageProvider) - assert(MockStore.calledWithNew(), 'instatiated MockStore') - assert(MockStore.calledWith(settings.store), 'passed settings') - - describe '#update()', -> - - it "should return a promise resolving to the updated annotation", (done) -> - a.update(ann) - .done (ret) -> - assert.equal(ret, ann) - done() - .fail (obj, msg) -> - done(new Error("promise rejected: #{msg}")) - - describe '#delete()', -> - - it "should return a promise resolving to the deleted annotation object", (done) -> - ann = {id: 123, some: 'data'} - a.delete(ann) - .done (ret) -> - assert.equal(ret, ann) - done() - .fail (obj, msg) -> - done(new Error("promise rejected: #{msg}")) - - describe '#query()', -> - - it "should return a promise resolving to the results and metadata", (done) -> - a.query({foo: 'bar', type: 'giraffe'}) - .done (res, meta) -> - assert.isArray(res) - assert.isObject(meta) - done() - .fail (obj, msg) -> - done(new Error("promise rejected: #{msg}")) diff --git a/test/spec/ui/adder_spec.js b/test/spec/ui/adder_spec.js new file mode 100644 index 000000000..45735d7c8 --- /dev/null +++ b/test/spec/ui/adder_spec.js @@ -0,0 +1,143 @@ +var assert = require('assertive-chai').assert; + +var h = require('../../helpers'); + +var adder = require('../../../src/ui/adder'), + util = require('../../../src/util'); + +var $ = util.$; + +describe('ui.adder.Adder', function () { + var a = null, + onCreate = null; + + beforeEach(function () { + h.addFixture('adder'); + onCreate = sinon.stub(); + a = new adder.Adder({ + onCreate: onCreate + }); + }); + + afterEach(function () { + a.destroy(); + h.clearFixtures(); + }); + + it('should start hidden', function () { + assert.isFalse(a.isShown()); + }); + + describe('.show()', function () { + it('should show the adder widget', function () { + a.show(); + assert.isTrue(a.isShown()); + }); + + it('sets the widget position if a position is provided', function () { + var position = { + top: '100px', + left: '200px' + }; + a.show(position); + assert.deepEqual({ + top: a.element[0].style.top, + left: a.element[0].style.left + }, position); + }); + }); + + describe('.hide()', function () { + it('should hide the adder widget', function () { + a.show(); + a.hide(); + assert.isFalse(a.isShown()); + }); + }); + + describe('.isShown()', function () { + it('should return true if the adder is shown', function () { + a.show(); + assert.isTrue(a.isShown()); + }); + + it('should return false if the adder is hidden', function () { + a.hide(); + assert.isFalse(a.isShown()); + }); + }); + + describe('.destroy()', function () { + it('should remove the adder from the document', function () { + a.destroy(); + assert.isFalse(a.element.parents().index(document.body) >= 0); + }); + }); + + describe('.load()', function () { + var ann = null; + + beforeEach(function () { + ann = {text: 'foo'}; + }); + + it("shows the widget", function () { + a.load(ann); + assert.isTrue(a.isShown()); + }); + + it("sets the widget position if a position is provided", function () { + var position = { + top: '123px', + left: '456px' + }; + a.load(ann, position); + assert.deepEqual({ + top: a.element[0].style.top, + left: a.element[0].style.left + }, position); + }); + }); + + describe('event handlers', function () { + var ann = null; + + beforeEach(function () { + ann = {text: 'foo'}; + a.load(ann); + }); + + it("calls the onCreate handler when the button is left-clicked", function () { + a.element.find('button').trigger({ + type: 'click', + which: 1 + }); + sinon.assert.calledWith(onCreate, ann); + }); + + it("does not call the onCreate handler when the button is right-clicked", function () { + a.element.find('button').trigger({ + type: 'click', + which: 3 + }); + sinon.assert.notCalled(onCreate); + }); + + it("passes the triggering event to the onCreate handler", function () { + a.element.find('button').trigger({ + type: 'click', + which: 1 + }); + assert.equal(onCreate.firstCall.args[1].type, 'click'); + }); + + it("hides the adder when the button is left-clicked", function () { + $(global.document.body).trigger('mouseup'); + a.element.find('button').trigger({ + type: 'click', + which: 1 + }); + assert.isFalse(a.isShown()); + }); + }); +}); diff --git a/test/spec/ui/editor_spec.js b/test/spec/ui/editor_spec.js new file mode 100644 index 000000000..cfd66923e --- /dev/null +++ b/test/spec/ui/editor_spec.js @@ -0,0 +1,644 @@ +var assert = require('assertive-chai').assert; + +var h = require('../../helpers'); + +var editor = require('../../../src/ui/editor'), + util = require('../../../src/util'); + +var $ = util.$; + +describe('ui.editor.Editor', function () { + var plugin = null; + + describe('in default configuration', function () { + beforeEach(function () { + plugin = new editor.Editor(); + }); + + afterEach(function () { + plugin.destroy(); + }); + + it('should start hidden', function () { + assert.isFalse(plugin.isShown()); + }); + + describe('.show()', function () { + it('should make the editor widget visible', function () { + plugin.show(); + assert.isTrue(plugin.isShown()); + }); + + it('sets the widget position if a position is provided', function () { + plugin.show({ + top: '100px', + left: '200px' + }); + assert.deepEqual({ + top: plugin.element[0].style.top, + left: plugin.element[0].style.left + }, { + top: '100px', + left: '200px' + }); + }); + }); + + describe('.hide()', function () { + it('should hide the editor widget', function () { + plugin.show(); + plugin.hide(); + assert.isFalse(plugin.isShown()); + }); + }); + + describe('.destroy()', function () { + it('should remove the editor from the document', function () { + plugin.destroy(); + assert.isFalse(plugin.element.parents().index(document.body) >= 0); + }); + }); + + describe('.load(annotation)', function () { + it('should show the widget', function () { + plugin.load({ + text: "Hello, world." + }); + assert.isTrue(plugin.isShown()); + }); + + it('should show the annotation text for editing', function () { + plugin.load({ + text: "Hello, world." + }); + assert.equal(plugin.element.find('textarea').val(), "Hello, world."); + }); + + it('should return a promise that is resolved if the editor is subsequently submitted', function (done) { + var ann = { + text: "Hello, world" + }; + var res = plugin.load(ann); + plugin.element.find('textarea').val('Updated in the editor'); + plugin.submit(); + res + .then(function () { + assert.equal(ann.text, "Updated in the editor"); + }) + .then(done, done); + }); + + it('should return a promise that is rejected if editing is subsequently cancelled', function (done) { + var ann = { + text: "Hello, world" + }; + var res = plugin.load(ann); + plugin.cancel(); + res + .then(function () { + done(new Error("Promise should have been rejected!")); + }, function () { + done(); + }); + }); + }); + + describe('.submit()', function () { + var ann = null; + + beforeEach(function () { + ann = { + text: "Giraffes are tall." + }; + plugin.load(ann); + }); + + it('should hide the widget', function () { + plugin.submit(); + assert.isFalse(plugin.isShown()); + }); + + it('should save any changes made to the annotation text', function () { + plugin.element.find('textarea').val('Lions are strong.'); + plugin.submit(); + assert.equal(ann.text, 'Lions are strong.'); + }); + }); + + describe('.cancel()', function () { + var ann = null; + + beforeEach(function () { + ann = { + text: "Blue whales are large." + }; + plugin.load(ann)['catch'](function () {}); + }); + + it('should hide the widget', function () { + plugin.submit(); + assert.isFalse(plugin.isShown()); + }); + + it('should NOT save changes made to the annotation text', function () { + plugin.element.find('textarea').val('Mice are small.'); + plugin.cancel(); + assert.equal(ann.text, 'Blue whales are large.'); + }); + }); + + describe('custom fields', function () { + var ann = null, + field = null, + elem = null; + + beforeEach(function () { + ann = { + text: "Donkeys with beachballs" + }; + field = { + label: "Example field", + load: sinon.spy(), + submit: sinon.spy() + }; + elem = plugin.addField(field); + }); + + it('should call the load callback of added fields when an annotation is loaded into the editor', function () { + plugin.load(ann); + sinon.assert.calledOnce(field.load); + }); + + it('should pass a DOM Node as the first argument to the load callback', function () { + plugin.load(ann); + var callArgs = field.load.args[0]; + assert.equal(callArgs[0].nodeType, 1); + }); + + it('should pass an annotation as the second argument to the load callback', function () { + plugin.load(ann); + var callArgs = field.load.args[0]; + assert.equal(callArgs[1], ann); + }); + + it('should return the created field element from .addField(field)', function () { + assert.equal(elem.nodeType, 1); + }); + + it('should add the plugin label to the field element', function () { + assert($(elem).html().indexOf('Example field') >= 0); + }); + it('should add an element by default', function () { + assert.equal($(elem).find(':input').prop('tagName'), 'INPUT'); + }); + + it('should add a