├── spec ├── javascripts │ ├── helpers │ │ ├── .gitkeep │ │ └── javascripts │ │ │ └── SpecHelper.js │ └── support │ │ ├── jasmine_helper.rb │ │ └── jasmine.yml └── CkanRTSpec.js ├── ckanext ├── realtime │ ├── event │ │ ├── __init__.py │ │ ├── event_dispatcher.py │ │ ├── base.py │ │ ├── event_factory.py │ │ └── datastore.py │ ├── logic │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── schema.py │ │ └── action.py │ ├── twisted │ │ ├── __init__.py │ │ ├── websocket.py │ │ └── redis.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_event_factory.py │ │ └── test_actions.py │ ├── __init__.py │ ├── db.py │ ├── plugin.py │ └── message_handler.py └── __init__.py ├── .coveragerc ├── MANIFEST.in ├── docs ├── source │ ├── includeme.rst │ ├── index.rst │ ├── tutorial.rst │ └── conf.py └── Makefile.default ├── Rakefile ├── setup.cfg ├── requirements.txt ├── .travis.yml ├── test.ini.example ├── .gitignore ├── LICENSE.rst ├── setup.py ├── client ├── CkanRT.js └── examples │ ├── ex1 │ └── index.html │ ├── ex2 │ └── index.html │ └── jquery-1.11.0.min.js ├── bin ├── travis-before-install.sh ├── ckan_wss └── datastore_listener └── README.rst /spec/javascripts/helpers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ckanext/realtime/event/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ckanext/realtime/logic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ckanext/realtime/twisted/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ckanext/realtime/tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = ckanext.realtime 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.rst 2 | include README.rst -------------------------------------------------------------------------------- /docs/source/includeme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../README.rst 2 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'jasmine' 2 | load 'jasmine/tasks/jasmine.rake' 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | with-pylons=test.ini 3 | with-coverage=1 4 | cover-package=ckanext.realtime 5 | -------------------------------------------------------------------------------- /ckanext/__init__.py: -------------------------------------------------------------------------------- 1 | # this is a namespace package 2 | try: 3 | import pkg_resources 4 | pkg_resources.declare_namespace(__name__) 5 | except ImportError: 6 | import pkgutil 7 | __path__ = pkgutil.extend_path(__path__, __name__) 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | autobahn==0.8.1 2 | coverage==3.7.1 3 | jsonpickle==0.6.1 4 | lockfile==0.9.1 5 | nose==1.3.0 6 | psycopg2==2.4.5 7 | redis==2.9.1 8 | requests==2.2.1 9 | sphinx_rtd_theme==0.1.6 10 | Twisted==13.2.0 11 | txredis==2.3 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | services: 5 | - redis-server 6 | env: 7 | - PGVERSION=9.1 8 | install: 9 | - pip install coveralls 10 | before_install: bash bin/travis-before-install.sh 11 | script: 12 | - rake jasmine:ci 13 | - nosetests --with-coverage --cover-package=ckanext.realtime --with-pylons=links/test-core.ini 14 | after_success: coveralls 15 | -------------------------------------------------------------------------------- /spec/javascripts/helpers/javascripts/SpecHelper.js: -------------------------------------------------------------------------------- 1 | beforeEach(function () { 2 | jasmine.addMatchers({ 3 | toBePlaying: function () { 4 | return { 5 | compare: function (actual, expected) { 6 | var player = actual; 7 | 8 | return { 9 | pass: player.currentlyPlayingSong === expected && player.isPlaying 10 | } 11 | } 12 | }; 13 | } 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /spec/javascripts/support/jasmine_helper.rb: -------------------------------------------------------------------------------- 1 | #Use this file to set/override Jasmine configuration options 2 | #You can remove it if you don't need it. 3 | #This file is loaded *after* jasmine.yml is interpreted. 4 | # 5 | #Example: using a different boot file. 6 | #Jasmine.configure do |config| 7 | # config.boot_dir = '/absolute/path/to/boot_dir' 8 | # config.boot_files = lambda { ['/absolute/path/to/boot_dir/file.js'] } 9 | #end 10 | # 11 | 12 | -------------------------------------------------------------------------------- /ckanext/realtime/__init__.py: -------------------------------------------------------------------------------- 1 | # this is a namespace package 2 | try: 3 | import pkg_resources 4 | pkg_resources.declare_namespace(__name__) 5 | except ImportError: 6 | import pkgutil 7 | __path__ = pkgutil.extend_path(__path__, __name__) 8 | 9 | SUCCESS_MESSAGE = 'SUCCESS' 10 | FAIL_MESSAGE = 'FAIL' 11 | 12 | YES_MESSAGE = 'YES' 13 | NO_MESSAGE = 'NO' 14 | 15 | NON_DATASTORE_MESSAGE = 'NOT-A-DATASTORE' 16 | INVALID_RESOURCE_MESSAGE = 'INVALID-RESOURCE' -------------------------------------------------------------------------------- /test.ini.example: -------------------------------------------------------------------------------- 1 | [app:main] 2 | use = config:../ckan/test-core.ini 3 | ckan.site_title = ckanext-realtime 4 | ckan.site_description = A test site for testing ckanext-realtime 5 | 6 | #provide valid sqlalchemy urls 7 | sqlalchemy.url = 8 | 9 | ckan.datastore.write_url = 10 | ckan.datastore.read_url = 11 | 12 | ckan.realtime.ckan_api_url = http://localhost:5000/api/3/action/ 13 | ckan.realtime.apikey = 14 | ckan.realtime.redis_host = 127.0.0.1 15 | ckan.realtime.redis_port = 6379 16 | ckan.realtime.wss_port = 9000 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Distribution / packaging 6 | *.egg-info/ 7 | dist/ 8 | 9 | # Eclipse 10 | .settings 11 | 12 | # Installer logs 13 | pip-log.txt 14 | pip-delete-this-directory.txt 15 | 16 | # Unit test / coverage reports 17 | .tox/ 18 | .coverage 19 | .cache 20 | nosetests.xml 21 | coverage.xml 22 | 23 | # Mr Developer 24 | .mr.developer.cfg 25 | .project 26 | .pydevproject 27 | 28 | # Rope 29 | .ropeproject 30 | 31 | # Sphinx documentation 32 | docs/build/ 33 | docs/Makefile 34 | 35 | #ckanext-realtime 36 | test.ini 37 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. ckanext-realtime documentation master file, created by 2 | sphinx-quickstart on Fri Jan 31 00:07:43 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to ckanext-realtime's documentation! 7 | ============================================ 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | includeme 15 | tutorial 16 | 17 | 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | 26 | -------------------------------------------------------------------------------- /ckanext/realtime/event/event_dispatcher.py: -------------------------------------------------------------------------------- 1 | import jsonpickle 2 | import logging 3 | import redis 4 | 5 | log = logging.getLogger(__name__) 6 | 7 | 8 | class EventDispatcher(object): 9 | '''Mediates events from ckan API actions to subscribers''' 10 | 11 | @classmethod 12 | def configure(cls, redis_host, redis_port): 13 | cls.r = redis.StrictRedis(host=redis_host, port=redis_port, db=0) 14 | 15 | @classmethod 16 | def dispatch_one(cls, event): 17 | cls.r.publish(event.event_name, jsonpickle.encode(event)) 18 | 19 | @classmethod 20 | def dispatch(cls, events): 21 | for e in events: 22 | cls.dispatch_one(e) 23 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | License 2 | +++++++ 3 | 4 | ckanext-realtime - CKAN extension which enables CKAN to serve realtime data 5 | 6 | Copyright (C) 2014 `Alexandra Instituttet A/S `_ and `Gatesense `_ 7 | 8 | This program is free software: you can redistribute it and/or modify 9 | it under the terms of the GNU Affero General Public License as published by 10 | the Free Software Foundation, either version 3 of the License, or 11 | (at your option) any later version. 12 | 13 | This program is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU Affero General Public License for more details. 17 | 18 | You should have received a copy of the GNU Affero General Public License 19 | along with this program. If not, see . 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | version = '0.4' 4 | 5 | setup( 6 | name='ckanext-realtime', 7 | version=version, 8 | description="CKAN extension which enables CKAN to serve realtime data", 9 | long_description=open('README.rst').read(), 10 | classifiers=[], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers 11 | keywords='', 12 | author='Justas Azna', 13 | author_email='justas.azna@gmail.dk', 14 | url='', 15 | license='AGPL', 16 | packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), 17 | namespace_packages=['ckanext', 'ckanext.realtime'], 18 | include_package_data=True, 19 | zip_safe=False, 20 | tests_require=[ 21 | 'nose', 22 | ], 23 | test_suite = 'nose.collector', 24 | install_requires=[ 25 | # -*- Extra requirements: -*- 26 | ], 27 | entry_points=\ 28 | """ 29 | [ckan.plugins] 30 | realtime=ckanext.realtime.plugin:RealtimePlugin 31 | """, 32 | ) 33 | -------------------------------------------------------------------------------- /ckanext/realtime/logic/auth.py: -------------------------------------------------------------------------------- 1 | '''Authorization for ckan api actions exposed by ckanext-realtime''' 2 | 3 | import ckan.plugins as p 4 | 5 | 6 | def realtime_auth(context, data_dict, privilege='resource_update'): 7 | user = context.get('user') 8 | authorized = p.toolkit.check_access(privilege, context, data_dict) 9 | 10 | if not authorized: 11 | return { 12 | 'success': False, 13 | 'msg': p.toolkit._('User {0} not authorized to update resource {1}' 14 | .format(str(user), data_dict['resource_id'])) 15 | } 16 | else: 17 | return {'success': True} 18 | 19 | 20 | def realtime_broadcast_event(context, data_dict): 21 | return realtime_auth(context, data_dict) 22 | 23 | 24 | def datastore_make_observable(context, data_dict): 25 | return realtime_auth(context, data_dict) 26 | 27 | 28 | def realtime_check_observable_datastore(context, data_dict): 29 | return realtime_auth(context, data_dict) 30 | -------------------------------------------------------------------------------- /ckanext/realtime/logic/schema.py: -------------------------------------------------------------------------------- 1 | import ckan.plugins as p 2 | 3 | _get_validator = p.toolkit.get_validator 4 | 5 | _not_missing = _get_validator('not_missing') 6 | _not_empty = _get_validator('not_empty') 7 | _resource_id_exists = _get_validator('resource_id_exists') 8 | _package_id_exists = _get_validator('package_id_exists') 9 | _ignore_missing = _get_validator('ignore_missing') 10 | 11 | 12 | def realtime_broadcast_event_schema(): 13 | schema = { 14 | 'event_type': [_not_empty, unicode], 15 | 'package_id': [_ignore_missing, unicode, _package_id_exists], 16 | 'resource_id': [_ignore_missing, unicode, _resource_id_exists], 17 | } 18 | 19 | 20 | return schema 21 | 22 | 23 | def datastore_make_observable_schema(): 24 | schema = { 25 | 'resource_id': [_not_empty, unicode, _resource_id_exists], 26 | } 27 | return schema 28 | 29 | def realtime_check_observable_datastore_schema(): 30 | schema = { 31 | 'resource_id': [_not_empty, unicode, _resource_id_exists], 32 | } 33 | 34 | return schema 35 | -------------------------------------------------------------------------------- /ckanext/realtime/event/base.py: -------------------------------------------------------------------------------- 1 | '''Base event classes''' 2 | import datetime 3 | 4 | class package_event(object): 5 | '''Event for package(dataset) level data observation''' 6 | def __init__(self, package_id): 7 | ''' 8 | :param package_id: the dataset to which the event is related 9 | :type package_id: string 10 | 11 | ''' 12 | self.package_id = package_id 13 | self.timestamp = str(datetime.datetime.now()) 14 | 15 | def __repr__(self): 16 | return ("{0}(package_id='{1}', timestamp='{2}')" 17 | .format(self.__class__, self.package_id, self.timestamp)) 18 | 19 | 20 | class resource_event(object): 21 | '''Event for resource level data observation''' 22 | def __init__(self, resource_id): 23 | ''' 24 | :param resource_id: the resource to which the event is related 25 | :type resource_id: string 26 | 27 | ''' 28 | self.resource_id = resource_id 29 | self.timestamp = str(datetime.datetime.now()) 30 | 31 | def __repr__(self): 32 | return ("{0}(resource_id='{1}', timestamp='{2}')" 33 | .format(self.__class__, self.resource_id, self.timestamp)) 34 | -------------------------------------------------------------------------------- /ckanext/realtime/event/event_factory.py: -------------------------------------------------------------------------------- 1 | import ckanext.realtime.event.datastore as ds_evt 2 | import ckan.logic 3 | 4 | _get_or_bust = ckan.logic.get_or_bust 5 | 6 | class EventFactory(object): 7 | '''Used to build realtime events from data_dict''' 8 | 9 | @classmethod 10 | def build_event(cls, data_dict): 11 | event_type = _get_or_bust(data_dict, 'event_type') 12 | 13 | if event_type == 'datastore_insert': 14 | return ds_evt.DatastoreInsertEvent(_get_or_bust(data_dict, 15 | 'resource_id')) 16 | elif event_type == 'datastore_update': 17 | return ds_evt.DatastoreUpdateEvent(_get_or_bust(data_dict, 18 | 'resource_id')) 19 | elif event_type == 'datastore_delete': 20 | return ds_evt.DatastoreDeleteEvent(_get_or_bust(data_dict, 21 | 'resource_id')) 22 | elif event_type == 'datastore_create': 23 | return ds_evt.DatastoreCreateEvent(_get_or_bust(data_dict, 24 | 'package_id'), 25 | _get_or_bust(data_dict, 26 | 'resource_id')) 27 | elif event_type == 'datastore_schema_alter': 28 | return ds_evt.DatastoreSchemaAlterEvent(_get_or_bust(data_dict, 29 | 'resource_id')) 30 | else: 31 | raise ckan.logic.ValidationError('Bad event_type') 32 | -------------------------------------------------------------------------------- /client/CkanRT.js: -------------------------------------------------------------------------------- 1 | function CkanRT(websocketUrl) { 2 | this.websocketUrl = websocketUrl; 3 | this.init(); 4 | } 5 | 6 | // START BLOCK - these methods should be overriden by the API user 7 | CkanRT.prototype.onDatastoreSubscribeResult = function(resourceId, status) { 8 | 9 | }; 10 | 11 | CkanRT.prototype.onDatastoreUnsubscribedResult = function(resourceId, status) { 12 | 13 | }; 14 | 15 | CkanRT.prototype.onDatastoreEvent = function(event) { 16 | 17 | }; 18 | 19 | // END BLOCK 20 | 21 | CkanRT.prototype.init = function() { 22 | this.websocket = new WebSocket(this.websocketUrl); 23 | 24 | var rt = this; 25 | this.websocket.onmessage = function(msg) { 26 | messageReceived(msg, rt); 27 | }; 28 | }; 29 | 30 | CkanRT.prototype.datastoreSubscribe = function(resourceId) { 31 | var packet = { 32 | type : 'datastoresubscribe', 33 | resource_id : resourceId, 34 | }; 35 | this.websocket.send(JSON.stringify(packet)); 36 | }; 37 | 38 | CkanRT.prototype.datastoreUnsubscribe = function(resourceId) { 39 | var packet = { 40 | type : 'datastoreunsubscribe', 41 | resource_id : resourceId, 42 | }; 43 | this.websocket.send(JSON.stringify(packet)); 44 | }; 45 | 46 | function messageReceived(message, rt) { 47 | var payload = JSON.parse(message.data); 48 | switch (payload.type) { 49 | case 'datastoresubscribe': 50 | rt.onDatastoreSubscribeResult(payload.resource_id, payload.result); 51 | break; 52 | case 'datastoreunsubscribe': 53 | rt.onDatastoreUnsubscribeResult(payload.resource_id, payload.result); 54 | break; 55 | case 'datastoreevent': 56 | rt.onDatastoreEvent(payload.event); 57 | break; 58 | default: 59 | console.log('unrecognized payload'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /ckanext/realtime/event/datastore.py: -------------------------------------------------------------------------------- 1 | import ckanext.realtime.event.base as base 2 | 3 | 4 | class DatastoreCreateEvent(base.package_event, base.resource_event): 5 | '''Fired when a new datastore is created 6 | 7 | TODO: emiting not implemented 8 | 9 | ''' 10 | event_name = 'datastore_create' 11 | 12 | def __init__(self, package_id, resource_id): 13 | base.package_event.__init__(self, package_id) 14 | base.resource_event.__init__(self, resource_id) 15 | 16 | 17 | class DatastoreInsertEvent(base.resource_event): 18 | '''Fired for each new tuple inserted in a particular observable datastore''' 19 | event_name = 'datastore_insert' 20 | 21 | def __init__(self, resource_id): 22 | base.resource_event.__init__(self, resource_id) 23 | 24 | 25 | class DatastoreUpdateEvent(base.resource_event): 26 | '''Fired for each updated tuple in a particular observable datastore''' 27 | event_name = 'datastore_update' 28 | 29 | def __init__(self, resource_id): 30 | base.resource_event.__init__(self, resource_id) 31 | 32 | 33 | class DatastoreDeleteEvent(base.resource_event): 34 | '''Fired for each deleted tuple in a particular observable datastore''' 35 | event_name = 'datastore_delete' 36 | 37 | def __init__(self, resource_id): 38 | base.resource_event.__init__(self, resource_id) 39 | 40 | 41 | class DatastoreSchemaAlterEvent(base.resource_event): 42 | '''Fired when the structure of some datastore is changed 43 | 44 | TODO: emiting not implemented 45 | 46 | ''' 47 | event_name = 'datastore_schema_alter' 48 | 49 | def __init__(self, resource_id): 50 | base.resource_event.__init__(self, resource_id) 51 | -------------------------------------------------------------------------------- /ckanext/realtime/db.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pylons 3 | 4 | import sqlalchemy 5 | 6 | log = logging.getLogger(__name__) 7 | 8 | 9 | def add_datastore_notifier_trigger(resource_id): 10 | '''This trigger sends datastore events to bin/datastore_listener script''' 11 | 12 | sql = ''' 13 | DROP TRIGGER IF EXISTS "{res}_notifier" ON "{res}" RESTRICT; 14 | 15 | CREATE TRIGGER "{res}_notifier" 16 | AFTER INSERT OR UPDATE OR DELETE ON "{res}" 17 | FOR EACH ROW 18 | EXECUTE PROCEDURE datastore_notifier(); 19 | '''.format(res=resource_id) 20 | 21 | engine = sqlalchemy.create_engine(pylons.config['ckan.datastore.write_url']) 22 | engine.execute(sql) 23 | 24 | 25 | def create_datastore_notifier_trigger_function(): 26 | '''Create a function for datastore tables used to notify about changes.''' 27 | 28 | sql = """ 29 | CREATE OR REPLACE FUNCTION "public"."datastore_notifier" () RETURNS trigger AS 'BEGIN 30 | EXECUTE ''NOTIFY ckanextrealtime, '''''' || TG_OP || '' '' || TG_TABLE_NAME || '''''';''; 31 | RETURN NEW; 32 | END' LANGUAGE "plpgsql" COST 100 33 | VOLATILE 34 | CALLED ON NULL INPUT 35 | SECURITY DEFINER; 36 | """ 37 | 38 | engine = sqlalchemy.create_engine(pylons.config['ckan.datastore.write_url']) 39 | engine.execute(sql) 40 | 41 | 42 | def notifier_trigger_function_exists(resource_id): 43 | sql = sqlalchemy.text('SELECT * FROM pg_trigger WHERE tgname = :tg;') 44 | 45 | engine = sqlalchemy.create_engine(pylons.config['ckan.datastore.write_url']) 46 | results = engine.execute(sql, tg='{}_notifier'.format(resource_id)) 47 | return results.rowcount == 1 48 | -------------------------------------------------------------------------------- /ckanext/realtime/plugin.py: -------------------------------------------------------------------------------- 1 | import ckanext.realtime.db as db 2 | import ckanext.realtime.event.event_dispatcher as evt 3 | import ckanext.realtime.logic.action as action 4 | import ckanext.realtime.logic.auth as auth 5 | 6 | import ckan.plugins as plugins 7 | 8 | class RealtimePlugin(plugins.SingletonPlugin): 9 | '''CKAN Plugin which enables **Observable Datastores** and publishing 10 | datastore events. 11 | 12 | Enabling ckanext-datastore plugin is a requirement. 13 | ''' 14 | plugins.implements(plugins.IConfigurable, inherit=True) 15 | plugins.implements(plugins.IActions) 16 | plugins.implements(plugins.IAuthFunctions) 17 | # plugins.implements(plugins.IDomainObjectModification, inherit=True) 18 | 19 | def configure(self, config): 20 | ''' Configure the plugin - inherited from IConfigurable ''' 21 | 22 | evt.EventDispatcher.configure(config['ckan.realtime.redis_host'], 23 | config['ckan.realtime.redis_port']) 24 | 25 | db.create_datastore_notifier_trigger_function() 26 | 27 | def get_actions(self): 28 | return {'realtime_broadcast_event': action.realtime_broadcast_event, 29 | 'datastore_make_observable': action.datastore_make_observable, 30 | 'realtime_check_observable_datastore': action.realtime_check_observable_datastore, 31 | } 32 | 33 | def get_auth_functions(self): 34 | return {'realtime_broadcast_event': auth.realtime_broadcast_event, 35 | 'datastore_make_observable': auth.datastore_make_observable, 36 | 'realtime_check_observable_datastore': auth.realtime_check_observable_datastore, 37 | } 38 | 39 | # def notify(self, entity, operation): 40 | # log.debug('domain object modification') 41 | # log.debug(entity) 42 | # log.debug(operation) 43 | # 44 | -------------------------------------------------------------------------------- /ckanext/realtime/twisted/websocket.py: -------------------------------------------------------------------------------- 1 | import autobahn.twisted.websocket as ws 2 | from twisted.python import log 3 | 4 | import ckanext.realtime.message_handler as mh 5 | 6 | class CkanWebSocketServerFactory(ws.WebSocketServerFactory): 7 | '''Twisted server factory for CKAN WebSocket protocols''' 8 | 9 | def __init__(self, url, api_url, apikey, test, debug=False, debugCodePaths=False): 10 | ws.WebSocketServerFactory.__init__(self, url, debug=debug, 11 | debugCodePaths=debugCodePaths) 12 | self.protocol = CkanWebSocketServerProtocol 13 | self.setProtocolOptions(allowHixie76=True) 14 | 15 | if test: 16 | # most of the responses in the TestClientMessageHandler are mocked 17 | self.message_handler = mh.TestMessageHandler(api_url, apikey) 18 | else: 19 | self.message_handler = mh.MessageHandler(api_url, apikey) 20 | 21 | def listen(self): 22 | '''Listen for incoming WebSocket connections''' 23 | ws.listenWS(self) 24 | 25 | def register(self, client): 26 | self.message_handler.register_websocket_client(client) 27 | 28 | def unregister(self, client): 29 | self.message_handler.unregister_websocket_client(client) 30 | 31 | def handle_from_redis(self, msg): 32 | self.message_handler.handle_message_from_redis(msg) 33 | 34 | def handle_from_client(self, msg, client): 35 | self.message_handler.handle_message_from_client(msg, client) 36 | 37 | 38 | class CkanWebSocketServerProtocol(ws.WebSocketServerProtocol): 39 | '''CKAN WebSocket protocol''' 40 | def onOpen(self): 41 | self.factory.register(self) 42 | 43 | def onMessage(self, msg, binary): 44 | if binary: 45 | return 46 | log.msg('request: ' + msg) 47 | self.factory.handle_from_client(msg, self) 48 | 49 | def connectionLost(self, reason): 50 | ws.WebSocketServerProtocol.connectionLost(self, reason) 51 | self.factory.unregister(self) 52 | -------------------------------------------------------------------------------- /ckanext/realtime/tests/test_event_factory.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import ckan.logic 3 | import ckan.tests as tests 4 | import ckanext.realtime.event.datastore as evt 5 | from ckanext.realtime.event.event_factory import EventFactory 6 | 7 | 8 | class TestEventFactory(tests.CheckMethods): 9 | 10 | def test_bad_input(self): 11 | data_dict = {'afgh': 1} 12 | self.assert_raises(ckan.logic.ValidationError, 13 | EventFactory.build_event, 14 | data_dict) 15 | 16 | data_dict = {'event_type': 'foobar', 'resource_id': str(uuid.uuid4())} 17 | self.assert_raises(ckan.logic.ValidationError, 18 | EventFactory.build_event, 19 | data_dict) 20 | 21 | def test_datastore_create(self): 22 | data_dict = {'event_type': 'datastore_create', 23 | 'resource_id': str(uuid.uuid4()), 24 | 'package_id': str(uuid.uuid4())} 25 | 26 | event = EventFactory.build_event(data_dict) 27 | 28 | self.assert_true(isinstance(event, evt.DatastoreCreateEvent)) 29 | 30 | def test_datastore_schema_alter(self): 31 | data_dict = {'event_type': 'datastore_schema_alter', 32 | 'resource_id': str(uuid.uuid4())} 33 | 34 | event = EventFactory.build_event(data_dict) 35 | 36 | self.assert_true(isinstance(event, evt.DatastoreSchemaAlterEvent)) 37 | 38 | def test_datastore_insert(self): 39 | data_dict = {'event_type': 'datastore_insert', 'resource_id': str(uuid.uuid4())} 40 | 41 | event = EventFactory.build_event(data_dict) 42 | self.assert_true(isinstance(event, evt.DatastoreInsertEvent)) 43 | 44 | def test_datastore_update_event(self): 45 | data_dict = {'event_type': 'datastore_update', 'resource_id': str(uuid.uuid4())} 46 | 47 | event = EventFactory.build_event(data_dict) 48 | self.assert_true(isinstance(event, evt.DatastoreUpdateEvent)) 49 | 50 | def test_datastore_delete_event(self): 51 | data_dict = {'event_type': 'datastore_delete', 'resource_id': str(uuid.uuid4())} 52 | 53 | event = EventFactory.build_event(data_dict) 54 | self.assert_true(isinstance(event, evt.DatastoreDeleteEvent)) 55 | -------------------------------------------------------------------------------- /spec/javascripts/support/jasmine.yml: -------------------------------------------------------------------------------- 1 | # src_files 2 | # 3 | # Return an array of filepaths relative to src_dir to include before jasmine specs. 4 | # Default: [] 5 | # 6 | # EXAMPLE: 7 | # 8 | # src_files: 9 | # - lib/source1.js 10 | # - lib/source2.js 11 | # - dist/**/*.js 12 | # 13 | src_files: 14 | - CkanRT.js 15 | 16 | # stylesheets 17 | # 18 | # Return an array of stylesheet filepaths relative to src_dir to include before jasmine specs. 19 | # Default: [] 20 | # 21 | # EXAMPLE: 22 | # 23 | # stylesheets: 24 | # - css/style.css 25 | # - stylesheets/*.css 26 | # 27 | stylesheets: 28 | - stylesheets/**/*.css 29 | 30 | # helpers 31 | # 32 | # Return an array of filepaths relative to spec_dir to include before jasmine specs. 33 | # Default: ["helpers/**/*.js"] 34 | # 35 | # EXAMPLE: 36 | # 37 | # helpers: 38 | # - helpers/**/*.js 39 | # 40 | helpers: 41 | - 'javascripts/helpers/**/*.js' 42 | 43 | # spec_files 44 | # 45 | # Return an array of filepaths relative to spec_dir to include. 46 | # Default: ["**/*[sS]pec.js"] 47 | # 48 | # EXAMPLE: 49 | # 50 | # spec_files: 51 | # - **/*[sS]pec.js 52 | # 53 | spec_files: 54 | - '**/*[sS]pec.js' 55 | 56 | # src_dir 57 | # 58 | # Source directory path. Your src_files must be returned relative to this path. Will use root if left blank. 59 | # Default: project root 60 | # 61 | # EXAMPLE: 62 | # 63 | # src_dir: public 64 | # 65 | src_dir: client 66 | 67 | # spec_dir 68 | # 69 | # Spec directory path. Your spec_files must be returned relative to this path. 70 | # Default: spec/javascripts 71 | # 72 | # EXAMPLE: 73 | # 74 | # spec_dir: spec/javascripts 75 | # 76 | spec_dir: spec 77 | 78 | # spec_helper 79 | # 80 | # Ruby file that Jasmine server will require before starting. 81 | # Returned relative to your root path 82 | # Default spec/javascripts/support/jasmine_helper.rb 83 | # 84 | # EXAMPLE: 85 | # 86 | # spec_helper: spec/javascripts/support/jasmine_helper.rb 87 | # 88 | spec_helper: spec/javascripts/support/jasmine_helper.rb 89 | 90 | # boot_dir 91 | # 92 | # Boot directory path. Your boot_files must be returned relative to this path. 93 | # Default: Built in boot file 94 | # 95 | # EXAMPLE: 96 | # 97 | # boot_dir: spec/javascripts/support/boot 98 | # 99 | boot_dir: 100 | 101 | # boot_files 102 | # 103 | # Return an array of filepaths relative to boot_dir to include in order to boot Jasmine 104 | # Default: Built in boot file 105 | # 106 | # EXAMPLE 107 | # 108 | # boot_files: 109 | # - '**/*.js' 110 | # 111 | boot_files: 112 | 113 | -------------------------------------------------------------------------------- /ckanext/realtime/twisted/redis.py: -------------------------------------------------------------------------------- 1 | from twisted.internet import reactor, protocol 2 | from twisted.python import log 3 | 4 | from txredis.client import RedisSubscriber 5 | 6 | def connect_event_processor(redis_host, redis_port, 7 | websocket_factory, events_for_subscribing): 8 | '''Initiate connection to Redis process. 9 | 10 | :param redis_host: the address of redis server 11 | :type redis_host: basestring 12 | :param redis_port: port on which the redis server in listening 13 | :type redis_port: int 14 | :param websocket_factory: twisted server factory for creating 15 | WS protocols of ckanext-realtime 16 | :type websocket_factory: ckanext.realtime.twisted.websocket.CKANWebSocketServerFactory 17 | 18 | ''' 19 | 20 | client_creator = protocol.ClientCreator(reactor, 21 | CkanEventProcessorProtocol, 22 | websocket_factory) 23 | 24 | d = client_creator.connectTCP(redis_host, redis_port) 25 | d.addCallback(_connection_success, *events_for_subscribing) 26 | d.addErrback(_connection_failure) 27 | 28 | return d 29 | 30 | 31 | def _connection_success(event_processor, *event_classes): 32 | '''Callback to deferred event_processor connection process''' 33 | event_processor.subscribe(event_classes) 34 | 35 | 36 | def _connection_failure(error): 37 | '''Errback to deferred event_processor connection process''' 38 | log.err(error) 39 | 40 | 41 | class CkanEventProcessorProtocol(RedisSubscriber): 42 | '''txredis-powered subscriber for receiving realtime events from CKAN''' 43 | 44 | def __init__(self, websocket_factory): 45 | RedisSubscriber.__init__(self) 46 | self.websocket_factory = websocket_factory 47 | 48 | def subscribe(self, event_classes): 49 | '''Subscribe to specific type of events 50 | 51 | :param event_classes: list of event classes, extending one of interfaces 52 | in the ckanext.realtime.event.base module 53 | :type event_classes: list 54 | 55 | ''' 56 | channels = [ec.event_name for ec in event_classes] 57 | RedisSubscriber.subscribe(self, *channels) 58 | log.msg('subscribed to {} new channels'.format(len(channels))) 59 | 60 | def messageReceived(self, channel, message): 61 | '''Pass the received CKAN events to WSS to be sent to WSCs''' 62 | 63 | if isinstance(message, basestring): 64 | self.websocket_factory.handle_from_redis(message) 65 | -------------------------------------------------------------------------------- /bin/travis-before-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # CKAN dependencies 4 | sudo apt-get update -q 5 | sudo apt-get install solr-jetty 6 | pip install -e 'git+https://github.com/ckan/ckan.git@ckan-2.2#egg=ckan' 7 | pip install --allow-all-external -r ~/virtualenv/python2.7/src/ckan/requirements.txt 8 | 9 | # ckanext-realtime 10 | python setup.py develop 11 | 12 | # Configure Solr 13 | echo -e "NO_START=0\nJETTY_HOST=127.0.0.1\nJETTY_PORT=8983\nJAVA_HOME=$JAVA_HOME" | sudo tee /etc/default/jetty 14 | sudo cp ~/virtualenv/python2.7/src/ckan/ckan/config/solr/schema.xml /etc/solr/conf/schema.xml 15 | sudo service jetty restart 16 | 17 | # Setup postgres' users and databases 18 | sudo -u postgres psql -c "CREATE USER ckan_default WITH PASSWORD 'pass';" 19 | sudo -u postgres psql -c "CREATE USER datastore_default WITH PASSWORD 'pass';" 20 | sudo -u postgres psql -c 'CREATE DATABASE ckan_default WITH OWNER ckan_default;' 21 | sudo -u postgres psql -c 'CREATE DATABASE datastore_default WITH OWNER ckan_default;' 22 | 23 | mkdir links 24 | PROJECT_DIR="`pwd`" 25 | CKAN_DIR="`python -c'import ckan; print ckan.__file__.rsplit("/",2)[0]'`" 26 | cd "$CKAN_DIR" 27 | paster make-config ckan development.ini --no-interactive 28 | 29 | sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/ckan_default:pass@localhost\/ckan_default/' development.ini 30 | sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/ckan_default:pass@localhost\/datastore_default/' development.ini 31 | sed -i -e '/\[app:main\]/a ckan.realtime.redis_host = 127.0.0.1\nckan.realtime.redis_port = 6379\nckan.realtime.wss_port = 9000' development.ini 32 | 33 | sed -i -e 's/^sqlalchemy.url.*/sqlalchemy.url = postgresql:\/\/ckan_default:pass@localhost\/ckan_default/' test-core.ini 34 | sed -i -e 's/.*datastore.write_url.*/ckan.datastore.write_url = postgresql:\/\/ckan_default:pass@localhost\/datastore_default/' test-core.ini 35 | sed -i -e '/\[app:main\]/a ckan.realtime.redis_host = 127.0.0.1\nckan.realtime.redis_port = 6379\nckan.realtime.wss_port = 9000' test-core.ini 36 | sed -i -e 's/.*datastore.read_url.*/ckan.datastore.read_url = postgresql:\/\/datastore_default@\/datastore_default/' test-core.ini 37 | 38 | ln -s "$CKAN_DIR"/test-core.ini "$PROJECT_DIR"/links/test-core.ini 39 | ln -s "$CKAN_DIR"/development.ini "$PROJECT_DIR"/links/development.ini 40 | ln -s "$CKAN_DIR"/who.ini "$PROJECT_DIR"/links/who.ini 41 | 42 | cat "$PROJECT_DIR"/links/test-core.ini 43 | 44 | paster db init -c "$PROJECT_DIR"/links/test-core.ini 45 | paster datastore set-permissions postgres -c "$PROJECT_DIR"/links/test-core.ini 46 | 47 | # install jasmine 48 | gem install jasmine 49 | 50 | # install dependencies 51 | cd "$PROJECT_DIR" 52 | pip install -r requirements.txt 53 | 54 | # start WebSocket server in test mode 55 | cd "$PROJECT_DIR"/bin 56 | python ckan_wss "$PROJECT_DIR"/links/development.ini --test & 57 | -------------------------------------------------------------------------------- /client/examples/ex1/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ckanext-realtime Example 1 6 | 7 | 8 | 9 | 10 | 79 | 80 |

ckanext-realtime Example 1

81 | 82 | 83 |
84 | 85 | 86 |
87 |
88 | 89 | 90 |
91 | 92 | 93 | 94 |

95 | 96 | 97 |
-------------------------------------------------------------------------------- /spec/CkanRTSpec.js: -------------------------------------------------------------------------------- 1 | /* 2 | * These specs require the websocket server to be running in test mode: 3 | * - python bin/ckan_wss --test 4 | */ 5 | var wsUri = "ws://127.0.0.1:9000/"; 6 | 7 | describe("CkanRT", function() { 8 | var rt; 9 | 10 | describe("subscribing/unsubscribing from observable datastores", function() { 11 | //should initiate WebSocket connection before each expectation 12 | beforeEach(function(done) { 13 | rt = new CkanRT(wsUri); 14 | rt.websocket.onopen = function() { 15 | done(); 16 | }; 17 | }); 18 | 19 | //should close the WebSocket connection after each expectation 20 | afterEach(function() { 21 | rt.websocket.close(); 22 | }); 23 | 24 | describe("when you subscribe/unsubscribe from observable datastores", function() { 25 | it("should succeed", function(done) { 26 | var resource = "observableResource"; 27 | var expectedResult = "SUCCESS"; 28 | rt.onDatastoreSubscribeResult = function(resourceId, status) { 29 | expect(resourceId).toEqual(resource); 30 | expect(status).toEqual(expectedResult); 31 | rt.onDatastoreUnsubscribeResult = function(resourceId, status) { 32 | expect(resourceId).toEqual(resource); 33 | expect(status).toEqual(expectedResult); 34 | done(); 35 | }; 36 | rt.datastoreUnsubscribe(resource); 37 | }; 38 | rt.datastoreSubscribe(resource); 39 | }); 40 | }); 41 | 42 | describe("when you subscribe to the same datastore repeatedly", function() { 43 | it("should fail", function(done) { 44 | var resource = "observableResource"; 45 | var expectedResult = "SUCCESS"; 46 | rt.onDatastoreSubscribeResult = function(resourceId, status) { 47 | expect(resourceId).toEqual(resource); 48 | expect(status).toEqual(expectedResult); 49 | 50 | expectedResult = "FAIL"; 51 | rt.onDatastoreSubscribeResult = function(resourceId, status) { 52 | expect(resourceId).toEqual(resource); 53 | expect(status).toEqual(expectedResult); 54 | done(); 55 | }; 56 | rt.datastoreSubscribe(resource); 57 | }; 58 | rt.datastoreSubscribe(resource); 59 | }); 60 | }); 61 | 62 | describe("when you unsubscribe from previously not subscribed datastore", function() { 63 | it("should fail", function(done) { 64 | var resource = "observableResource"; 65 | var expectedResult = "FAIL"; 66 | rt.onDatastoreUnsubscribeResult = function(resourceId, status) { 67 | expect(resourceId).toEqual(resource); 68 | expect(status).toEqual(expectedResult); 69 | done(); 70 | }; 71 | rt.datastoreUnsubscribe(resource); 72 | }); 73 | }); 74 | 75 | describe("when you subscribe to non-datastore resources", function() { 76 | it("should fail with error", function(done) { 77 | var resource = "nonDatastoreResource"; 78 | var expectedResult = "NOT-A-DATASTORE"; 79 | rt.onDatastoreSubscribeResult = function(resourceId, status) { 80 | expect(resourceId).toEqual(resource); 81 | expect(status).toEqual(expectedResult); 82 | done(); 83 | }; 84 | rt.datastoreSubscribe(resource); 85 | }); 86 | }); 87 | 88 | describe("when you subscribe to invalid resources", function() { 89 | it("should fail with error", function(done) { 90 | var resource = "invalidResource"; 91 | var expectedResult = "INVALID-RESOURCE"; 92 | rt.onDatastoreSubscribeResult = function(resourceId, status) { 93 | expect(resourceId).toEqual(resource); 94 | expect(status).toEqual(expectedResult); 95 | done(); 96 | }; 97 | rt.datastoreSubscribe(resource); 98 | }); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /bin/ckan_wss: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | '''Twisted-powered(autobahn & txredis) WebSocket server for ckanext-realtime. 3 | 4 | This server listens for realtime events from Redis (pub/sub) and sends them to 5 | WebSocket clients. 6 | 7 | ''' 8 | from __future__ import absolute_import 9 | 10 | import argparse 11 | import ConfigParser 12 | import sys 13 | 14 | import lockfile as lf 15 | 16 | from twisted.internet import reactor 17 | from twisted.python import log 18 | 19 | import ckanext.realtime.event.datastore as de 20 | import ckanext.realtime.twisted.websocket as ws 21 | import ckanext.realtime.twisted.redis as rd 22 | 23 | def main(config, test): 24 | if not test: 25 | apikey = config.get('app:main', 'ckan.realtime.apikey') 26 | ckan_api_url = config.get('app:main', 'ckan.realtime.ckan_api_url') 27 | 28 | redis_host = config.get('app:main', 'ckan.realtime.redis_host') 29 | redis_port = int(config.get('app:main', 'ckan.realtime.redis_port')) 30 | 31 | wss_port = config.get('app:main', 'ckan.realtime.wss_port') 32 | wss_url = 'ws://127.0.0.1:{}'.format(wss_port) 33 | else: 34 | # no need to connect to the real api if running in the test mode 35 | apikey = 'foo' 36 | ckan_api_url = 'foobar' 37 | 38 | redis_host = '127.0.0.1' 39 | redis_port = 6379 40 | 41 | wss_url = 'ws://127.0.0.1:9000' 42 | 43 | 44 | events_for_subscribing = [de.DatastoreInsertEvent, 45 | de.DatastoreDeleteEvent, 46 | de.DatastoreUpdateEvent] 47 | 48 | websocket_factory = ws.CkanWebSocketServerFactory(wss_url, 49 | ckan_api_url, 50 | apikey, 51 | test) 52 | websocket_factory.listen() 53 | rd.connect_event_processor(redis_host, 54 | redis_port, 55 | websocket_factory, 56 | events_for_subscribing) 57 | 58 | reactor.run() 59 | 60 | 61 | if __name__ == '__main__': 62 | parser = argparse.ArgumentParser(description='Run CKAN WebSocket server') 63 | parser.add_argument("config") 64 | 65 | # this flag is used for client testing sessions - it enables the 66 | # WebSocket server to provide fake replies to the clients, in some cases 67 | parser.add_argument('--test', dest='test', action='store_true', help='Run the WebSocket server in test mode') 68 | parser.add_argument('--lock-file', 69 | dest='lock_file', 70 | help='Full path to lock file, in order to prevent several instances from running (E. g. in case you start this by a cronjob)') 71 | parser.set_defaults(test=False, lock_file=None) 72 | 73 | args = parser.parse_args() 74 | 75 | config = ConfigParser.RawConfigParser() 76 | config.read(args.config) 77 | 78 | log.startLogging(sys.stdout) 79 | 80 | if args.lock_file: 81 | lock = lf.LockFile(args.lock_file) 82 | if lock.is_locked(): 83 | log.msg('Lock file already exists') 84 | sys.exit() 85 | 86 | try: 87 | lock.acquire() 88 | except lf.AlreadyLocked: 89 | log.msg('Already locked') 90 | sys.exit() 91 | except lf.LockFailed: 92 | log.msg('Locking failure') 93 | sys.exit() 94 | else: 95 | log.msg('Lock acquired') 96 | 97 | main(config, args.test) 98 | 99 | if args.lock_file: 100 | lock.release() 101 | log.msg('Lock released') 102 | -------------------------------------------------------------------------------- /ckanext/realtime/logic/action.py: -------------------------------------------------------------------------------- 1 | '''This module contains ckan API actions specific to ckanext-realtime.''' 2 | 3 | import logging 4 | import pylons 5 | import sqlalchemy 6 | 7 | import ckan.lib.navl.dictization_functions 8 | import ckan.plugins as p 9 | import ckanext.realtime as rt 10 | import ckanext.realtime.db as db 11 | from ckanext.realtime.event.event_dispatcher import EventDispatcher 12 | from ckanext.realtime.event.event_factory import EventFactory 13 | import ckanext.realtime.logic.schema as realtime_schema 14 | 15 | log = logging.getLogger(__name__) 16 | _validate = ckan.lib.navl.dictization_functions.validate 17 | 18 | 19 | def realtime_broadcast_event(context, data_dict): 20 | '''Broadcast event to registered listeners. 21 | 22 | :param event_type: the type of the event 23 | :type event_type: string 24 | :param resource_id: the id of the resource to which the event belongs 25 | (optional) 26 | :type resource_id: string 27 | :param package_id: the id of the package to which the event belongs 28 | (optional) 29 | 30 | ''' 31 | schema = context.get('schema', 32 | realtime_schema.realtime_broadcast_event_schema()) 33 | 34 | data_dict, errors = _validate(data_dict, schema, context) 35 | if errors: 36 | raise p.toolkit.ValidationError(errors) 37 | 38 | if not 'resource_id' in data_dict and not 'package_id' in data_dict: 39 | raise p.toolkit.ValidationError('Either resource_id or package_id or both, have to be set') 40 | 41 | p.toolkit.check_access('realtime_broadcast_event', context, data_dict) 42 | 43 | event = EventFactory.build_event(data_dict) 44 | EventDispatcher.dispatch_one(event) 45 | 46 | 47 | def datastore_make_observable(context, data_dict): 48 | '''Changes a simple datastore to an observable datastore. 49 | 50 | :param resource_id: id of the resource to which the datastore is bound 51 | :type resource_id: string 52 | 53 | ''' 54 | schema = context.get('schema', 55 | realtime_schema.datastore_make_observable_schema()) 56 | 57 | data_dict, errors = _validate(data_dict, schema, context) 58 | if errors: 59 | raise p.toolkit.ValidationError(errors) 60 | 61 | p.toolkit.check_access('datastore_make_observable', context, data_dict) 62 | 63 | if not _datastore_exists(data_dict): 64 | return {'success': False} 65 | db.add_datastore_notifier_trigger(data_dict['resource_id']) 66 | return {'success': True} 67 | 68 | 69 | def realtime_check_observable_datastore(context, data_dict): 70 | '''Check whether a particular datastore is observable 71 | 72 | :param resource_id: target resource 73 | :type resource_id: string 74 | 75 | :return: indication whether the resource is an observable datastore, 76 | non-observable datastore or not a datastore 77 | :rtype: dictionary 78 | 79 | ''' 80 | schema = context.get('schema', realtime_schema.realtime_check_observable_datastore_schema()) 81 | data_dict, errors = _validate(data_dict, schema, context) 82 | if errors: 83 | raise p.toolkit.ValidationError(errors) 84 | 85 | p.toolkit.check_access('realtime_check_observable_datastore', context, data_dict) 86 | 87 | if not _datastore_exists(data_dict): 88 | return {'is_observable': rt.NON_DATASTORE_MESSAGE} 89 | elif db.notifier_trigger_function_exists(data_dict['resource_id']): 90 | return {'is_observable': rt.YES_MESSAGE} 91 | else: 92 | return {'is_observable': rt.NO_MESSAGE} 93 | 94 | 95 | def _datastore_exists(data_dict): 96 | connection_url = pylons.config['ckan.datastore.write_url'] 97 | 98 | res_id = data_dict['resource_id'] 99 | resources_sql = sqlalchemy.text(u'''SELECT 1 FROM "_table_metadata" 100 | WHERE name = :id AND alias_of IS NULL''') 101 | 102 | engine = sqlalchemy.create_engine(connection_url) 103 | 104 | results = engine.execute(resources_sql, id=res_id) 105 | return results.rowcount > 0 106 | 107 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/alexandrainst/ckanext-realtime.png?branch=master 2 | :target: https://travis-ci.org/alexandrainst/ckanext-realtime 3 | .. image:: https://coveralls.io/repos/alexandrainst/ckanext-realtime/badge.png 4 | :target: https://coveralls.io/r/alexandrainst/ckanext-realtime 5 | 6 | ckanext-realtime 7 | ================ 8 | 9 | **ckanext-realtime** is CKAN plugin which makes your CKAN site into a **Realtime Data Portal**. By using **CkanRT.js** library, client applications 10 | can subscribe to realtime events from *Observable Datastores*. Check out our demo app `here `_. 11 | 12 | For more info read the `docs `_. Feel free to submit your ideas and pull requests if you would like to contribute. 13 | 14 | Copying and License 15 | ------------------- 16 | 17 | This material is copyright (c) 2014 `Alexandra Instituttet A/S `_ and `Gatesense `_. 18 | 19 | It is open and licensed under the GNU Affero General Public License (AGPL) v3.0 20 | whose full text may be found at: 21 | 22 | http://www.fsf.org/licensing/licenses/agpl-3.0.html 23 | 24 | 25 | Quick Start Guide 26 | ================= 27 | 28 | What's in the project? 29 | ---------------------- 30 | #. CKAN extension which enables observable datastores 31 | #. Datastore listener script (bin/datastore_listener) 32 | #. WebSocket server (bin/ckan_wss) 33 | #. JavaScript library for communication with the realtime CKAN (client/CkanRT.js) 34 | 35 | Environment 36 | ----------- 37 | The project has been tested on **Arch Linux** and **Ubuntu 12.04** servers. That Said, you will need: 38 | 39 | #. Redis Server 40 | #. CKAN (tested on 2.2 but it should work with earlier minor releases as well). 41 | #. ckanext-datastore plugin enabled 42 | 43 | 44 | Installation 45 | ------------ 46 | 47 | #. Install the plugin 48 | 49 | | *$ python setup.py develop* 50 | 51 | #. Install the requirements 52 | 53 | | *$ pip install -r requirements.txt* 54 | 55 | #. Set ckanext-realtime specific configuration options in your ckan config (e.g. /etc/ckan/default/production.ini): 56 | 57 | | *# ckanext-realtime settings* 58 | | 59 | | *#at what url can the Action API be reached* 60 | | *ckan.realtime.ckan_api_url = http://localhost:5000/api/3/action/* 61 | | 62 | | *# admin API key to be used by WebSocket server and datastore listener* 63 | | *ckan.realtime.apikey = * 64 | | 65 | | *#redis server host* 66 | | *ckan.realtime.redis_host = 127.0.0.1* 67 | | 68 | | *#redis server port* 69 | | *ckan.realtime.redis_port = 6379* 70 | | 71 | | *#WebsocketServer port* 72 | | *ckan.realtime.wss_port = 9000* 73 | 74 | #. Enable the plugin in the CKAN configuration file: 75 | 76 | | *# datastore plugin is a requirement for the realtime plugin* 77 | | *ckan.plugin = ... datastore realtime* 78 | 79 | 80 | Try 81 | --- 82 | After installing and configuring the plugin, start up CKAN and Redis. 83 | Then start the Datastore Listener script so that it can 84 | notify you about changes to Observable Datastores using PostgreSQL LISTEN/NOTIFY feature: 85 | 86 | | *$ python bin/datastore_listener /etc/ckan/default/development.ini* 87 | 88 | Lastly, start the WebSocket server: 89 | 90 | | *$ python bin/ckan_wss /etc/ckan/default/development.ini* 91 | 92 | Now you are ready to run the client examples found in client/examples folder of the project. 93 | 94 | Running Python Tests 95 | -------------------- 96 | Copy test.ini.example to test.ini and add your specific test settings. 97 | In order to run python tests, you have to start solr server and run this command: 98 | 99 | | *$ nosetests* 100 | 101 | Running Jasmine Specs 102 | --------------------- 103 | First, install jasmine test runner gem: 104 | 105 | | *$ gem install jasmine* 106 | 107 | 108 | Start the WebSocket server in the test mode: 109 | 110 | | *$ python bin/ckan_wss - -test* 111 | 112 | Start the jasmine test runner: 113 | 114 | | *$ rake jasmine* 115 | 116 | And run the tests in your browser by navigating to *localhost:8888* in your browser. Alternatively, execute the tests directly in your shell: 117 | 118 | | *$ rake jasmine:ci* 119 | -------------------------------------------------------------------------------- /bin/datastore_listener: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | ''' 4 | Listen for events from datastore tables using PostgreSQL LISTEN/NOTIFY. 5 | ''' 6 | 7 | import argparse 8 | import ConfigParser 9 | import json 10 | import lockfile as lf 11 | import psycopg2.extensions 12 | import requests 13 | import select 14 | import sqlalchemy 15 | import sys 16 | import urlparse 17 | 18 | def call_ckan_api(notify, ckan_api_url, apikey): 19 | '''Parses NOTIFY packages and forwards the info to ckan api. 20 | 21 | :param notify: NOTIFY package issued from datastore 22 | :param ckan_api_url: action api endpoint 23 | (E.g. 'http://localhost:5000/api/3/action/') 24 | 25 | :param apikey: ckan api key for this script, used to call 26 | 'realtime_broadcast_event' api function 27 | 28 | ''' 29 | url = urlparse.urljoin(ckan_api_url, 'realtime_broadcast_event') 30 | auth_header = {'Authorization': apikey, 31 | 'content-type': 'application/json'} 32 | 33 | print type(notify) 34 | tokens = notify.payload.split() 35 | try: 36 | op = tokens[0] 37 | resource_id = tokens[1] 38 | except IndexError, e: 39 | print e 40 | return 41 | except Exception, e: 42 | print e 43 | return 44 | 45 | if op == 'INSERT': 46 | event_type = 'datastore_insert' 47 | elif op == 'UPDATE': 48 | event_type = 'datastore_update' 49 | elif op == 'DELETE': 50 | event_type = 'datastore_delete' 51 | else: 52 | print 'unrecognized operation!' 53 | return 54 | 55 | payload = {'event_type': event_type, 'resource_id': resource_id} 56 | r = requests.post(url, data=json.dumps(payload), headers=auth_header) 57 | print r.text 58 | 59 | 60 | def make_connection(sqlalchemy_url): 61 | engine = sqlalchemy.create_engine(sqlalchemy_url) 62 | base_conn = engine.connect() 63 | sub_conn = base_conn.connection 64 | psycopg_conn = sub_conn.connection 65 | return psycopg_conn 66 | 67 | 68 | def main(config): 69 | datastore_url = config.get('app:main', 'ckan.datastore.read_url') 70 | apikey = config.get('app:main', 'ckan.realtime.apikey') 71 | ckan_api_url = config.get('app:main', 'ckan.realtime.ckan_api_url') 72 | 73 | conn = make_connection(datastore_url) 74 | conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT) 75 | curs = conn.cursor() 76 | curs.execute("LISTEN ckanextrealtime;") 77 | 78 | print "Waiting for notifications on channel 'ckanextrealtime'" 79 | while 1: 80 | if select.select([conn], [], [], 5) == ([], [], []): # 5 second timeout 81 | pass 82 | else: 83 | conn.poll() 84 | while conn.notifies: 85 | notify = conn.notifies.pop() 86 | print "Got NOTIFY:", notify.pid, notify.channel, notify.payload 87 | call_ckan_api(notify, ckan_api_url, apikey) 88 | 89 | 90 | if __name__ == '__main__': 91 | parser = argparse.ArgumentParser(description='Listen for events from PostgreSQL datastores with LISTEN/NOTIFY.') 92 | parser.add_argument("config") 93 | parser.add_argument('--lock-file', 94 | dest='lock_file', 95 | help='Full path to lock file, in order to prevent several instances from running (E. g. in case you start this by a cronjob)') 96 | parser.set_defaults(test=False, lock_file=None) 97 | args = parser.parse_args() 98 | 99 | config = ConfigParser.RawConfigParser() 100 | config.read(args.config) 101 | 102 | if args.lock_file: 103 | lock = lf.LockFile(args.lock_file) 104 | if lock.is_locked(): 105 | print 'Lock file already exists' 106 | sys.exit() 107 | 108 | try: 109 | lock.acquire() 110 | except lf.AlreadyLocked: 111 | print 'Already locked' 112 | sys.exit() 113 | except lf.LockFailed: 114 | print 'Locking failure' 115 | sys.exit() 116 | else: 117 | print 'Lock acquired' 118 | 119 | try: 120 | main(config) 121 | except: 122 | if args.lock_file: 123 | lock.release() 124 | print 'Lock released' 125 | -------------------------------------------------------------------------------- /client/examples/ex2/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ckanext-realtime Showcase 6 | 7 | 8 | 9 | 10 | 138 |

ckanext-realtime demonstration

139 |

140 | Use the below controls to insert/delete data to/from the sample datastore. 141 | With ckanext-realtime, every change (insert/update/delete) in datastores, subscibed to by some client, notifies that client in realtime. 142 | You may do with the realtime events what you will. In case of this sample app, the realtime events trigger refreshing of the datastore preview window. 143 |

144 | 145 |

For more details about ckanext-realtime plugin refer to our github repo

146 | 147 |
148 | 149 |
150 | 151 | 152 |
153 |
154 | 155 |
156 | 157 | 158 |
159 |
160 |
161 | 162 |
163 |
164 |
165 | -------------------------------------------------------------------------------- /docs/Makefile.default: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/ckanext-realtime.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/ckanext-realtime.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/ckanext-realtime" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/ckanext-realtime" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/source/tutorial.rst: -------------------------------------------------------------------------------- 1 | Realtime apps with CKAN (Tutorial) 2 | ================================== 3 | 4 | Intro 5 | ----- 6 | 7 | `ckanext-realtime `_ is the latest contributions to Free Software from Gatesense team. It is an extension for CKAN open data platform which enables app developers to make realtime applications with CKAN. More specifically, you can subscribe to resources (datastores) and thus monitor their changes in realtime. gatesense.com is a realtime-enabled CKAN portal, among other things. This means that you can build realtime apps with datasets found at http://gatesense.com/data/dataset . 8 | 9 | 10 | Requirements 11 | ------------ 12 | To complete this tutorial, you will need a CKAN data portal with ckanext-realtime installed and a bit of JavaScript, jQuery and HTML knowledge. CKAN Action API knowledge is not necessary as it is relatively straightforward. You may either setup your own CKAN instance or use `gatesense.com `_ because it already has ckanext-realtime installed on it. 13 | 14 | Tutorial 15 | -------- 16 | 17 | For this tutorial we will use a dummy dataset - `ckanext-realtime showcase `_. Alternatively, you can choose to use a dataset that contains real data like Realtime traffic data on `gatesense `_. 18 | 19 | Our client application will reload resource presentation in realtime, every time some new data comes in (much like this `one `_ ). 20 | 21 | In order to achieve realtime, ckanext-realtime uses WebSocket protocol. The WebSocket server is running on ws://gatesense.com:9998/ . 22 | 23 | Step 1 - download CkanRT.js library 24 | ----------------------------------- 25 | CkanRT.js is a simple abstraction on top of Javascript WebSocket client. You can find it on `github `_. Download it and place it in your app directory. 26 | 27 | Step 2 - basic structure 28 | ------------------------ 29 | We will start with an app with CkanRT and jQuery included which simply renders our resource data in a table: 30 | 31 | 32 | .. code-block:: html 33 | 34 | 35 | 36 | 37 | 38 | Realtime Apps with CKAN - Tutorial 39 | 40 | 41 | 42 | 43 | 90 |

Realtime Apps with CKAN - Tutorial

91 | 92 |
93 |
94 |
95 | 96 | If view the page now, you should be able to see some data rendered in table. 97 | 98 | Step 3 - insert data 99 | -------------------- 100 | Next step, implement a function to insert data. The function: 101 | 102 | .. code-block:: javascript 103 | 104 | //insert a new tuple into the sample datastore 105 | function insertToDatastore(msg, datastore) { 106 | var now = new Date(); 107 | 108 | //construct the payload 109 | var data = { 110 | resource_id : datastore, 111 | records : [{ 112 | message : msg, 113 | timestamp : now.toISOString() 114 | }], 115 | }; 116 | 117 | writeLog('Inserting into ' + datastore + '...'); 118 | 119 | jQuery.ajax({ 120 | url : actionApiRoot + 'datastore_create', 121 | type : 'POST', 122 | 123 | // You must authenticate with apikey when inserting 124 | beforeSend : function(request) { 125 | request.setRequestHeader("Authorization", apikey); 126 | }, 127 | 128 | data : JSON.stringify(data), 129 | dataType : 'application/json', 130 | }); 131 | } 132 | 133 | Now add some html for inputing text (just above the ouput): 134 | 135 | .. code-block:: html 136 | 137 |
138 | 139 |
140 | 141 | 142 |
143 | 144 | And hook up the button with some javascript in the init method: 145 | 146 | .. code-block:: javascript 147 | 148 | $("input[name='insert']").click(function() { 149 | var msg = jQuery("#message").val(); 150 | insertToDatastore(msg, datastoreResource); 151 | }); 152 | 153 | Step 4 - connect to notification server and subscribe to resource notifications 154 | ------------------------------------------------------------------------------- 155 | We've got an app which can insert data into datastore and display its data. 156 | Let's connect to our realtime server and subscribe to resource notifications (in the init method): 157 | 158 | .. code-block:: javascript 159 | 160 | //connect to notification server 161 | var rt = new CkanRT('ws://gatesense.com:9998/'); 162 | 163 | // define CkanRT callbacks 164 | 165 | //called when notification server reports back with subscribing status 166 | rt.onDatastoreSubscribeResult = function(resourceId, status) { 167 | writeLog('Subscribe to ' + resourceId + '. Status: ' + status); 168 | }; 169 | 170 | //called when notification server reports back with unsubscribing status 171 | rt.onDatastoreUnsubscribeResult = function(resourceId, status) { 172 | writeLog('Unsubscribe from ' + resourceId + '. Status: ' + status); 173 | }; 174 | 175 | //new event on one of your subscribtions 176 | rt.onDatastoreEvent = function(event) { 177 | writeLog('New event: ' + JSON.stringify(event)); 178 | }; 179 | 180 | //end of CkanRT specific callbacks 181 | 182 | //some WebSocket callbacks 183 | 184 | //subscribe to datastoreResource when WebSocket connection opens 185 | rt.websocket.onopen = function(evt) { 186 | writeLog("Connection to notification server opened."); 187 | rt.datastoreSubscribe(datastoreResource); 188 | }; 189 | 190 | //connection closed 191 | rt.websocket.onclose = function(evt) { 192 | writeLog("Disconnected from notification server"); 193 | }; 194 | 195 | //something's wrong... 196 | rt.websocket.onerror = function(evt) { 197 | writeLog('ERROR:' + evt.data, true); 198 | }; 199 | //end of WebSocket callbacks 200 | 201 | 202 | Step 5 - reload the table in realtime 203 | ------------------------------------- 204 | One last thing- let's reload the table when we get a notification because I'm really tired of pressing F5 :) 205 | 206 | .. code-block:: javascript 207 | 208 | rt.onDatastoreEvent = function(event) { 209 | writeLog('New event: ' + JSON.stringify(event)); 210 | reload(datastoreResource); 211 | }; 212 | 213 | 214 | Step 6 - We're done! 215 | -------------------- 216 | You can find the code for this app `here `_. You call also see the app `running `_ 217 | 218 | 219 | What's next? 220 | ------------ 221 | OK, so this app isn't very nice- can you come up with something better? Register at `gatesense.com `_, get your apikey and start hacking . I look forward to see what you build :) 222 | -------------------------------------------------------------------------------- /ckanext/realtime/tests/test_actions.py: -------------------------------------------------------------------------------- 1 | import paste.fixture 2 | import pylons.test 3 | import pylons.config as config 4 | import sqlalchemy 5 | import sqlalchemy.orm as orm 6 | 7 | import ckan.model as model 8 | import ckan.plugins as p 9 | import ckan.lib.create_test_data as ctd 10 | from ckanext.datastore.tests.helpers import rebuild_all_dbs, set_url_type 11 | 12 | import ckan.tests as tests 13 | 14 | import ckanext.realtime as rt 15 | 16 | 17 | class TestRealtimeActions(object): 18 | ''' Tests for actions ckanext.realtime.logic.action module 19 | ''' 20 | sysadmin_user = None 21 | normal_user = None 22 | 23 | @classmethod 24 | def setup_class(cls): 25 | '''Nose runs this method once to setup our test class.''' 26 | # Make the Paste TestApp that we'll use to simulate HTTP requests to 27 | # CKAN. 28 | cls.app = paste.fixture.TestApp(pylons.test.pylonsapp) 29 | 30 | 31 | engine = sqlalchemy.create_engine(config['ckan.datastore.write_url']) 32 | cls.Session = orm.scoped_session(orm.sessionmaker(bind=engine)) 33 | 34 | rebuild_all_dbs(cls.Session) 35 | p.load('datastore') 36 | p.load('realtime') 37 | 38 | ctd.CreateTestData.create() 39 | 40 | cls.sysadmin_user = model.User.get('testsysadmin') 41 | cls.normal_user = model.User.get('annafan') 42 | 43 | # make test resource writable through action api 44 | set_url_type( 45 | model.Package.get('annakarenina').resources, cls.sysadmin_user) 46 | 47 | cls._create_test_datastore() 48 | 49 | 50 | @classmethod 51 | def _create_test_datastore(cls): 52 | resource = model.Package.get('annakarenina').resources[0] 53 | tests.call_action_api(cls.app, 'datastore_create', 54 | resource_id=resource.id, 55 | apikey=cls.sysadmin_user.apikey) 56 | 57 | @classmethod 58 | def teardown_class(cls): 59 | '''Nose runs this method once after all the test methods in our class 60 | have been run. 61 | ''' 62 | rebuild_all_dbs(cls.Session) 63 | 64 | # unload plugins 65 | p.unload('datastore') 66 | p.unload('realtime') 67 | 68 | # realtime_make_observable tests 69 | def test_make_observable_by_admin(self): 70 | resource = model.Package.get('annakarenina').resources[0] 71 | 72 | tests.call_action_api(self.app, 'datastore_make_observable', 73 | resource_id=resource.id, 74 | apikey=self.sysadmin_user.apikey) 75 | 76 | def test_make_observable_by_normal_user(self): 77 | # should it really be 409? 78 | resource = model.Package.get('annakarenina').resources[0] 79 | 80 | tests.call_action_api(self.app, 'datastore_make_observable', 81 | resource_id=resource.id, 82 | apikey=self.normal_user.apikey, 83 | status=409) 84 | 85 | def test_make_observable_without_apikey(self): 86 | resource = model.Package.get('annakarenina').resources[0] 87 | tests.call_action_api(self.app, 'datastore_make_observable', 88 | resource_id=resource.id, 89 | status=403) 90 | 91 | def test_make_observable_by_admin_bad_request(self): 92 | tests.call_action_api(self.app, 'datastore_make_observable', 93 | apikey=self.sysadmin_user.apikey, 94 | status=409) 95 | 96 | # realtime_broadcast_events tests 97 | def test_broadcast_event_by_admin(self): 98 | resource = model.Package.get('annakarenina').resources[0] 99 | 100 | tests.call_action_api(self.app, 'realtime_broadcast_event', 101 | resource_id=resource.id, 102 | event_type='datastore_update', 103 | apikey=self.sysadmin_user.apikey) 104 | 105 | def test_broadcast_event_by_normal_user(self): 106 | # should it really be 409? 107 | resource = model.Package.get('annakarenina').resources[0] 108 | 109 | tests.call_action_api(self.app, 'realtime_broadcast_event', 110 | resource_id=resource.id, 111 | event_type='datastore_update', 112 | apikey=self.normal_user.apikey, 113 | status=409) 114 | 115 | def test_broadcast_event_without_apikey(self): 116 | resource = model.Package.get('annakarenina').resources[0] 117 | tests.call_action_api(self.app, 'realtime_broadcast_event', 118 | resource_id=resource.id, 119 | event_type='datastore_update', 120 | status=403) 121 | 122 | def test_broadcast_event_by_admin_bad_request(self): 123 | tests.call_action_api(self.app, 'realtime_broadcast_event', 124 | event_type='datastore_update', 125 | apikey=self.sysadmin_user.apikey, 126 | status=409) 127 | 128 | # realtime_check_observable_datastore tests 129 | def test_check_observable_datastore_by_admin(self): 130 | resource = model.Package.get('annakarenina').resources[0] 131 | 132 | # make observable 133 | tests.call_action_api(self.app, 'datastore_make_observable', 134 | resource_id=resource.id, 135 | apikey=self.sysadmin_user.apikey) 136 | 137 | # is observable? 138 | res = tests.call_action_api(self.app, 'realtime_check_observable_datastore', 139 | resource_id=resource.id, 140 | apikey=self.sysadmin_user.apikey) 141 | 142 | assert res['is_observable'] == rt.YES_MESSAGE 143 | 144 | def test_check_observable_datastore_by_normal_user(self): 145 | resource = model.Package.get('annakarenina').resources[0] 146 | # is observable? 147 | tests.call_action_api(self.app, 'realtime_check_observable_datastore', 148 | resource_id=resource.id, 149 | apikey=self.normal_user.apikey, 150 | status=409) 151 | 152 | def test_check_observable_datastore_without_apikey(self): 153 | resource = model.Package.get('annakarenina').resources[0] 154 | # is observable? 155 | tests.call_action_api(self.app, 'realtime_check_observable_datastore', 156 | resource_id=resource.id, 157 | status=403) 158 | 159 | def test_check_observable_datastore_by_admin_bad_request(self): 160 | # is observable? 161 | tests.call_action_api(self.app, 'realtime_check_observable_datastore', 162 | apikey=self.sysadmin_user.apikey, 163 | status=409) 164 | 165 | def test_check_normal_datastore(self): 166 | package = model.Package.get('annakarenina') 167 | 168 | # create new resource 169 | res = tests.call_action_api(self.app, 'datastore_create', 170 | resource={'package_id': package.id}, 171 | url='foo', 172 | apikey=self.sysadmin_user.apikey) 173 | 174 | # is observable? 175 | res = tests.call_action_api(self.app, 'realtime_check_observable_datastore', 176 | resource_id=res['resource_id'], 177 | apikey=self.sysadmin_user.apikey) 178 | 179 | assert res['is_observable'] == rt.NO_MESSAGE 180 | 181 | def test_check_non_datastore(self): 182 | package = model.Package.get('annakarenina') 183 | 184 | # create new resource 185 | res = tests.call_action_api(self.app, 'resource_create', 186 | package_id=package.id, 187 | url='foo', 188 | apikey=self.sysadmin_user.apikey) 189 | 190 | # is observable? 191 | res = tests.call_action_api(self.app, 'realtime_check_observable_datastore', 192 | resource_id=res['id'], 193 | apikey=self.sysadmin_user.apikey) 194 | 195 | assert res['is_observable'] == rt.NON_DATASTORE_MESSAGE 196 | 197 | 198 | def test_check_invalid_resource(self): 199 | # in observable? 200 | tests.call_action_api(self.app, 'realtime_check_observable_datastore', 201 | resource_id='invalidResource', 202 | apikey=self.sysadmin_user.apikey, 203 | status=409) 204 | -------------------------------------------------------------------------------- /ckanext/realtime/message_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import jsonpickle 3 | import requests 4 | import urlparse 5 | 6 | from twisted.python import log 7 | 8 | import ckanext.realtime as rt 9 | 10 | class message_handler_base(object): 11 | '''This class contains the logic for handling messages from WebSocket clients 12 | and Redis subscriber. You should use 1 of it's derived classes: 13 | MessageHandler or TestMessageHandler. 14 | 15 | ''' 16 | 17 | def __init__(self, api_url, apikey): 18 | self.api_url = api_url 19 | self.wss_api_key = apikey 20 | self.resource_to_clients = {} 21 | 22 | def register_websocket_client(self, client): 23 | '''Register WebSocket client so that it can receive 24 | messages from CKAN 25 | 26 | :param client: WebSocket client 27 | :type client: ckanext.realtime.twisted.websocket.CkanWebSocketServerProtocol 28 | 29 | ''' 30 | log.msg("register client {}".format(client.peer)) 31 | 32 | def unregister_websocket_client(self, client): 33 | ''' Unregister WebSocket client 34 | 35 | :param client: WebSocket client 36 | :type client: ckanext.realtime.twisted.websocket.CkanWebSocketServerProtocol 37 | 38 | ''' 39 | log.msg("unregister client {}".format(client.peer)) 40 | 41 | for client_list in self.resource_to_clients.values(): 42 | if client in client_list: 43 | client_list.remove(client) 44 | 45 | def handle_message_from_client(self, json_msg, client): 46 | '''Handle incoming messages from WebSocket clients 47 | 48 | This method should be called through either MessageHandler 49 | or TestMessageHandler. 50 | 51 | :param json_msg: json string containing request body that came from client. 52 | :type json_msg: basestring 53 | :param client: WebSocket client 54 | :type client: ckanext.realtime.twisted.websocket.CkanWebSocketServerProtocol 55 | 56 | ''' 57 | request = jsonpickle.decode(json_msg) 58 | response = None 59 | if request['type'] == 'datastoresubscribe': 60 | response = self._datastore_subscribe(request, client) 61 | elif request['type'] == 'datastoreunsubscribe': 62 | response = self._datastore_unsubscribe(request, client) 63 | 64 | if response: 65 | response = jsonpickle.encode(response) 66 | log.msg('response: ' + response) 67 | client.sendMessage(response) 68 | 69 | 70 | def handle_message_from_redis(self, json_msg): 71 | '''Handle incoming messages from CKAN through redis 72 | 73 | This method should be called through either MessageHandler 74 | or TestMessageHandler. 75 | 76 | :param json_msg: json string containing event to be sent to clients. 77 | :type json_msg: basestring 78 | 79 | ''' 80 | log.msg('From Redis: ' + json_msg) 81 | 82 | event = jsonpickle.decode(json_msg) 83 | resource_id = event.resource_id 84 | 85 | if resource_id in self.resource_to_clients: 86 | response = {'event': event.__dict__} 87 | response['type'] = 'datastoreevent' 88 | 89 | #event_name is a class attribute so it wasn't in the __dict__ 90 | response['event']['name'] = event.event_name 91 | 92 | for client in self.resource_to_clients[resource_id]: 93 | log.msg('To WSC: ' +client.peer) 94 | client.sendMessage(jsonpickle.encode(response)) 95 | 96 | def _datastore_unsubscribe(self, request, client): 97 | resource_id = request['resource_id'] 98 | result = rt.SUCCESS_MESSAGE if self._remove_subscription(resource_id, client) else rt.FAIL_MESSAGE 99 | 100 | return {'type': 'datastoreunsubscribe', 101 | 'resource_id': resource_id, 102 | 'result': result} 103 | 104 | def _add_subscribtion(self, resource_id, client): 105 | if not resource_id in self.resource_to_clients: 106 | self.resource_to_clients[resource_id] = [] 107 | # the subscription has been previously registered 108 | if client in self.resource_to_clients[resource_id]: 109 | return False 110 | # add subscription 111 | self.resource_to_clients[resource_id].append(client) 112 | return True 113 | 114 | def _remove_subscription(self, resource_id, client): 115 | # must exist beforehand 116 | if not (resource_id in self.resource_to_clients and client in self.resource_to_clients[resource_id]): 117 | return False 118 | 119 | self.resource_to_clients[resource_id].remove(client) 120 | return True 121 | 122 | 123 | class MessageHandler(message_handler_base): 124 | '''This class handles the requests from WebSocket clients and gives 125 | appropriate responses. 126 | 127 | ''' 128 | def __init__(self, api_url, apikey): 129 | message_handler_base.__init__(self, api_url, apikey) 130 | 131 | def _datastore_subscribe(self, request, client): 132 | 133 | def datastore_make_observable(resource_id, apikey): 134 | url = urlparse.urljoin(self.api_url, 'datastore_make_observable') 135 | payload = {'resource_id': resource_id} 136 | r = requests.post(url, 137 | data=jsonpickle.encode(payload), 138 | headers={'Authorization': apikey, 139 | 'Content-Type': 'application/json'}) 140 | log.msg(r.text) 141 | response = jsonpickle.decode(r.text) 142 | return response['result']['success'] 143 | 144 | resource_id = request['resource_id'] 145 | 146 | # ask the CKAN API if the datastore is observable 147 | url = urlparse.urljoin(self.api_url, 'realtime_check_observable_datastore') 148 | payload = {'resource_id': resource_id} 149 | r = requests.post(url, 150 | data=jsonpickle.encode(payload), 151 | headers={'Authorization': self.wss_api_key, 152 | 'Content-Type': 'application/json'}) 153 | log.msg(r.text) 154 | 155 | 156 | # this status code does not necessarily mean 157 | # "invalid resource" (TODO: maybe should come up with something else?) 158 | if r.status_code == 409: 159 | return {'type': 'datastoresubscribe', 160 | 'resource_id': request['resource_id'], 161 | 'result': rt.INVALID_RESOURCE_MESSAGE} 162 | 163 | # read response from the CKAN API 164 | response = jsonpickle.decode(r.text) 165 | 166 | # decide what to do 167 | is_observable = response['result']['is_observable'] 168 | if is_observable == rt.YES_MESSAGE: 169 | result = rt.SUCCESS_MESSAGE if self._add_subscribtion(resource_id, client) else rt.FAIL_MESSAGE 170 | 171 | return {'type': 'datastoresubscribe', 172 | 'resource_id': request['resource_id'], 173 | 'result': result} 174 | 175 | elif is_observable == rt.NO_MESSAGE: 176 | if datastore_make_observable(request['resource_id'], self.wss_api_key): 177 | result = rt.SUCCESS_MESSAGE 178 | else: 179 | result = rt.FAIL_MESSAGE 180 | 181 | return {'type': 'datastoresubscribe', 182 | 'resource_id': request['resource_id'], 183 | 'result': result} 184 | 185 | elif is_observable == rt.NON_DATASTORE_MESSAGE: 186 | return {'type': 'datastoresubscribe', 187 | 'resource_id': request['resource_id'], 188 | 'result': rt.NON_DATASTORE_MESSAGE} 189 | 190 | 191 | class TestMessageHandler(message_handler_base): 192 | '''Mock API of ClientMessageHandler for testing purposes''' 193 | 194 | def __init__(self, api_url, apikey): 195 | message_handler_base.__init__(self, api_url, apikey) 196 | 197 | def _datastore_subscribe(self, request, client): 198 | resource_id = request['resource_id'] 199 | 200 | if resource_id == 'observableResource': 201 | result = rt.SUCCESS_MESSAGE if self._add_subscribtion(resource_id, client) else rt.FAIL_MESSAGE 202 | return {'type': 'datastoresubscribe', 203 | 'resource_id': resource_id, 204 | 'result': result} 205 | 206 | elif resource_id == 'nonDatastoreResource': 207 | return {'type': 'datastoresubscribe', 208 | 'resource_id': resource_id, 209 | 'result': rt.NON_DATASTORE_MESSAGE} 210 | 211 | else: 212 | return {'type': 'datastoresubscribe', 213 | 'resource_id': resource_id, 214 | 'result': rt.INVALID_RESOURCE_MESSAGE} 215 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # ckanext-realtime documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jan 31 00:07:43 2014. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | #sys.path.insert(0, os.path.abspath('.')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | #needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [ 33 | 'sphinx.ext.autodoc', 34 | 'sphinx.ext.doctest', 35 | 'sphinx.ext.intersphinx', 36 | 'sphinx.ext.todo', 37 | 'sphinx.ext.coverage', 38 | 'sphinx.ext.pngmath', 39 | 'sphinx.ext.ifconfig', 40 | 'sphinx.ext.viewcode', 41 | ] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ['_templates'] 45 | 46 | # The suffix of source filenames. 47 | source_suffix = '.rst' 48 | 49 | # The encoding of source files. 50 | #source_encoding = 'utf-8-sig' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # General information about the project. 56 | project = 'ckanext-realtime' 57 | copyright = '2014, Alexandra Institute and Gatesense' 58 | 59 | # The version info for the project you're documenting, acts as replacement for 60 | # |version| and |release|, also used in various other places throughout the 61 | # built documents. 62 | # 63 | # The short X.Y version. 64 | version = '0.4' 65 | # The full version, including alpha/beta/rc tags. 66 | release = '0.4' 67 | 68 | # The language for content autogenerated by Sphinx. Refer to documentation 69 | # for a list of supported languages. 70 | #language = None 71 | 72 | # There are two options for replacing |today|: either, you set today to some 73 | # non-false value, then it is used: 74 | #today = '' 75 | # Else, today_fmt is used as the format for a strftime call. 76 | #today_fmt = '%B %d, %Y' 77 | 78 | # List of patterns, relative to source directory, that match files and 79 | # directories to ignore when looking for source files. 80 | exclude_patterns = [] 81 | 82 | # The reST default role (used for this markup: `text`) to use for all 83 | # documents. 84 | #default_role = None 85 | 86 | # If true, '()' will be appended to :func: etc. cross-reference text. 87 | #add_function_parentheses = True 88 | 89 | # If true, the current module name will be prepended to all description 90 | # unit titles (such as .. function::). 91 | #add_module_names = True 92 | 93 | # If true, sectionauthor and moduleauthor directives will be shown in the 94 | # output. They are ignored by default. 95 | #show_authors = False 96 | 97 | # The name of the Pygments (syntax highlighting) style to use. 98 | pygments_style = 'sphinx' 99 | 100 | # A list of ignored prefixes for module index sorting. 101 | #modindex_common_prefix = [] 102 | 103 | # If true, keep warnings as "system message" paragraphs in the built documents. 104 | #keep_warnings = False 105 | 106 | 107 | # -- Options for HTML output ---------------------------------------------- 108 | 109 | # The theme to use for HTML and HTML Help pages. See the documentation for 110 | # a list of builtin themes. 111 | 112 | # rtd theme 113 | import sphinx_rtd_theme 114 | html_theme = "sphinx_rtd_theme" 115 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 116 | 117 | #html_theme = 'default' 118 | 119 | # Theme options are theme-specific and customize the look and feel of a theme 120 | # further. For a list of options available for each theme, see the 121 | # documentation. 122 | #html_theme_options = { 123 | # "rightsidebar": "true", 124 | # "relbarbgcolor": "black" 125 | #} 126 | 127 | 128 | # Add any paths that contain custom themes here, relative to this directory. 129 | #html_theme_path = [] 130 | 131 | # The name for this set of Sphinx documents. If None, it defaults to 132 | # " v documentation". 133 | #html_title = None 134 | 135 | # A shorter title for the navigation bar. Default is the same as html_title. 136 | #html_short_title = None 137 | 138 | # The name of an image file (relative to this directory) to place at the top 139 | # of the sidebar. 140 | #html_logo = None 141 | 142 | # The name of an image file (within the static path) to use as favicon of the 143 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 144 | # pixels large. 145 | #html_favicon = None 146 | 147 | # Add any paths that contain custom static files (such as style sheets) here, 148 | # relative to this directory. They are copied after the builtin static files, 149 | # so a file named "default.css" will overwrite the builtin "default.css". 150 | html_static_path = ['_static'] 151 | 152 | # Add any extra paths that contain custom files (such as robots.txt or 153 | # .htaccess) here, relative to this directory. These files are copied 154 | # directly to the root of the documentation. 155 | #html_extra_path = [] 156 | 157 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 158 | # using the given strftime format. 159 | #html_last_updated_fmt = '%b %d, %Y' 160 | 161 | # If true, SmartyPants will be used to convert quotes and dashes to 162 | # typographically correct entities. 163 | #html_use_smartypants = True 164 | 165 | # Custom sidebar templates, maps document names to template names. 166 | html_sidebars = { 167 | '**': ['relations.html', 'globaltoc.html'], 168 | # There's no point in showing the table of contents in the sidebar on the 169 | # table of contents page! So: 170 | 'index': ['relations.html'], 171 | } 172 | 173 | 174 | # Additional templates that should be rendered to pages, maps page names to 175 | # template names. 176 | #html_additional_pages = {} 177 | 178 | # If false, no module index is generated. 179 | #html_domain_indices = True 180 | 181 | # If false, no index is generated. 182 | #html_use_index = True 183 | 184 | # If true, the index is split into individual pages for each letter. 185 | #html_split_index = False 186 | 187 | # If true, links to the reST sources are added to the pages. 188 | #html_show_sourcelink = True 189 | 190 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 191 | #html_show_sphinx = True 192 | 193 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 194 | #html_show_copyright = True 195 | 196 | # If true, an OpenSearch description file will be output, and all pages will 197 | # contain a tag referring to it. The value of this option must be the 198 | # base URL from which the finished HTML is served. 199 | #html_use_opensearch = '' 200 | 201 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 202 | #html_file_suffix = None 203 | 204 | # Output file base name for HTML help builder. 205 | htmlhelp_basename = 'ckanext-realtimedoc' 206 | 207 | 208 | # -- Options for LaTeX output --------------------------------------------- 209 | 210 | latex_elements = { 211 | # The paper size ('letterpaper' or 'a4paper'). 212 | #'papersize': 'letterpaper', 213 | 214 | # The font size ('10pt', '11pt' or '12pt'). 215 | #'pointsize': '10pt', 216 | 217 | # Additional stuff for the LaTeX preamble. 218 | #'preamble': '', 219 | } 220 | 221 | # Grouping the document tree into LaTeX files. List of tuples 222 | # (source start file, target name, title, 223 | # author, documentclass [howto, manual, or own class]). 224 | latex_documents = [ 225 | ('index', 'ckanext-realtime.tex', 'ckanext-realtime Documentation', 226 | 'Alexandra Institute', 'manual'), 227 | ] 228 | 229 | # The name of an image file (relative to this directory) to place at the top of 230 | # the title page. 231 | #latex_logo = None 232 | 233 | # For "manual" documents, if this is true, then toplevel headings are parts, 234 | # not chapters. 235 | #latex_use_parts = False 236 | 237 | # If true, show page references after internal links. 238 | #latex_show_pagerefs = False 239 | 240 | # If true, show URL addresses after external links. 241 | #latex_show_urls = False 242 | 243 | # Documents to append as an appendix to all manuals. 244 | #latex_appendices = [] 245 | 246 | # If false, no module index is generated. 247 | #latex_domain_indices = True 248 | 249 | 250 | # -- Options for manual page output --------------------------------------- 251 | 252 | # One entry per manual page. List of tuples 253 | # (source start file, name, description, authors, manual section). 254 | man_pages = [ 255 | ('index', 'ckanext-realtime', 'ckanext-realtime Documentation', 256 | ['Alexandra Institute'], 1) 257 | ] 258 | 259 | # If true, show URL addresses after external links. 260 | #man_show_urls = False 261 | 262 | 263 | # -- Options for Texinfo output ------------------------------------------- 264 | 265 | # Grouping the document tree into Texinfo files. List of tuples 266 | # (source start file, target name, title, author, 267 | # dir menu entry, description, category) 268 | texinfo_documents = [ 269 | ('index', 'ckanext-realtime', 'ckanext-realtime Documentation', 270 | 'Alexandra Institute', 'ckanext-realtime', 'One line description of project.', 271 | 'Miscellaneous'), 272 | ] 273 | 274 | # Documents to append as an appendix to all manuals. 275 | #texinfo_appendices = [] 276 | 277 | # If false, no module index is generated. 278 | #texinfo_domain_indices = True 279 | 280 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 281 | #texinfo_show_urls = 'footnote' 282 | 283 | # If true, do not generate a @detailmenu in the "Top" node's menu. 284 | #texinfo_no_detailmenu = False 285 | 286 | 287 | # -- Options for Epub output ---------------------------------------------- 288 | 289 | # Bibliographic Dublin Core info. 290 | epub_title = 'ckanext-realtime' 291 | epub_author = 'Alexandra Institute' 292 | epub_publisher = 'Alexandra Institute' 293 | epub_copyright = '2014, Alexandra Institute' 294 | 295 | # The basename for the epub file. It defaults to the project name. 296 | #epub_basename = 'ckanext-realtime' 297 | 298 | # The HTML theme for the epub output. Since the default themes are not optimized 299 | # for small screen space, using the same theme for HTML and epub output is 300 | # usually not wise. This defaults to 'epub', a theme designed to save visual 301 | # space. 302 | #epub_theme = 'epub' 303 | 304 | # The language of the text. It defaults to the language option 305 | # or en if the language is not set. 306 | #epub_language = '' 307 | 308 | # The scheme of the identifier. Typical schemes are ISBN or URL. 309 | #epub_scheme = '' 310 | 311 | # The unique identifier of the text. This can be a ISBN number 312 | # or the project homepage. 313 | #epub_identifier = '' 314 | 315 | # A unique identification for the text. 316 | #epub_uid = '' 317 | 318 | # A tuple containing the cover image and cover page html template filenames. 319 | #epub_cover = () 320 | 321 | # A sequence of (type, uri, title) tuples for the guide element of content.opf. 322 | #epub_guide = () 323 | 324 | # HTML files that should be inserted before the pages created by sphinx. 325 | # The format is a list of tuples containing the path and title. 326 | #epub_pre_files = [] 327 | 328 | # HTML files shat should be inserted after the pages created by sphinx. 329 | # The format is a list of tuples containing the path and title. 330 | #epub_post_files = [] 331 | 332 | # A list of files that should not be packed into the epub file. 333 | epub_exclude_files = ['search.html'] 334 | 335 | # The depth of the table of contents in toc.ncx. 336 | #epub_tocdepth = 3 337 | 338 | # Allow duplicate toc entries. 339 | #epub_tocdup = True 340 | 341 | # Choose between 'default' and 'includehidden'. 342 | #epub_tocscope = 'default' 343 | 344 | # Fix unsupported image types using the PIL. 345 | #epub_fix_images = False 346 | 347 | # Scale large images. 348 | #epub_max_image_width = 0 349 | 350 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 351 | #epub_show_urls = 'inline' 352 | 353 | # If false, no index is generated. 354 | #epub_use_index = True 355 | 356 | 357 | # Example configuration for intersphinx: refer to the Python standard library. 358 | intersphinx_mapping = {'http://docs.python.org/': None} 359 | -------------------------------------------------------------------------------- /client/examples/jquery-1.11.0.min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery v1.11.0 | (c) 2005, 2014 jQuery Foundation, Inc. | jquery.org/license */ 2 | !function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=c.slice,e=c.concat,f=c.push,g=c.indexOf,h={},i=h.toString,j=h.hasOwnProperty,k="".trim,l={},m="1.11.0",n=function(a,b){return new n.fn.init(a,b)},o=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,p=/^-ms-/,q=/-([\da-z])/gi,r=function(a,b){return b.toUpperCase()};n.fn=n.prototype={jquery:m,constructor:n,selector:"",length:0,toArray:function(){return d.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:d.call(this)},pushStack:function(a){var b=n.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a,b){return n.each(this,a,b)},map:function(a){return this.pushStack(n.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:c.sort,splice:c.splice},n.extend=n.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||n.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(e=arguments[h]))for(d in e)a=g[d],c=e[d],g!==c&&(j&&c&&(n.isPlainObject(c)||(b=n.isArray(c)))?(b?(b=!1,f=a&&n.isArray(a)?a:[]):f=a&&n.isPlainObject(a)?a:{},g[d]=n.extend(j,f,c)):void 0!==c&&(g[d]=c));return g},n.extend({expando:"jQuery"+(m+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===n.type(a)},isArray:Array.isArray||function(a){return"array"===n.type(a)},isWindow:function(a){return null!=a&&a==a.window},isNumeric:function(a){return a-parseFloat(a)>=0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},isPlainObject:function(a){var b;if(!a||"object"!==n.type(a)||a.nodeType||n.isWindow(a))return!1;try{if(a.constructor&&!j.call(a,"constructor")&&!j.call(a.constructor.prototype,"isPrototypeOf"))return!1}catch(c){return!1}if(l.ownLast)for(b in a)return j.call(a,b);for(b in a);return void 0===b||j.call(a,b)},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?h[i.call(a)]||"object":typeof a},globalEval:function(b){b&&n.trim(b)&&(a.execScript||function(b){a.eval.call(a,b)})(b)},camelCase:function(a){return a.replace(p,"ms-").replace(q,r)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b,c){var d,e=0,f=a.length,g=s(a);if(c){if(g){for(;f>e;e++)if(d=b.apply(a[e],c),d===!1)break}else for(e in a)if(d=b.apply(a[e],c),d===!1)break}else if(g){for(;f>e;e++)if(d=b.call(a[e],e,a[e]),d===!1)break}else for(e in a)if(d=b.call(a[e],e,a[e]),d===!1)break;return a},trim:k&&!k.call("\ufeff\xa0")?function(a){return null==a?"":k.call(a)}:function(a){return null==a?"":(a+"").replace(o,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(s(Object(a))?n.merge(c,"string"==typeof a?[a]:a):f.call(c,a)),c},inArray:function(a,b,c){var d;if(b){if(g)return g.call(b,a,c);for(d=b.length,c=c?0>c?Math.max(0,d+c):c:0;d>c;c++)if(c in b&&b[c]===a)return c}return-1},merge:function(a,b){var c=+b.length,d=0,e=a.length;while(c>d)a[e++]=b[d++];if(c!==c)while(void 0!==b[d])a[e++]=b[d++];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,f=0,g=a.length,h=s(a),i=[];if(h)for(;g>f;f++)d=b(a[f],f,c),null!=d&&i.push(d);else for(f in a)d=b(a[f],f,c),null!=d&&i.push(d);return e.apply([],i)},guid:1,proxy:function(a,b){var c,e,f;return"string"==typeof b&&(f=a[b],b=a,a=f),n.isFunction(a)?(c=d.call(arguments,2),e=function(){return a.apply(b||this,c.concat(d.call(arguments)))},e.guid=a.guid=a.guid||n.guid++,e):void 0},now:function(){return+new Date},support:l}),n.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(a,b){h["[object "+b+"]"]=b.toLowerCase()});function s(a){var b=a.length,c=n.type(a);return"function"===c||n.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var t=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s="sizzle"+-new Date,t=a.document,u=0,v=0,w=eb(),x=eb(),y=eb(),z=function(a,b){return a===b&&(j=!0),0},A="undefined",B=1<<31,C={}.hasOwnProperty,D=[],E=D.pop,F=D.push,G=D.push,H=D.slice,I=D.indexOf||function(a){for(var b=0,c=this.length;c>b;b++)if(this[b]===a)return b;return-1},J="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",K="[\\x20\\t\\r\\n\\f]",L="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",M=L.replace("w","w#"),N="\\["+K+"*("+L+")"+K+"*(?:([*^$|!~]?=)"+K+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+M+")|)|)"+K+"*\\]",O=":("+L+")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|"+N.replace(3,8)+")*)|.*)\\)|)",P=new RegExp("^"+K+"+|((?:^|[^\\\\])(?:\\\\.)*)"+K+"+$","g"),Q=new RegExp("^"+K+"*,"+K+"*"),R=new RegExp("^"+K+"*([>+~]|"+K+")"+K+"*"),S=new RegExp("="+K+"*([^\\]'\"]*?)"+K+"*\\]","g"),T=new RegExp(O),U=new RegExp("^"+M+"$"),V={ID:new RegExp("^#("+L+")"),CLASS:new RegExp("^\\.("+L+")"),TAG:new RegExp("^("+L.replace("w","w*")+")"),ATTR:new RegExp("^"+N),PSEUDO:new RegExp("^"+O),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+K+"*(even|odd|(([+-]|)(\\d*)n|)"+K+"*(?:([+-]|)"+K+"*(\\d+)|))"+K+"*\\)|)","i"),bool:new RegExp("^(?:"+J+")$","i"),needsContext:new RegExp("^"+K+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+K+"*((?:-\\d)?\\d*)"+K+"*\\)|)(?=[^-]|$)","i")},W=/^(?:input|select|textarea|button)$/i,X=/^h\d$/i,Y=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,$=/[+~]/,_=/'|\\/g,ab=new RegExp("\\\\([\\da-f]{1,6}"+K+"?|("+K+")|.)","ig"),bb=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)};try{G.apply(D=H.call(t.childNodes),t.childNodes),D[t.childNodes.length].nodeType}catch(cb){G={apply:D.length?function(a,b){F.apply(a,H.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function db(a,b,d,e){var f,g,h,i,j,m,p,q,u,v;if((b?b.ownerDocument||b:t)!==l&&k(b),b=b||l,d=d||[],!a||"string"!=typeof a)return d;if(1!==(i=b.nodeType)&&9!==i)return[];if(n&&!e){if(f=Z.exec(a))if(h=f[1]){if(9===i){if(g=b.getElementById(h),!g||!g.parentNode)return d;if(g.id===h)return d.push(g),d}else if(b.ownerDocument&&(g=b.ownerDocument.getElementById(h))&&r(b,g)&&g.id===h)return d.push(g),d}else{if(f[2])return G.apply(d,b.getElementsByTagName(a)),d;if((h=f[3])&&c.getElementsByClassName&&b.getElementsByClassName)return G.apply(d,b.getElementsByClassName(h)),d}if(c.qsa&&(!o||!o.test(a))){if(q=p=s,u=b,v=9===i&&a,1===i&&"object"!==b.nodeName.toLowerCase()){m=ob(a),(p=b.getAttribute("id"))?q=p.replace(_,"\\$&"):b.setAttribute("id",q),q="[id='"+q+"'] ",j=m.length;while(j--)m[j]=q+pb(m[j]);u=$.test(a)&&mb(b.parentNode)||b,v=m.join(",")}if(v)try{return G.apply(d,u.querySelectorAll(v)),d}catch(w){}finally{p||b.removeAttribute("id")}}}return xb(a.replace(P,"$1"),b,d,e)}function eb(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function fb(a){return a[s]=!0,a}function gb(a){var b=l.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function hb(a,b){var c=a.split("|"),e=a.length;while(e--)d.attrHandle[c[e]]=b}function ib(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||B)-(~a.sourceIndex||B);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function jb(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function kb(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function lb(a){return fb(function(b){return b=+b,fb(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function mb(a){return a&&typeof a.getElementsByTagName!==A&&a}c=db.support={},f=db.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},k=db.setDocument=function(a){var b,e=a?a.ownerDocument||a:t,g=e.defaultView;return e!==l&&9===e.nodeType&&e.documentElement?(l=e,m=e.documentElement,n=!f(e),g&&g!==g.top&&(g.addEventListener?g.addEventListener("unload",function(){k()},!1):g.attachEvent&&g.attachEvent("onunload",function(){k()})),c.attributes=gb(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=gb(function(a){return a.appendChild(e.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=Y.test(e.getElementsByClassName)&&gb(function(a){return a.innerHTML="
",a.firstChild.className="i",2===a.getElementsByClassName("i").length}),c.getById=gb(function(a){return m.appendChild(a).id=s,!e.getElementsByName||!e.getElementsByName(s).length}),c.getById?(d.find.ID=function(a,b){if(typeof b.getElementById!==A&&n){var c=b.getElementById(a);return c&&c.parentNode?[c]:[]}},d.filter.ID=function(a){var b=a.replace(ab,bb);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(ab,bb);return function(a){var c=typeof a.getAttributeNode!==A&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return typeof b.getElementsByTagName!==A?b.getElementsByTagName(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return typeof b.getElementsByClassName!==A&&n?b.getElementsByClassName(a):void 0},p=[],o=[],(c.qsa=Y.test(e.querySelectorAll))&&(gb(function(a){a.innerHTML="",a.querySelectorAll("[t^='']").length&&o.push("[*^$]="+K+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||o.push("\\["+K+"*(?:value|"+J+")"),a.querySelectorAll(":checked").length||o.push(":checked")}),gb(function(a){var b=e.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&o.push("name"+K+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||o.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),o.push(",.*:")})),(c.matchesSelector=Y.test(q=m.webkitMatchesSelector||m.mozMatchesSelector||m.oMatchesSelector||m.msMatchesSelector))&&gb(function(a){c.disconnectedMatch=q.call(a,"div"),q.call(a,"[s!='']:x"),p.push("!=",O)}),o=o.length&&new RegExp(o.join("|")),p=p.length&&new RegExp(p.join("|")),b=Y.test(m.compareDocumentPosition),r=b||Y.test(m.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},z=b?function(a,b){if(a===b)return j=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===e||a.ownerDocument===t&&r(t,a)?-1:b===e||b.ownerDocument===t&&r(t,b)?1:i?I.call(i,a)-I.call(i,b):0:4&d?-1:1)}:function(a,b){if(a===b)return j=!0,0;var c,d=0,f=a.parentNode,g=b.parentNode,h=[a],k=[b];if(!f||!g)return a===e?-1:b===e?1:f?-1:g?1:i?I.call(i,a)-I.call(i,b):0;if(f===g)return ib(a,b);c=a;while(c=c.parentNode)h.unshift(c);c=b;while(c=c.parentNode)k.unshift(c);while(h[d]===k[d])d++;return d?ib(h[d],k[d]):h[d]===t?-1:k[d]===t?1:0},e):l},db.matches=function(a,b){return db(a,null,null,b)},db.matchesSelector=function(a,b){if((a.ownerDocument||a)!==l&&k(a),b=b.replace(S,"='$1']"),!(!c.matchesSelector||!n||p&&p.test(b)||o&&o.test(b)))try{var d=q.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return db(b,l,null,[a]).length>0},db.contains=function(a,b){return(a.ownerDocument||a)!==l&&k(a),r(a,b)},db.attr=function(a,b){(a.ownerDocument||a)!==l&&k(a);var e=d.attrHandle[b.toLowerCase()],f=e&&C.call(d.attrHandle,b.toLowerCase())?e(a,b,!n):void 0;return void 0!==f?f:c.attributes||!n?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},db.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},db.uniqueSort=function(a){var b,d=[],e=0,f=0;if(j=!c.detectDuplicates,i=!c.sortStable&&a.slice(0),a.sort(z),j){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return i=null,a},e=db.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=db.selectors={cacheLength:50,createPseudo:fb,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(ab,bb),a[3]=(a[4]||a[5]||"").replace(ab,bb),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||db.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&db.error(a[0]),a},PSEUDO:function(a){var b,c=!a[5]&&a[2];return V.CHILD.test(a[0])?null:(a[3]&&void 0!==a[4]?a[2]=a[4]:c&&T.test(c)&&(b=ob(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(ab,bb).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=w[a+" "];return b||(b=new RegExp("(^|"+K+")"+a+"("+K+"|$)"))&&w(a,function(a){return b.test("string"==typeof a.className&&a.className||typeof a.getAttribute!==A&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=db.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),t=!i&&!h;if(q){if(f){while(p){l=b;while(l=l[p])if(h?l.nodeName.toLowerCase()===r:1===l.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&t){k=q[s]||(q[s]={}),j=k[a]||[],n=j[0]===u&&j[1],m=j[0]===u&&j[2],l=n&&q.childNodes[n];while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if(1===l.nodeType&&++m&&l===b){k[a]=[u,n,m];break}}else if(t&&(j=(b[s]||(b[s]={}))[a])&&j[0]===u)m=j[1];else while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if((h?l.nodeName.toLowerCase()===r:1===l.nodeType)&&++m&&(t&&((l[s]||(l[s]={}))[a]=[u,m]),l===b))break;return m-=e,m===d||m%d===0&&m/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||db.error("unsupported pseudo: "+a);return e[s]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?fb(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=I.call(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:fb(function(a){var b=[],c=[],d=g(a.replace(P,"$1"));return d[s]?fb(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),!c.pop()}}),has:fb(function(a){return function(b){return db(a,b).length>0}}),contains:fb(function(a){return function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:fb(function(a){return U.test(a||"")||db.error("unsupported lang: "+a),a=a.replace(ab,bb).toLowerCase(),function(b){var c;do if(c=n?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===m},focus:function(a){return a===l.activeElement&&(!l.hasFocus||l.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return X.test(a.nodeName)},input:function(a){return W.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:lb(function(){return[0]}),last:lb(function(a,b){return[b-1]}),eq:lb(function(a,b,c){return[0>c?c+b:c]}),even:lb(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:lb(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:lb(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:lb(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function qb(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=v++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j=[u,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(i=b[s]||(b[s]={}),(h=i[d])&&h[0]===u&&h[1]===f)return j[2]=h[2];if(i[d]=j,j[2]=a(b,c,g))return!0}}}function rb(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function sb(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(!c||c(f,d,e))&&(g.push(f),j&&b.push(h));return g}function tb(a,b,c,d,e,f){return d&&!d[s]&&(d=tb(d)),e&&!e[s]&&(e=tb(e,f)),fb(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||wb(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:sb(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=sb(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?I.call(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=sb(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):G.apply(g,r)})}function ub(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],i=g||d.relative[" "],j=g?1:0,k=qb(function(a){return a===b},i,!0),l=qb(function(a){return I.call(b,a)>-1},i,!0),m=[function(a,c,d){return!g&&(d||c!==h)||((b=c).nodeType?k(a,c,d):l(a,c,d))}];f>j;j++)if(c=d.relative[a[j].type])m=[qb(rb(m),c)];else{if(c=d.filter[a[j].type].apply(null,a[j].matches),c[s]){for(e=++j;f>e;e++)if(d.relative[a[e].type])break;return tb(j>1&&rb(m),j>1&&pb(a.slice(0,j-1).concat({value:" "===a[j-2].type?"*":""})).replace(P,"$1"),c,e>j&&ub(a.slice(j,e)),f>e&&ub(a=a.slice(e)),f>e&&pb(a))}m.push(c)}return rb(m)}function vb(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,i,j,k){var m,n,o,p=0,q="0",r=f&&[],s=[],t=h,v=f||e&&d.find.TAG("*",k),w=u+=null==t?1:Math.random()||.1,x=v.length;for(k&&(h=g!==l&&g);q!==x&&null!=(m=v[q]);q++){if(e&&m){n=0;while(o=a[n++])if(o(m,g,i)){j.push(m);break}k&&(u=w)}c&&((m=!o&&m)&&p--,f&&r.push(m))}if(p+=q,c&&q!==p){n=0;while(o=b[n++])o(r,s,g,i);if(f){if(p>0)while(q--)r[q]||s[q]||(s[q]=E.call(j));s=sb(s)}G.apply(j,s),k&&!f&&s.length>0&&p+b.length>1&&db.uniqueSort(j)}return k&&(u=w,h=t),r};return c?fb(f):f}g=db.compile=function(a,b){var c,d=[],e=[],f=y[a+" "];if(!f){b||(b=ob(a)),c=b.length;while(c--)f=ub(b[c]),f[s]?d.push(f):e.push(f);f=y(a,vb(e,d))}return f};function wb(a,b,c){for(var d=0,e=b.length;e>d;d++)db(a,b[d],c);return c}function xb(a,b,e,f){var h,i,j,k,l,m=ob(a);if(!f&&1===m.length){if(i=m[0]=m[0].slice(0),i.length>2&&"ID"===(j=i[0]).type&&c.getById&&9===b.nodeType&&n&&d.relative[i[1].type]){if(b=(d.find.ID(j.matches[0].replace(ab,bb),b)||[])[0],!b)return e;a=a.slice(i.shift().value.length)}h=V.needsContext.test(a)?0:i.length;while(h--){if(j=i[h],d.relative[k=j.type])break;if((l=d.find[k])&&(f=l(j.matches[0].replace(ab,bb),$.test(i[0].type)&&mb(b.parentNode)||b))){if(i.splice(h,1),a=f.length&&pb(i),!a)return G.apply(e,f),e;break}}}return g(a,m)(f,b,!n,e,$.test(a)&&mb(b.parentNode)||b),e}return c.sortStable=s.split("").sort(z).join("")===s,c.detectDuplicates=!!j,k(),c.sortDetached=gb(function(a){return 1&a.compareDocumentPosition(l.createElement("div"))}),gb(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||hb("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&gb(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||hb("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),gb(function(a){return null==a.getAttribute("disabled")})||hb(J,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),db}(a);n.find=t,n.expr=t.selectors,n.expr[":"]=n.expr.pseudos,n.unique=t.uniqueSort,n.text=t.getText,n.isXMLDoc=t.isXML,n.contains=t.contains;var u=n.expr.match.needsContext,v=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,w=/^.[^:#\[\.,]*$/;function x(a,b,c){if(n.isFunction(b))return n.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return n.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(w.test(b))return n.filter(b,a,c);b=n.filter(b,a)}return n.grep(a,function(a){return n.inArray(a,b)>=0!==c})}n.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?n.find.matchesSelector(d,a)?[d]:[]:n.find.matches(a,n.grep(b,function(a){return 1===a.nodeType}))},n.fn.extend({find:function(a){var b,c=[],d=this,e=d.length;if("string"!=typeof a)return this.pushStack(n(a).filter(function(){for(b=0;e>b;b++)if(n.contains(d[b],this))return!0}));for(b=0;e>b;b++)n.find(a,d[b],c);return c=this.pushStack(e>1?n.unique(c):c),c.selector=this.selector?this.selector+" "+a:a,c},filter:function(a){return this.pushStack(x(this,a||[],!1))},not:function(a){return this.pushStack(x(this,a||[],!0))},is:function(a){return!!x(this,"string"==typeof a&&u.test(a)?n(a):a||[],!1).length}});var y,z=a.document,A=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,B=n.fn.init=function(a,b){var c,d;if(!a)return this;if("string"==typeof a){if(c="<"===a.charAt(0)&&">"===a.charAt(a.length-1)&&a.length>=3?[null,a,null]:A.exec(a),!c||!c[1]&&b)return!b||b.jquery?(b||y).find(a):this.constructor(b).find(a);if(c[1]){if(b=b instanceof n?b[0]:b,n.merge(this,n.parseHTML(c[1],b&&b.nodeType?b.ownerDocument||b:z,!0)),v.test(c[1])&&n.isPlainObject(b))for(c in b)n.isFunction(this[c])?this[c](b[c]):this.attr(c,b[c]);return this}if(d=z.getElementById(c[2]),d&&d.parentNode){if(d.id!==c[2])return y.find(a);this.length=1,this[0]=d}return this.context=z,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):n.isFunction(a)?"undefined"!=typeof y.ready?y.ready(a):a(n):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),n.makeArray(a,this))};B.prototype=n.fn,y=n(z);var C=/^(?:parents|prev(?:Until|All))/,D={children:!0,contents:!0,next:!0,prev:!0};n.extend({dir:function(a,b,c){var d=[],e=a[b];while(e&&9!==e.nodeType&&(void 0===c||1!==e.nodeType||!n(e).is(c)))1===e.nodeType&&d.push(e),e=e[b];return d},sibling:function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c}}),n.fn.extend({has:function(a){var b,c=n(a,this),d=c.length;return this.filter(function(){for(b=0;d>b;b++)if(n.contains(this,c[b]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=u.test(a)||"string"!=typeof a?n(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&n.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?n.unique(f):f)},index:function(a){return a?"string"==typeof a?n.inArray(this[0],n(a)):n.inArray(a.jquery?a[0]:a,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(n.unique(n.merge(this.get(),n(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function E(a,b){do a=a[b];while(a&&1!==a.nodeType);return a}n.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return n.dir(a,"parentNode")},parentsUntil:function(a,b,c){return n.dir(a,"parentNode",c)},next:function(a){return E(a,"nextSibling")},prev:function(a){return E(a,"previousSibling")},nextAll:function(a){return n.dir(a,"nextSibling")},prevAll:function(a){return n.dir(a,"previousSibling")},nextUntil:function(a,b,c){return n.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return n.dir(a,"previousSibling",c)},siblings:function(a){return n.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return n.sibling(a.firstChild)},contents:function(a){return n.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:n.merge([],a.childNodes)}},function(a,b){n.fn[a]=function(c,d){var e=n.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=n.filter(d,e)),this.length>1&&(D[a]||(e=n.unique(e)),C.test(a)&&(e=e.reverse())),this.pushStack(e)}});var F=/\S+/g,G={};function H(a){var b=G[a]={};return n.each(a.match(F)||[],function(a,c){b[c]=!0}),b}n.Callbacks=function(a){a="string"==typeof a?G[a]||H(a):n.extend({},a);var b,c,d,e,f,g,h=[],i=!a.once&&[],j=function(l){for(c=a.memory&&l,d=!0,f=g||0,g=0,e=h.length,b=!0;h&&e>f;f++)if(h[f].apply(l[0],l[1])===!1&&a.stopOnFalse){c=!1;break}b=!1,h&&(i?i.length&&j(i.shift()):c?h=[]:k.disable())},k={add:function(){if(h){var d=h.length;!function f(b){n.each(b,function(b,c){var d=n.type(c);"function"===d?a.unique&&k.has(c)||h.push(c):c&&c.length&&"string"!==d&&f(c)})}(arguments),b?e=h.length:c&&(g=d,j(c))}return this},remove:function(){return h&&n.each(arguments,function(a,c){var d;while((d=n.inArray(c,h,d))>-1)h.splice(d,1),b&&(e>=d&&e--,f>=d&&f--)}),this},has:function(a){return a?n.inArray(a,h)>-1:!(!h||!h.length)},empty:function(){return h=[],e=0,this},disable:function(){return h=i=c=void 0,this},disabled:function(){return!h},lock:function(){return i=void 0,c||k.disable(),this},locked:function(){return!i},fireWith:function(a,c){return!h||d&&!i||(c=c||[],c=[a,c.slice?c.slice():c],b?i.push(c):j(c)),this},fire:function(){return k.fireWith(this,arguments),this},fired:function(){return!!d}};return k},n.extend({Deferred:function(a){var b=[["resolve","done",n.Callbacks("once memory"),"resolved"],["reject","fail",n.Callbacks("once memory"),"rejected"],["notify","progress",n.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return n.Deferred(function(c){n.each(b,function(b,f){var g=n.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&n.isFunction(a.promise)?a.promise().done(c.resolve).fail(c.reject).progress(c.notify):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?n.extend(a,d):d}},e={};return d.pipe=d.then,n.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=d.call(arguments),e=c.length,f=1!==e||a&&n.isFunction(a.promise)?e:0,g=1===f?a:n.Deferred(),h=function(a,b,c){return function(e){b[a]=this,c[a]=arguments.length>1?d.call(arguments):e,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(e>1)for(i=new Array(e),j=new Array(e),k=new Array(e);e>b;b++)c[b]&&n.isFunction(c[b].promise)?c[b].promise().done(h(b,k,c)).fail(g.reject).progress(h(b,j,i)):--f;return f||g.resolveWith(k,c),g.promise()}});var I;n.fn.ready=function(a){return n.ready.promise().done(a),this},n.extend({isReady:!1,readyWait:1,holdReady:function(a){a?n.readyWait++:n.ready(!0)},ready:function(a){if(a===!0?!--n.readyWait:!n.isReady){if(!z.body)return setTimeout(n.ready);n.isReady=!0,a!==!0&&--n.readyWait>0||(I.resolveWith(z,[n]),n.fn.trigger&&n(z).trigger("ready").off("ready"))}}});function J(){z.addEventListener?(z.removeEventListener("DOMContentLoaded",K,!1),a.removeEventListener("load",K,!1)):(z.detachEvent("onreadystatechange",K),a.detachEvent("onload",K))}function K(){(z.addEventListener||"load"===event.type||"complete"===z.readyState)&&(J(),n.ready())}n.ready.promise=function(b){if(!I)if(I=n.Deferred(),"complete"===z.readyState)setTimeout(n.ready);else if(z.addEventListener)z.addEventListener("DOMContentLoaded",K,!1),a.addEventListener("load",K,!1);else{z.attachEvent("onreadystatechange",K),a.attachEvent("onload",K);var c=!1;try{c=null==a.frameElement&&z.documentElement}catch(d){}c&&c.doScroll&&!function e(){if(!n.isReady){try{c.doScroll("left")}catch(a){return setTimeout(e,50)}J(),n.ready()}}()}return I.promise(b)};var L="undefined",M;for(M in n(l))break;l.ownLast="0"!==M,l.inlineBlockNeedsLayout=!1,n(function(){var a,b,c=z.getElementsByTagName("body")[0];c&&(a=z.createElement("div"),a.style.cssText="border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px",b=z.createElement("div"),c.appendChild(a).appendChild(b),typeof b.style.zoom!==L&&(b.style.cssText="border:0;margin:0;width:1px;padding:1px;display:inline;zoom:1",(l.inlineBlockNeedsLayout=3===b.offsetWidth)&&(c.style.zoom=1)),c.removeChild(a),a=b=null)}),function(){var a=z.createElement("div");if(null==l.deleteExpando){l.deleteExpando=!0;try{delete a.test}catch(b){l.deleteExpando=!1}}a=null}(),n.acceptData=function(a){var b=n.noData[(a.nodeName+" ").toLowerCase()],c=+a.nodeType||1;return 1!==c&&9!==c?!1:!b||b!==!0&&a.getAttribute("classid")===b};var N=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,O=/([A-Z])/g;function P(a,b,c){if(void 0===c&&1===a.nodeType){var d="data-"+b.replace(O,"-$1").toLowerCase();if(c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:N.test(c)?n.parseJSON(c):c}catch(e){}n.data(a,b,c)}else c=void 0}return c}function Q(a){var b;for(b in a)if(("data"!==b||!n.isEmptyObject(a[b]))&&"toJSON"!==b)return!1;return!0}function R(a,b,d,e){if(n.acceptData(a)){var f,g,h=n.expando,i=a.nodeType,j=i?n.cache:a,k=i?a[h]:a[h]&&h;if(k&&j[k]&&(e||j[k].data)||void 0!==d||"string"!=typeof b)return k||(k=i?a[h]=c.pop()||n.guid++:h),j[k]||(j[k]=i?{}:{toJSON:n.noop}),("object"==typeof b||"function"==typeof b)&&(e?j[k]=n.extend(j[k],b):j[k].data=n.extend(j[k].data,b)),g=j[k],e||(g.data||(g.data={}),g=g.data),void 0!==d&&(g[n.camelCase(b)]=d),"string"==typeof b?(f=g[b],null==f&&(f=g[n.camelCase(b)])):f=g,f 3 | }}function S(a,b,c){if(n.acceptData(a)){var d,e,f=a.nodeType,g=f?n.cache:a,h=f?a[n.expando]:n.expando;if(g[h]){if(b&&(d=c?g[h]:g[h].data)){n.isArray(b)?b=b.concat(n.map(b,n.camelCase)):b in d?b=[b]:(b=n.camelCase(b),b=b in d?[b]:b.split(" ")),e=b.length;while(e--)delete d[b[e]];if(c?!Q(d):!n.isEmptyObject(d))return}(c||(delete g[h].data,Q(g[h])))&&(f?n.cleanData([a],!0):l.deleteExpando||g!=g.window?delete g[h]:g[h]=null)}}}n.extend({cache:{},noData:{"applet ":!0,"embed ":!0,"object ":"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"},hasData:function(a){return a=a.nodeType?n.cache[a[n.expando]]:a[n.expando],!!a&&!Q(a)},data:function(a,b,c){return R(a,b,c)},removeData:function(a,b){return S(a,b)},_data:function(a,b,c){return R(a,b,c,!0)},_removeData:function(a,b){return S(a,b,!0)}}),n.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=n.data(f),1===f.nodeType&&!n._data(f,"parsedAttrs"))){c=g.length;while(c--)d=g[c].name,0===d.indexOf("data-")&&(d=n.camelCase(d.slice(5)),P(f,d,e[d]));n._data(f,"parsedAttrs",!0)}return e}return"object"==typeof a?this.each(function(){n.data(this,a)}):arguments.length>1?this.each(function(){n.data(this,a,b)}):f?P(f,a,n.data(f,a)):void 0},removeData:function(a){return this.each(function(){n.removeData(this,a)})}}),n.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=n._data(a,b),c&&(!d||n.isArray(c)?d=n._data(a,b,n.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=n.queue(a,b),d=c.length,e=c.shift(),f=n._queueHooks(a,b),g=function(){n.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return n._data(a,c)||n._data(a,c,{empty:n.Callbacks("once memory").add(function(){n._removeData(a,b+"queue"),n._removeData(a,c)})})}}),n.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthh;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f},X=/^(?:checkbox|radio)$/i;!function(){var a=z.createDocumentFragment(),b=z.createElement("div"),c=z.createElement("input");if(b.setAttribute("className","t"),b.innerHTML="
a",l.leadingWhitespace=3===b.firstChild.nodeType,l.tbody=!b.getElementsByTagName("tbody").length,l.htmlSerialize=!!b.getElementsByTagName("link").length,l.html5Clone="<:nav>"!==z.createElement("nav").cloneNode(!0).outerHTML,c.type="checkbox",c.checked=!0,a.appendChild(c),l.appendChecked=c.checked,b.innerHTML="",l.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue,a.appendChild(b),b.innerHTML="",l.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,l.noCloneEvent=!0,b.attachEvent&&(b.attachEvent("onclick",function(){l.noCloneEvent=!1}),b.cloneNode(!0).click()),null==l.deleteExpando){l.deleteExpando=!0;try{delete b.test}catch(d){l.deleteExpando=!1}}a=b=c=null}(),function(){var b,c,d=z.createElement("div");for(b in{submit:!0,change:!0,focusin:!0})c="on"+b,(l[b+"Bubbles"]=c in a)||(d.setAttribute(c,"t"),l[b+"Bubbles"]=d.attributes[c].expando===!1);d=null}();var Y=/^(?:input|select|textarea)$/i,Z=/^key/,$=/^(?:mouse|contextmenu)|click/,_=/^(?:focusinfocus|focusoutblur)$/,ab=/^([^.]*)(?:\.(.+)|)$/;function bb(){return!0}function cb(){return!1}function db(){try{return z.activeElement}catch(a){}}n.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=n._data(a);if(r){c.handler&&(i=c,c=i.handler,e=i.selector),c.guid||(c.guid=n.guid++),(g=r.events)||(g=r.events={}),(k=r.handle)||(k=r.handle=function(a){return typeof n===L||a&&n.event.triggered===a.type?void 0:n.event.dispatch.apply(k.elem,arguments)},k.elem=a),b=(b||"").match(F)||[""],h=b.length;while(h--)f=ab.exec(b[h])||[],o=q=f[1],p=(f[2]||"").split(".").sort(),o&&(j=n.event.special[o]||{},o=(e?j.delegateType:j.bindType)||o,j=n.event.special[o]||{},l=n.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&n.expr.match.needsContext.test(e),namespace:p.join(".")},i),(m=g[o])||(m=g[o]=[],m.delegateCount=0,j.setup&&j.setup.call(a,d,p,k)!==!1||(a.addEventListener?a.addEventListener(o,k,!1):a.attachEvent&&a.attachEvent("on"+o,k))),j.add&&(j.add.call(a,l),l.handler.guid||(l.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,l):m.push(l),n.event.global[o]=!0);a=null}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=n.hasData(a)&&n._data(a);if(r&&(k=r.events)){b=(b||"").match(F)||[""],j=b.length;while(j--)if(h=ab.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=n.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,m=k[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),i=f=m.length;while(f--)g=m[f],!e&&q!==g.origType||c&&c.guid!==g.guid||h&&!h.test(g.namespace)||d&&d!==g.selector&&("**"!==d||!g.selector)||(m.splice(f,1),g.selector&&m.delegateCount--,l.remove&&l.remove.call(a,g));i&&!m.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||n.removeEvent(a,o,r.handle),delete k[o])}else for(o in k)n.event.remove(a,o+b[j],c,d,!0);n.isEmptyObject(k)&&(delete r.handle,n._removeData(a,"events"))}},trigger:function(b,c,d,e){var f,g,h,i,k,l,m,o=[d||z],p=j.call(b,"type")?b.type:b,q=j.call(b,"namespace")?b.namespace.split("."):[];if(h=l=d=d||z,3!==d.nodeType&&8!==d.nodeType&&!_.test(p+n.event.triggered)&&(p.indexOf(".")>=0&&(q=p.split("."),p=q.shift(),q.sort()),g=p.indexOf(":")<0&&"on"+p,b=b[n.expando]?b:new n.Event(p,"object"==typeof b&&b),b.isTrigger=e?2:3,b.namespace=q.join("."),b.namespace_re=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=d),c=null==c?[b]:n.makeArray(c,[b]),k=n.event.special[p]||{},e||!k.trigger||k.trigger.apply(d,c)!==!1)){if(!e&&!k.noBubble&&!n.isWindow(d)){for(i=k.delegateType||p,_.test(i+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),l=h;l===(d.ownerDocument||z)&&o.push(l.defaultView||l.parentWindow||a)}m=0;while((h=o[m++])&&!b.isPropagationStopped())b.type=m>1?i:k.bindType||p,f=(n._data(h,"events")||{})[b.type]&&n._data(h,"handle"),f&&f.apply(h,c),f=g&&h[g],f&&f.apply&&n.acceptData(h)&&(b.result=f.apply(h,c),b.result===!1&&b.preventDefault());if(b.type=p,!e&&!b.isDefaultPrevented()&&(!k._default||k._default.apply(o.pop(),c)===!1)&&n.acceptData(d)&&g&&d[p]&&!n.isWindow(d)){l=d[g],l&&(d[g]=null),n.event.triggered=p;try{d[p]()}catch(r){}n.event.triggered=void 0,l&&(d[g]=l)}return b.result}},dispatch:function(a){a=n.event.fix(a);var b,c,e,f,g,h=[],i=d.call(arguments),j=(n._data(this,"events")||{})[a.type]||[],k=n.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=n.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,g=0;while((e=f.handlers[g++])&&!a.isImmediatePropagationStopped())(!a.namespace_re||a.namespace_re.test(e.namespace))&&(a.handleObj=e,a.data=e.data,c=((n.event.special[e.origType]||{}).handle||e.handler).apply(f.elem,i),void 0!==c&&(a.result=c)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&(!a.button||"click"!==a.type))for(;i!=this;i=i.parentNode||this)if(1===i.nodeType&&(i.disabled!==!0||"click"!==a.type)){for(e=[],f=0;h>f;f++)d=b[f],c=d.selector+" ",void 0===e[c]&&(e[c]=d.needsContext?n(c,this).index(i)>=0:n.find(c,this,null,[i]).length),e[c]&&e.push(d);e.length&&g.push({elem:i,handlers:e})}return h]","i"),ib=/^\s+/,jb=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,kb=/<([\w:]+)/,lb=/\s*$/g,sb={option:[1,""],legend:[1,"
","
"],area:[1,"",""],param:[1,"",""],thead:[1,"","
"],tr:[2,"","
"],col:[2,"","
"],td:[3,"","
"],_default:l.htmlSerialize?[0,"",""]:[1,"X
","
"]},tb=eb(z),ub=tb.appendChild(z.createElement("div"));sb.optgroup=sb.option,sb.tbody=sb.tfoot=sb.colgroup=sb.caption=sb.thead,sb.th=sb.td;function vb(a,b){var c,d,e=0,f=typeof a.getElementsByTagName!==L?a.getElementsByTagName(b||"*"):typeof a.querySelectorAll!==L?a.querySelectorAll(b||"*"):void 0;if(!f)for(f=[],c=a.childNodes||a;null!=(d=c[e]);e++)!b||n.nodeName(d,b)?f.push(d):n.merge(f,vb(d,b));return void 0===b||b&&n.nodeName(a,b)?n.merge([a],f):f}function wb(a){X.test(a.type)&&(a.defaultChecked=a.checked)}function xb(a,b){return n.nodeName(a,"table")&&n.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function yb(a){return a.type=(null!==n.find.attr(a,"type"))+"/"+a.type,a}function zb(a){var b=qb.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function Ab(a,b){for(var c,d=0;null!=(c=a[d]);d++)n._data(c,"globalEval",!b||n._data(b[d],"globalEval"))}function Bb(a,b){if(1===b.nodeType&&n.hasData(a)){var c,d,e,f=n._data(a),g=n._data(b,f),h=f.events;if(h){delete g.handle,g.events={};for(c in h)for(d=0,e=h[c].length;e>d;d++)n.event.add(b,c,h[c][d])}g.data&&(g.data=n.extend({},g.data))}}function Cb(a,b){var c,d,e;if(1===b.nodeType){if(c=b.nodeName.toLowerCase(),!l.noCloneEvent&&b[n.expando]){e=n._data(b);for(d in e.events)n.removeEvent(b,d,e.handle);b.removeAttribute(n.expando)}"script"===c&&b.text!==a.text?(yb(b).text=a.text,zb(b)):"object"===c?(b.parentNode&&(b.outerHTML=a.outerHTML),l.html5Clone&&a.innerHTML&&!n.trim(b.innerHTML)&&(b.innerHTML=a.innerHTML)):"input"===c&&X.test(a.type)?(b.defaultChecked=b.checked=a.checked,b.value!==a.value&&(b.value=a.value)):"option"===c?b.defaultSelected=b.selected=a.defaultSelected:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}}n.extend({clone:function(a,b,c){var d,e,f,g,h,i=n.contains(a.ownerDocument,a);if(l.html5Clone||n.isXMLDoc(a)||!hb.test("<"+a.nodeName+">")?f=a.cloneNode(!0):(ub.innerHTML=a.outerHTML,ub.removeChild(f=ub.firstChild)),!(l.noCloneEvent&&l.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||n.isXMLDoc(a)))for(d=vb(f),h=vb(a),g=0;null!=(e=h[g]);++g)d[g]&&Cb(e,d[g]);if(b)if(c)for(h=h||vb(a),d=d||vb(f),g=0;null!=(e=h[g]);g++)Bb(e,d[g]);else Bb(a,f);return d=vb(f,"script"),d.length>0&&Ab(d,!i&&vb(a,"script")),d=h=e=null,f},buildFragment:function(a,b,c,d){for(var e,f,g,h,i,j,k,m=a.length,o=eb(b),p=[],q=0;m>q;q++)if(f=a[q],f||0===f)if("object"===n.type(f))n.merge(p,f.nodeType?[f]:f);else if(mb.test(f)){h=h||o.appendChild(b.createElement("div")),i=(kb.exec(f)||["",""])[1].toLowerCase(),k=sb[i]||sb._default,h.innerHTML=k[1]+f.replace(jb,"<$1>")+k[2],e=k[0];while(e--)h=h.lastChild;if(!l.leadingWhitespace&&ib.test(f)&&p.push(b.createTextNode(ib.exec(f)[0])),!l.tbody){f="table"!==i||lb.test(f)?""!==k[1]||lb.test(f)?0:h:h.firstChild,e=f&&f.childNodes.length;while(e--)n.nodeName(j=f.childNodes[e],"tbody")&&!j.childNodes.length&&f.removeChild(j)}n.merge(p,h.childNodes),h.textContent="";while(h.firstChild)h.removeChild(h.firstChild);h=o.lastChild}else p.push(b.createTextNode(f));h&&o.removeChild(h),l.appendChecked||n.grep(vb(p,"input"),wb),q=0;while(f=p[q++])if((!d||-1===n.inArray(f,d))&&(g=n.contains(f.ownerDocument,f),h=vb(o.appendChild(f),"script"),g&&Ab(h),c)){e=0;while(f=h[e++])pb.test(f.type||"")&&c.push(f)}return h=null,o},cleanData:function(a,b){for(var d,e,f,g,h=0,i=n.expando,j=n.cache,k=l.deleteExpando,m=n.event.special;null!=(d=a[h]);h++)if((b||n.acceptData(d))&&(f=d[i],g=f&&j[f])){if(g.events)for(e in g.events)m[e]?n.event.remove(d,e):n.removeEvent(d,e,g.handle);j[f]&&(delete j[f],k?delete d[i]:typeof d.removeAttribute!==L?d.removeAttribute(i):d[i]=null,c.push(f))}}}),n.fn.extend({text:function(a){return W(this,function(a){return void 0===a?n.text(this):this.empty().append((this[0]&&this[0].ownerDocument||z).createTextNode(a))},null,a,arguments.length)},append:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=xb(this,a);b.appendChild(a)}})},prepend:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=xb(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},remove:function(a,b){for(var c,d=a?n.filter(a,this):this,e=0;null!=(c=d[e]);e++)b||1!==c.nodeType||n.cleanData(vb(c)),c.parentNode&&(b&&n.contains(c.ownerDocument,c)&&Ab(vb(c,"script")),c.parentNode.removeChild(c));return this},empty:function(){for(var a,b=0;null!=(a=this[b]);b++){1===a.nodeType&&n.cleanData(vb(a,!1));while(a.firstChild)a.removeChild(a.firstChild);a.options&&n.nodeName(a,"select")&&(a.options.length=0)}return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return n.clone(this,a,b)})},html:function(a){return W(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a)return 1===b.nodeType?b.innerHTML.replace(gb,""):void 0;if(!("string"!=typeof a||nb.test(a)||!l.htmlSerialize&&hb.test(a)||!l.leadingWhitespace&&ib.test(a)||sb[(kb.exec(a)||["",""])[1].toLowerCase()])){a=a.replace(jb,"<$1>");try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(n.cleanData(vb(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=arguments[0];return this.domManip(arguments,function(b){a=this.parentNode,n.cleanData(vb(this)),a&&a.replaceChild(b,this)}),a&&(a.length||a.nodeType)?this:this.remove()},detach:function(a){return this.remove(a,!0)},domManip:function(a,b){a=e.apply([],a);var c,d,f,g,h,i,j=0,k=this.length,m=this,o=k-1,p=a[0],q=n.isFunction(p);if(q||k>1&&"string"==typeof p&&!l.checkClone&&ob.test(p))return this.each(function(c){var d=m.eq(c);q&&(a[0]=p.call(this,c,d.html())),d.domManip(a,b)});if(k&&(i=n.buildFragment(a,this[0].ownerDocument,!1,this),c=i.firstChild,1===i.childNodes.length&&(i=c),c)){for(g=n.map(vb(i,"script"),yb),f=g.length;k>j;j++)d=i,j!==o&&(d=n.clone(d,!0,!0),f&&n.merge(g,vb(d,"script"))),b.call(this[j],d,j);if(f)for(h=g[g.length-1].ownerDocument,n.map(g,zb),j=0;f>j;j++)d=g[j],pb.test(d.type||"")&&!n._data(d,"globalEval")&&n.contains(h,d)&&(d.src?n._evalUrl&&n._evalUrl(d.src):n.globalEval((d.text||d.textContent||d.innerHTML||"").replace(rb,"")));i=c=null}return this}}),n.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){n.fn[a]=function(a){for(var c,d=0,e=[],g=n(a),h=g.length-1;h>=d;d++)c=d===h?this:this.clone(!0),n(g[d])[b](c),f.apply(e,c.get());return this.pushStack(e)}});var Db,Eb={};function Fb(b,c){var d=n(c.createElement(b)).appendTo(c.body),e=a.getDefaultComputedStyle?a.getDefaultComputedStyle(d[0]).display:n.css(d[0],"display");return d.detach(),e}function Gb(a){var b=z,c=Eb[a];return c||(c=Fb(a,b),"none"!==c&&c||(Db=(Db||n("