diff --git a/.gitignore b/.gitignore index 3343614b6ad..ff16dcca0ed 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ fl_notes.txt # local symlinks ckan/public/scripts/ckanjs.js +# custom style +ckan/public/base/less/custom.less + # nosetest coverage output .coverage htmlcov/* diff --git a/LICENSE.txt b/LICENSE.txt index 4eb1394ef49..5c4c0f074c2 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -114,4 +114,28 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +rjsmin / rcssmin +---------------- + +Parts of these packages are include in the include directory of ckan. +Full packages can be found at. +http://opensource.perlig.de/rjsmin/ +http://opensource.perlig.de/rcssmin/ + +They both are licensed under Approved License, Version 2 + +Copyright 2011, 2012 +Andr\xe9 Malo or his licensors, as applicable + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/bin/less b/bin/less new file mode 100755 index 00000000000..452dcc81e15 --- /dev/null +++ b/bin/less @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +var path = require('path'), + nodeWatch = require('nodewatch'), + exec = require('child_process').exec, + watch = path.join(__dirname, '..', 'ckan', 'public', 'base', 'less'), + debug = process.env.ENV !== 'production', + lastArg = process.argv.slice().pop(); + +function now() { + return new Date().toISOString().replace('T', ' ').substr(0, 19); +} + +function compile(event, filename) { + var start = Date.now(), + filename = 'main.css'; + + if (debug) { + filename = 'main.debug.css'; + } + + exec('`npm bin`/lessc ' + __dirname + '/../ckan/public/base/less/main.less > ' + __dirname + '/../ckan/public/base/css/' + filename, function (err, stdout, stderr) { + var duration = Date.now() - start; + + if (err) { + console.log('An error occurred running the less command:'); + console.log(err.message); + } + else if (stderr || stdout) { + console.log(stdout, stderr); + } else { + console.log('[%s] recompiled ' + filename + ' in %sms', now(), duration); + } + }); +} + +if (lastArg === '-p' || lastArg === '--production') { + debug = false; +} + +console.log('Watching %s', watch); +nodeWatch.add(watch).onChange(compile); +compile(); diff --git a/ckan/__init__.py b/ckan/__init__.py index c242227f436..45a9be90c84 100644 --- a/ckan/__init__.py +++ b/ckan/__init__.py @@ -1,4 +1,5 @@ -__version__ = '1.8b' +__version__ = '2.0a' + __description__ = 'Comprehensive Knowledge Archive Network (CKAN) Software' __long_description__ = \ '''CKAN software provides a hub for datasets. The flagship site running CKAN diff --git a/ckan/config/deployment.ini_tmpl b/ckan/config/deployment.ini_tmpl index fda532ba54e..c30d2cc9f11 100644 --- a/ckan/config/deployment.ini_tmpl +++ b/ckan/config/deployment.ini_tmpl @@ -92,10 +92,10 @@ package_form = standard ckan.site_title = CKAN ## Logo image to use on the home page -ckan.site_logo = /img/logo_64px_wide.png +ckan.site_logo = /base/images/ckan-logo.png ## Site tagline / description (used on front page) -ckan.site_description = +ckan.site_description = DataSuite ## Used in creating some absolute urls (such as rss feeds, css files) and ## dump filenames diff --git a/ckan/config/environment.py b/ckan/config/environment.py index 0b4fe91ef7a..78e647c65bd 100644 --- a/ckan/config/environment.py +++ b/ckan/config/environment.py @@ -9,6 +9,7 @@ from paste.deploy.converters import asbool import sqlalchemy from pylons import config +from pylons.i18n import _, ungettext from genshi.template import TemplateLoader from genshi.filters.i18n import Translator @@ -20,6 +21,8 @@ log = logging.getLogger(__name__) +import lib.jinja_extensions + # Suppress benign warning 'Unbuilt egg for setuptools' warnings.simplefilter('ignore', UserWarning) @@ -158,6 +161,10 @@ def find_controller(self, controller): 'ckan.site_id for SOLR search-index rebuild to work.' config['ckan.site_id'] = ckan_host + # ensure that a favicon has been set + favicon = config.get('ckan.favicon', '/images/icons/ckan.ico') + config['ckan.favicon'] = favicon + # Init SOLR settings and check if the schema is compatible #from ckan.lib.search import SolrSettings, check_solr_schema_version @@ -169,7 +176,9 @@ def find_controller(self, controller): search.check_solr_schema_version() config['routes.map'] = routing.make_map() - config['pylons.app_globals'] = app_globals.Globals() + config['pylons.app_globals'] = app_globals.app_globals + # initialise the globals + config['pylons.app_globals']._init() # add helper functions restrict_helpers = asbool( @@ -177,13 +186,24 @@ def find_controller(self, controller): helpers = _Helpers(h, restrict_helpers) config['pylons.h'] = helpers - # Redo template setup to use genshi.search_path - # (so remove std template setup) - template_paths = [paths['templates'][0]] + ## redo template setup to use genshi.search_path + ## (so remove std template setup) + legacy_templates_path = os.path.join(root, 'templates_legacy') + if asbool(config.get('ckan.legacy_templates', 'no')): + template_paths = [legacy_templates_path] + # if we are testing allow new templates + if asbool(config.get('ckan.enable_testing', 'false')): + jinja2_templates_path = os.path.join(root, 'templates') + template_paths.append(jinja2_templates_path) + else: + template_paths = [paths['templates'][0]] + template_paths.append(legacy_templates_path) + extra_template_paths = config.get('extra_template_paths', '') if extra_template_paths: # must be first for them to override defaults template_paths = extra_template_paths.split(',') + template_paths + config['pylons.app_globals'].template_paths = template_paths # Translator (i18n) translator = Translator(pylons.translator) @@ -281,6 +301,26 @@ def genshi_lookup_attr(cls, obj, key): # # ################################################################# + + # Create Jinja2 environment + env = lib.jinja_extensions.Environment( + loader=lib.jinja_extensions.CkanFileSystemLoader(template_paths), + autoescape=True, + extensions=['jinja2.ext.do', 'jinja2.ext.with_', + lib.jinja_extensions.SnippetExtension, + lib.jinja_extensions.CkanExtend, + lib.jinja_extensions.CkanInternationalizationExtension, + lib.jinja_extensions.LinkForExtension, + lib.jinja_extensions.ResourceExtension, + lib.jinja_extensions.UrlForStaticExtension, + lib.jinja_extensions.UrlForExtension] + ) + env.install_gettext_callables(_, ungettext, newstyle=True) + # custom filters + env.filters['empty_and_escape'] = lib.jinja_extensions.empty_and_escape + env.filters['truncate'] = lib.jinja_extensions.truncate + config['pylons.app_globals'].jinja_env = env + # CONFIGURATION OPTIONS HERE (note: all config options will override # any Pylons config options) @@ -310,5 +350,6 @@ def genshi_lookup_attr(cls, obj, key): if not model.meta.engine: model.init_model(engine) + for plugin in p.PluginImplementations(p.IConfigurable): plugin.configure(config) diff --git a/ckan/config/middleware.py b/ckan/config/middleware.py index 05b1887b096..9e004a5806a 100644 --- a/ckan/config/middleware.py +++ b/ckan/config/middleware.py @@ -17,12 +17,14 @@ from routes.middleware import RoutesMiddleware from repoze.who.config import WhoConfig from repoze.who.middleware import PluggableAuthenticationMiddleware +from fanstatic import Fanstatic from ckan.plugins import PluginImplementations from ckan.plugins.interfaces import IMiddleware from ckan.lib.i18n import get_locales_from_config from ckan.config.environment import load_environment +import ckan.lib.app_globals as app_globals def make_app(global_conf, full_stack=True, static_files=True, **app_conf): """Create a Pylons WSGI application and return it @@ -52,6 +54,8 @@ def make_app(global_conf, full_stack=True, static_files=True, **app_conf): # The Pylons WSGI app app = PylonsApp() + # set pylons globals + app_globals.reset() for plugin in PluginImplementations(IMiddleware): app = plugin.make_middleware(app, config) @@ -64,6 +68,26 @@ def make_app(global_conf, full_stack=True, static_files=True, **app_conf): # CUSTOM MIDDLEWARE HERE (filtered by error handling middlewares) #app = QueueLogMiddleware(app) + # Fanstatic + if asbool(config.get('debug', False)): + fanstatic_config = { + 'versioning' : True, + 'recompute_hashes' : True, + 'minified' : False, + 'bottom' : True, + 'bundle' : False, + } + else: + fanstatic_config = { + 'versioning' : True, + 'recompute_hashes' : False, + 'minified' : True, + 'bottom' : True, + 'bundle' : True, + } + app = Fanstatic(app, **fanstatic_config) + + if asbool(full_stack): # Handle Python exceptions app = ErrorHandler(app, global_conf, **config['pylons.errorware']) @@ -134,9 +158,10 @@ def make_app(global_conf, full_stack=True, static_files=True, **app_conf): if asbool(config.get('ckan.page_cache_enabled')): app = PageCacheMiddleware(app, config) - # Tracking add config option + # Tracking if asbool(config.get('ckan.tracking_enabled', 'false')): app = TrackingMiddleware(app, config) + return app class I18nMiddleware(object): @@ -176,10 +201,10 @@ def __call__(self, environ, start_response): path_info = '/'.join(urllib.quote(pce,'') for pce in path_info.split('/')) qs = environ.get('QUERY_STRING') - # sort out weird encodings - qs = urllib.quote(qs, '') if qs: + # sort out weird encodings + #qs = urllib.quote(qs, '') environ['CKAN_CURRENT_URL'] = '%s?%s' % (path_info, qs) else: environ['CKAN_CURRENT_URL'] = path_info diff --git a/ckan/config/routing.py b/ckan/config/routing.py index fe81fed83c4..a50181bbe1e 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -125,6 +125,8 @@ def make_map(): action='munge_title_to_package_name') m.connect('/util/tag/munge', action='munge_tag') m.connect('/util/status', action='status') + m.connect('/util/snippet/{snippet_path:.*}', action='snippet') + m.connect('/i18n/{lang}', action='i18n_js_translations') ########### ## /END API @@ -155,6 +157,11 @@ def make_map(): ##map.connect('/package/edit/{id}', controller='package_formalchemy', action='edit') with SubMapper(map, controller='related') as m: + m.connect('related_new', '/dataset/{id}/related/new', action='new') + m.connect('related_edit', '/dataset/{id}/related/edit/{related_id}', + action='edit') + m.connect('related_delete', '/dataset/{id}/related/delete/{related_id}', + action='delete') m.connect('related_list', '/dataset/{id}/related', action='list') m.connect('related_read', '/apps/{id}', action='read') m.connect('related_dashboard', '/apps', action='dashboard') @@ -181,18 +188,25 @@ def make_map(): m.connect('/dataset/{action}/{id}', requirements=dict(action='|'.join([ 'edit', - 'editresources', + 'new_metadata', + 'new_resource', 'authz', 'history', 'read_ajax', 'history_ajax', 'followers', + 'delete', + 'api_data', ])) ) m.connect('/dataset/{id}.{format}', action='read') m.connect('/dataset/{id}', action='read') m.connect('/dataset/{id}/resource/{resource_id}', action='resource_read') + m.connect('/dataset/{id}/resource_delete/{resource_id}', + action='resource_delete') + m.connect('/dataset/{id}/resource_edit/{resource_id}', + action='resource_edit') m.connect('/dataset/{id}/resource/{resource_id}/download', action='resource_download') m.connect('/dataset/{id}/resource/{resource_id}/embed', @@ -218,6 +232,7 @@ def make_map(): requirements=dict(action='|'.join([ 'edit', 'authz', + 'delete', 'history' ])) ) @@ -298,10 +313,18 @@ def make_map(): m.connect('storage_file', '/storage/f/{label:.*}', action='file') + with SubMapper(map, controller='util') as m: + m.connect('/i18n/strings_{lang}.js', action='i18n_js_strings') + m.connect('/util/redirect', action='redirect') + m.connect('/testing/primer', action='primer') + m.connect('/testing/markup', action='markup') for plugin in routing_plugins: map = plugin.after_map(map) + # sometimes we get requests for favicon.ico we should redirect to + # the real favicon location. + map.redirect('/favicon.ico', config.get('ckan.favicon')) map.redirect('/*(url)/', '/{url}', _redirect_code='301 Moved Permanently') diff --git a/ckan/controllers/admin.py b/ckan/controllers/admin.py index a7bf8aee2a6..5246e238553 100644 --- a/ckan/controllers/admin.py +++ b/ckan/controllers/admin.py @@ -1,4 +1,8 @@ -from ckan.lib.base import * +from pylons import config + +import ckan.lib.base as base +import ckan.lib.helpers as h +import ckan.lib.app_globals as app_globals import ckan.authz import ckan.lib.authztool import ckan.model as model @@ -8,26 +12,85 @@ role_tuples = [(x, x) for x in roles] +c = base.c +request = base.request +_ = base._ + def get_sysadmins(): q = model.Session.query(model.SystemRole).filter_by(role=model.Role.ADMIN) return [uor.user for uor in q.all() if uor.user] -class AdminController(BaseController): +class AdminController(base.BaseController): def __before__(self, action, **params): super(AdminController, self).__before__(action, **params) if not ckan.authz.Authorizer().is_sysadmin(unicode(c.user)): - abort(401, _('Need to be system administrator to administer')) + base.abort(401, _('Need to be system administrator to administer')) c.revision_change_state_allowed = ( c.user and self.authorizer.is_authorized(c.user, model.Action.CHANGE_STATE, model.Revision)) + def _get_config_form_items(self): + # Styles for use in the form.select() macro. + styles = [{'text': 'Default', 'value': '/base/css/main.css'}, + {'text': 'Red', 'value': '/base/css/red.css'}, + {'text': 'Green', 'value': '/base/css/green.css'}, + {'text': 'Maroon', 'value': '/base/css/maroon.css'}, + {'text': 'Fuchsia', 'value': '/base/css/fuchsia.css'}] + items = [ + {'name': 'ckan.site_title', 'control': 'input', 'label': _('Site Title'), 'placeholder': _('')}, + {'name': 'ckan.main_css', 'control': 'select', 'options': styles, 'label': _('Style'), 'placeholder': _('')}, + {'name': 'ckan.site_description', 'control': 'input', 'label': _('Site Tag Line'), 'placeholder': _('')}, + {'name': 'ckan.site_logo', 'control': 'input', 'label': _('Site Tag Logo'), 'placeholder': _('')}, + {'name': 'ckan.site_about', 'control': 'markdown', 'label': _('About'), 'placeholder': _('About page text')}, + {'name': 'ckan.site_intro_text', 'control': 'markdown', 'label': _('Intro Text'), 'placeholder': _('Text on home page')}, + {'name': 'ckan.site_custom_css', 'control': 'textarea', 'label': _('Custom CSS'), 'placeholder': _('Customisable css inserted into the page header')}, + ] + return items + + def reset_config(self): + if 'cancel' in request.params: + h.redirect_to(controller='admin', action='config') + + if request.method == 'POST': + # remove sys info items + for item in self._get_config_form_items(): + name = item['name'] + app_globals.delete_global(name) + # reset to values in config + app_globals.reset() + h.redirect_to(controller='admin', action='config') + + return base.render('admin/confirm_reset.html') + + def config(self): + + items = self._get_config_form_items() + data = request.POST + if 'save' in data: + # update config from form + for item in items: + name = item['name'] + if name in data: + app_globals.set_global(name, data[name]) + app_globals.reset() + h.redirect_to(controller='admin', action='config') + + data = {} + for item in items: + name = item['name'] + data[name] = config.get(name) + + vars = {'data': data, 'errors': {}, 'form_items': items} + return base.render('admin/config.html', + extra_vars = vars) + def index(self): #now pass the list of sysadmins c.sysadmins = [a.name for a in get_sysadmins()] - return render('admin/index.html') + return base.render('admin/index.html') def authz(self): def action_save_form(users): @@ -176,7 +239,7 @@ def action_add_form(users): c.users = users c.user_role_dict = user_role_dict - return render('admin/authz.html') + return base.render('admin/authz.html') def trash(self): c.deleted_revisions = model.Session.query( @@ -185,7 +248,7 @@ def trash(self): model.Package).filter_by(state=model.State.DELETED) if not request.params or (len(request.params) == 1 and '__no_cache__' in request.params): - return render('admin/trash.html') + return base.render('admin/trash.html') else: # NB: we repeat retrieval of of revisions # this is obviously inefficient (but probably not *that* bad) diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index 4d5905bc1b7..b05721a796d 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -1,3 +1,4 @@ +import os.path import logging import cgi import datetime @@ -146,6 +147,12 @@ def get_api(self, ver=None): response_data['version'] = ver return self._finish_ok(response_data) + def snippet(self, snippet_path, ver=None): + ''' Renders and returns a snippet used by ajax calls ''' + # we only allow snippets in templates/ajax_snippets and it's subdirs + snippet_path = u'ajax_snippets/' + snippet_path + return base.render(snippet_path, extra_vars=dict(request.params)) + def action(self, logic_function, ver=None): try: function = get_action(logic_function) @@ -727,3 +734,14 @@ def status(self): data_dict = {} status = get_action('status_show')(context, data_dict) return self._finish_ok(status) + + def i18n_js_translations(self, lang): + ''' translation strings for front end ''' + ckan_path = os.path.join(os.path.dirname(__file__), '..') + source = os.path.abspath(os.path.join(ckan_path, 'public', + 'base', 'i18n', '%s.js' % lang)) + response.headers['Content-Type'] = CONTENT_TYPES['json'] + if not os.path.exists(source): + return '{}' + f = open(source, 'r') + return(f) diff --git a/ckan/controllers/error.py b/ckan/controllers/error.py index db210b5eae3..c5ba044d246 100644 --- a/ckan/controllers/error.py +++ b/ckan/controllers/error.py @@ -3,7 +3,6 @@ from paste.urlparser import PkgResourcesParser from pylons import request, tmpl_context as c from pylons.controllers.util import forward -from pylons.middleware import error_document_template from webhelpers.html.builder import literal from ckan.lib.base import BaseController diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index be696ffce83..c40979bbabb 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -142,6 +142,7 @@ def read(self, id): # most search operations should reset the page counter: params_nopage = [(k, v) for k, v in request.params.items() if k != 'page'] + #sort_by = request.params.get('sort', 'name asc') sort_by = request.params.get('sort', None) def search_url(params): @@ -152,16 +153,17 @@ def search_url(params): return url + u'?' + urlencode(params) def drill_down_url(**by): - params = list(params_nopage) - params.extend(by.items()) - return search_url(set(params)) + return h.add_url_param(alternative_url=None, + controller='group', action='read', + extras=dict(id=c.group_dict.get('name')), + new_params=by) c.drill_down_url = drill_down_url - def remove_field(key, value): - params = list(params_nopage) - params.remove((key, value)) - return search_url(params) + def remove_field(key, value=None, replace=None): + return h.remove_url_param(key, value=value, replace=replace, + controller='group', action='read', + extras=dict(id=c.group_dict.get('name'))) c.remove_field = remove_field @@ -213,6 +215,14 @@ def pager_url(q=None, page=None): 'Use `c.search_facets` instead.') c.search_facets = query['search_facets'] + c.facet_titles = {'groups': _('Groups'), + 'tags': _('Tags'), + 'res_format': _('Formats'), + 'license': _('Licence'), } + c.search_facets_limits = {} + for facet in c.facets.keys(): + limit = int(request.params.get('_%s_limit' % facet, 10)) + c.search_facets_limits[facet] = limit c.page.items = query['results'] c.sort_by_selected = sort_by @@ -251,7 +261,8 @@ def new(self, data=None, errors=None, error_summary=None): data = data or {} errors = errors or {} error_summary = error_summary or {} - vars = {'data': data, 'errors': errors, 'error_summary': error_summary} + vars = {'data': data, 'errors': errors, + 'error_summary': error_summary, 'action': 'new'} self._setup_template_variables(context, data, group_type=group_type) c.form = render(self._group_form(group_type=group_type), @@ -290,7 +301,8 @@ def edit(self, id, data=None, errors=None, error_summary=None): abort(401, _('User %r not authorized to edit %s') % (c.user, id)) errors = errors or {} - vars = {'data': data, 'errors': errors, 'error_summary': error_summary} + vars = {'data': data, 'errors': errors, + 'error_summary': error_summary, 'action': 'edit'} self._setup_template_variables(context, data, group_type=group_type) c.form = render(self._group_form(group_type), extra_vars=vars) @@ -385,6 +397,30 @@ def authz(self, id): self._prepare_authz_info_for_render(roles) return render('group/authz.html') + def delete(self, id): + if 'cancel' in request.params: + h.redirect_to(controller='group', action='edit', id=id) + + context = {'model': model, 'session': model.Session, + 'user': c.user or c.author} + + try: + check_access('group_delete', context, {'id': id}) + except NotAuthorized: + abort(401, _('Unauthorized to delete group %s') % '') + + try: + if request.method == 'POST': + get_action('group_delete')(context, {'id': id}) + h.flash_notice(_('Group has been deleted.')) + h.redirect_to(controller='group', action='index') + c.group_dict = get_action('group_show')(context, {'id': id}) + except NotAuthorized: + abort(401, _('Unauthorized to delete group %s') % '') + except NotFound: + abort(404, _('Group not found')) + return render('group/confirm_delete.html') + def history(self, id): if 'diff' in request.params or 'selected1' in request.params: try: diff --git a/ckan/controllers/home.py b/ckan/controllers/home.py index be7d9b9ab1c..dc90e8ed4de 100644 --- a/ckan/controllers/home.py +++ b/ckan/controllers/home.py @@ -9,8 +9,10 @@ from ckan.lib.base import * from ckan.lib.helpers import url_for -CACHE_PARAMETER = '__cache' +CACHE_PARAMETERS = ['__cache', '__no_cache__'] +# horrible hack +dirty_cached_group_stuff = None class HomeController(BaseController): repo = model.repo @@ -43,13 +45,16 @@ def index(self): data_dict = { 'q': '*:*', 'facet.field': g.facets, - 'rows': 0, + 'rows': 4, 'start': 0, + 'sort': 'views_recent desc', 'fq': 'capacity:"public"' } query = ckan.logic.get_action('package_search')( context, data_dict) + c.search_facets = query['search_facets'] c.package_count = query['count'] + c.datasets = query['results'] c.facets = query['facets'] maintain.deprecate_context_item( @@ -58,6 +63,11 @@ def index(self): c.search_facets = query['search_facets'] + c.facet_titles = {'groups': _('Groups'), + 'tags': _('Tags'), + 'res_format': _('Formats'), + 'license': _('Licence'), } + data_dict = {'order_by': 'packages', 'all_fields': 1} # only give the terms to group dictize that are returned in the # facets as full results take a lot longer @@ -94,9 +104,77 @@ def index(self): if msg: h.flash_notice(msg, allow_html=True) - c.recently_changed_packages_activity_stream = \ - ckan.logic.action.get.recently_changed_packages_activity_list_html( - context, {}) + @property + def recently_changed_packages_activity_stream(): + return ckan.logic.action.get.recently_changed_packages_activity_list_html(context, {}) + c.recently_changed_packages_activity_stream = recently_changed_packages_activity_stream + + # START OF DIRTYNESS + def get_group(id): + def _get_group_type(id): + """ + Given the id of a group it determines the type of a group given + a valid id/name for the group. + """ + group = model.Group.get(id) + if not group: + return None + return group.type + + def db_to_form_schema(group_type=None): + from ckan.lib.plugins import lookup_group_plugin + return lookup_group_plugin(group_type).db_to_form_schema() + + group_type = _get_group_type(id.split('@')[0]) + context = {'model': model, 'session': model.Session, + 'ignore_auth': True, + 'user': c.user or c.author, + 'schema': db_to_form_schema(group_type=group_type), + 'for_view': True} + data_dict = {'id': id} + + try: + group_dict = ckan.logic.get_action('group_show')(context, data_dict) + except ckan.logic.NotFound: + return None + + return {'group_dict' :group_dict} + + global dirty_cached_group_stuff + if not dirty_cached_group_stuff: + groups_data = [] + groups = config.get('demo.featured_groups', '').split() + + for group_name in groups: + group = get_group(group_name) + if group: + groups_data.append(group) + if len(groups_data) == 2: + break + + # c.groups is from the solr query above + if len(groups_data) < 2 and len(c.groups) > 0: + group = get_group(c.groups[0]['name']) + if group: + groups_data.append(group) + if len(groups_data) < 2 and len(c.groups) > 1: + group = get_group(c.groups[1]['name']) + if group: + groups_data.append(group) + # We get all the packages or at least too many so + # limit it to just 2 + for group in groups_data: + group['group_dict']['packages'] = group['group_dict']['packages'][:2] + #now add blanks so we have two + while len(groups_data) < 2: + groups_data.append({'group_dict' :{}}) + # cache for later use + dirty_cached_group_stuff = groups_data + + + c.group_package_stuff = dirty_cached_group_stuff + + # END OF DIRTYNESS return render('home/index.html', cache_force=True) diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py index 96cbb82aa95..d19dffed11a 100644 --- a/ckan/controllers/package.py +++ b/ckan/controllers/package.py @@ -30,7 +30,7 @@ import ckan.rating import ckan.misc import ckan.lib.accept as accept -from home import CACHE_PARAMETER +from home import CACHE_PARAMETERS from ckan.lib.plugins import lookup_package_plugin @@ -140,18 +140,15 @@ def search(self): if k != 'page'] def drill_down_url(alternative_url=None, **by): - params = set(params_nopage) - params |= set(by.items()) - if alternative_url: - return url_with_params(alternative_url, params) - return search_url(params) + return h.add_url_param(alternative_url=alternative_url, + controller='package', action='search', + new_params=by) c.drill_down_url = drill_down_url - def remove_field(key, value): - params = list(params_nopage) - params.remove((key, value)) - return search_url(params) + def remove_field(key, value=None, replace=None): + return h.remove_url_param(key, value=value, replace=replace, + controller='package', action='search') c.remove_field = remove_field @@ -181,6 +178,7 @@ def _sort_by(fields): else: c.sort_by_fields = [field.split()[0] for field in sort_by.split(',')] + c.sort_by_selected = sort_by def pager_url(q=None, page=None): params = list(params_nopage) @@ -191,6 +189,9 @@ def pager_url(q=None, page=None): try: c.fields = [] + # c.fields_grouped will contain a dict of params containing + # a list of values eg {'tags':['tag1', 'tag2']} + c.fields_grouped = {} search_extras = {} fq = '' for (param, value) in request.params.items(): @@ -199,6 +200,10 @@ def pager_url(q=None, page=None): if not param.startswith('ext_'): c.fields.append((param, value)) fq += ' %s:"%s"' % (param, value) + if param not in c.fields_grouped: + c.fields_grouped[param] = [value] + else: + c.fields_grouped[param].append(value) else: search_extras[param] = value @@ -232,6 +237,14 @@ def pager_url(q=None, page=None): c.query_error = True c.facets = {} c.page = h.Page(collection=[]) + c.search_facets_limits = {} + for facet in c.search_facets.keys(): + limit = int(request.params.get('_%s_limit' % facet, 10)) + c.search_facets_limits[facet] = limit + c.facet_titles = {'groups': _('Groups'), + 'tags': _('Tags'), + 'res_format': _('Formats'), + 'license': _('Licence'), } maintain.deprecate_context_item( 'facets', @@ -442,13 +455,31 @@ def new(self, data=None, errors=None, error_summary=None): return self._save_new(context) data = data or clean_dict(unflatten(tuplize_dict(parse_params( - request.params, ignore_keys=[CACHE_PARAMETER])))) + request.params, ignore_keys=CACHE_PARAMETERS)))) c.resources_json = json.dumps(data.get('resources', [])) + # convert tags if not supplied in data + if data and not data.get('tag_string'): + data['tag_string'] = ', '.join( + h.dict_list_reduce(data.get('tags', {}), 'name')) errors = errors or {} error_summary = error_summary or {} + # in the phased add dataset we need to know that + # we have already completed stage 1 + stage = ['active'] + if data.get('state') == 'draft': + stage = ['active', 'complete'] + elif data.get('state') == 'draft-complete': + stage = ['active', 'complete', 'complete'] + + # if we are creating from a group then this allows the group to be + # set automatically + data['group_id'] = request.params.get('group') or \ + request.params.get('groups__0__id') + vars = {'data': data, 'errors': errors, - 'error_summary': error_summary} + 'error_summary': error_summary, + 'action': 'new', 'stage': stage} c.errors_json = json.dumps(errors) self._setup_template_variables(context, {'id': id}) @@ -460,7 +491,215 @@ def new(self, data=None, errors=None, error_summary=None): else: c.form = render(self._package_form(package_type=package_type), extra_vars=vars) - return render(self._new_template(package_type)) + return render(self._new_template(package_type), + extra_vars={'stage': stage}) + + def resource_edit(self, id, resource_id, data=None, errors=None, + error_summary=None): + if request.method == 'POST' and not data: + data = data or clean_dict(unflatten(tuplize_dict(parse_params( + request.POST)))) + # we don't want to include save as it is part of the form + del data['save'] + + context = {'model': model, 'session': model.Session, + 'api_version': 3, + 'user': c.user or c.author, + 'extras_as_string': True} + + data['package_id'] = id + try: + if resource_id: + data['id'] = resource_id + get_action('resource_update')(context, data) + else: + get_action('resource_create')(context, data) + except ValidationError, e: + errors = e.error_dict + error_summary = e.error_summary + return self.resource_edit(id, resource_id, data, + errors, error_summary) + except NotAuthorized: + abort(401, _('Unauthorized to edit this resource')) + redirect(h.url_for(controller='package', action='resource_read', + id=id, resource_id=resource_id)) + + + context = {'model': model, 'session': model.Session, + 'api_version': 3, + 'user': c.user or c.author,} + pkg_dict = get_action('package_show')(context, {'id': id}) + if pkg_dict['state'].startswith('draft'): + # dataset has not yet been fully created + resource_dict = get_action('resource_show')(context, {'id': resource_id}) + fields = ['url', 'resource_type', 'format', 'name', 'description', 'id'] + data = {} + for field in fields: + data[field] = resource_dict[field] + return self.new_resource(id, data=data) + # resource is fully created + try: + resource_dict = get_action('resource_show')(context, {'id': resource_id}) + except NotFound: + abort(404, _('Resource not found')) + c.pkg_dict = pkg_dict + c.resource = resource_dict + # set the form action + c.form_action = h.url_for(controller='package', + action='resource_edit', + resource_id=resource_id, + id=id) + data = resource_dict + errors = errors or {} + error_summary = error_summary or {} + vars = {'data': data, 'errors': errors, + 'error_summary': error_summary, 'action': 'new'} + return render('package/resource_edit.html', extra_vars=vars) + + + + def new_resource(self, id, data=None, errors=None, error_summary=None): + ''' FIXME: This is a temporary action to allow styling of the + forms. ''' + if request.method == 'POST' and not data: + save_action = request.params.get('save') + data = data or clean_dict(unflatten(tuplize_dict(parse_params( + request.POST)))) + # we don't want to include save as it is part of the form + del data['save'] + resource_id = data['id'] + del data['id'] + + context = {'model': model, 'session': model.Session, + 'api_version': 3, + 'user': c.user or c.author, + 'extras_as_string': True} + + # see if we have any data that we are trying to save + data_provided = False + for key, value in data.iteritems(): + if value and key != 'resource_type': + data_provided = True + break + + if not data_provided and save_action != "go-dataset-complete": + if save_action == 'go-dataset': + # go to final stage of adddataset + redirect(h.url_for(controller='package', + action='edit', id=id)) + # see if we have added any resources + try: + data_dict = get_action('package_show')(context, {'id': id}) + except NotAuthorized: + abort(401, _('Unauthorized to update dataset')) + if not len(data_dict['resources']): + # no data so keep on page + h.flash_error(_('You must add at least one data resource')) + redirect(h.url_for(controller='package', + action='new_resource', id=id)) + # we have a resource so let them add metadata + redirect(h.url_for(controller='package', + action='new_metadata', id=id)) + + data['package_id'] = id + try: + if resource_id: + data['id'] = resource_id + get_action('resource_update')(context, data) + else: + get_action('resource_create')(context, data) + except ValidationError, e: + errors = e.error_dict + error_summary = e.error_summary + return self.new_resource(id, data, errors, error_summary) + except NotAuthorized: + abort(401, _('Unauthorized to create a resource')) + if save_action == 'go-metadata': + # go to final stage of add dataset + redirect(h.url_for(controller='package', + action='new_metadata', id=id)) + elif save_action == 'go-dataset': + # go to first stage of add dataset + redirect(h.url_for(controller='package', + action='edit', id=id)) + elif save_action == 'go-dataset-complete': + # go to first stage of add dataset + redirect(h.url_for(controller='package', + action='read', id=id)) + else: + # add more resources + redirect(h.url_for(controller='package', + action='new_resource', id=id)) + errors = errors or {} + error_summary = error_summary or {} + vars = {'data': data, 'errors': errors, + 'error_summary': error_summary, 'action': 'new'} + vars['pkg_name'] = id + # get resources for sidebar + context = {'model': model, 'session': model.Session, + 'user': c.user or c.author, 'extras_as_string': True,} + pkg_dict = get_action('package_show')(context, {'id': id}) + # required for nav menu + vars['pkg_dict'] = pkg_dict + if pkg_dict['state'] == 'draft': + vars['stage'] = ['complete', 'active'] + elif pkg_dict['state'] == 'draft-complete': + vars['stage'] = ['complete', 'active', 'complete'] + return render('package/new_resource.html', extra_vars=vars) + + def new_metadata(self, id, data=None, errors=None, error_summary=None): + ''' FIXME: This is a temporary action to allow styling of the + forms. ''' + if request.method == 'POST' and not data: + save_action = request.params.get('save') + data = data or clean_dict(unflatten(tuplize_dict(parse_params( + request.POST)))) + # we don't want to include save as it is part of the form + del data['save'] + context = {'model': model, 'session': model.Session, + 'api_version': 3, + 'user': c.user or c.author, + 'extras_as_string': True} + data_dict = get_action('package_show')(context, {'id': id}) + + data_dict['id'] = id + # update the state + if save_action == 'finish': + # we want this to go live when saved + data_dict['state'] = 'active' + elif save_action in ['go-resources', 'go-dataset']: + data_dict['state'] = 'draft-complete' + # allow the state to be changed + context['allow_state_change'] = True + data_dict.update(data) + try: + get_action('package_update')(context, data_dict) + except ValidationError, e: + errors = e.error_dict + error_summary = e.error_summary + return self.new_metadata(id, data, errors, error_summary) + except NotAuthorized: + abort(401, _('Unauthorized to update dataset')) + if save_action == 'go-resources': + # we want to go back to the add resources form stage + redirect(h.url_for(controller='package', + action='new_resource', id=id)) + elif save_action == 'go-dataset': + # we want to go back to the add dataset stage + redirect(h.url_for(controller='package', + action='edit', id=id)) + + redirect(h.url_for(controller='package', action='read', id=id)) + + if not data: + context = {'model': model, 'session': model.Session, + 'user': c.user or c.author, 'extras_as_string': True,} + data = get_action('package_show')(context, {'id': id}) + errors = errors or {} + error_summary = error_summary or {} + vars = {'data': data, 'errors': errors, 'error_summary': error_summary} + vars['pkg_name'] = id + return render('package/new_package_metadata.html', extra_vars=vars) def edit(self, id, data=None, errors=None, error_summary=None): package_type = self._get_package_type(id) @@ -483,6 +722,12 @@ def edit(self, id, data=None, errors=None, error_summary=None): abort(401, _('Unauthorized to read package %s') % '') except NotFound: abort(404, _('Dataset not found')) + # are we doing a multiphase add? + if data.get('state', '').startswith('draft'): + c.form_action = h.url_for(controller='package', action='new') + c.form_style = 'new' + return self.new(data=data, errors=errors, + error_summary=error_summary) c.pkg = context.get("package") c.resources_json = json.dumps(data.get('resources', [])) @@ -491,16 +736,26 @@ def edit(self, id, data=None, errors=None, error_summary=None): check_access('package_update', context) except NotAuthorized, e: abort(401, _('User %r not authorized to edit %s') % (c.user, id)) - + # convert tags if not supplied in data + if data and not data.get('tag_string'): + data['tag_string'] = ', '.join(h.dict_list_reduce( + c.pkg_dict.get('tags', {}), 'name')) errors = errors or {} vars = {'data': data, 'errors': errors, - 'error_summary': error_summary} + 'error_summary': error_summary, 'action': 'edit'} c.errors_json = json.dumps(errors) self._setup_template_variables(context, {'id': id}, package_type=package_type) c.related_count = c.pkg.related_count + # we have already completed stage 1 + vars['stage'] = ['active'] + if data.get('state') == 'draft': + vars['stage'] = ['active', 'complete'] + elif data.get('state') == 'draft-complete': + vars['stage'] = ['active', 'complete', 'complete'] + # TODO: This check is to maintain backwards compatibility with the # old way of creating custom forms. This behaviour is now deprecated. if hasattr(self, 'package_form'): @@ -509,22 +764,15 @@ def edit(self, id, data=None, errors=None, error_summary=None): c.form = render(self._package_form(package_type=package_type), extra_vars=vars) - if (c.action == u'editresources'): - return render('package/editresources.html') - else: - return render('package/edit.html') - - def editresources(self, id, data=None, errors=None, error_summary=None): - '''Hook method made available for routing purposes.''' - return self.edit(id, data, errors, error_summary) + return render('package/edit.html') def read_ajax(self, id, revision=None): package_type = self._get_package_type(id) context = {'model': model, 'session': model.Session, 'user': c.user or c.author, 'extras_as_string': True, - 'schema': self._form_to_db_schema(package_type= - package_type), + 'schema': self._form_to_db_schema( + package_type=package_type), 'revision_id': revision} try: data = get_action('package_show')(context, {'id': id}) @@ -588,16 +836,72 @@ def _get_package_type(self, id): return pkg.type or 'package' return None + def _tag_string_to_list(self, tag_string): + ''' This is used to change tags from a sting to a list of dicts ''' + out = [] + for tag in tag_string.split(','): + tag = tag.strip() + if tag: + out.append({'name': tag, + 'state': 'active'}) + return out + def _save_new(self, context, package_type=None): + # The staged add dataset used the new functionality when the dataset is + # partially created so we need to know if we actually are updating or + # this is a real new. + is_an_update = False + ckan_phase = request.params.get('_ckan_phase') + if ckan_phase: + # phased add dataset so use api schema for validation + context['api_version'] = 3 from ckan.lib.search import SearchIndexError try: data_dict = clean_dict(unflatten( tuplize_dict(parse_params(request.POST)))) + if ckan_phase: + # prevent clearing of groups etc + context['allow_partial_update'] = True + # sort the tags + data_dict['tags'] = self._tag_string_to_list( + data_dict['tag_string']) + if data_dict.get('pkg_name'): + is_an_update = True + # This is actually an update not a save + data_dict['id'] = data_dict['pkg_name'] + del data_dict['pkg_name'] + # this is actually an edit not a save + pkg_dict = get_action('package_update')(context, data_dict) + + if request.params['save'] == 'go-metadata': + # redirect to add metadata + url = h.url_for(controller='package', + action='new_metadata', + id=pkg_dict['name']) + else: + # redirect to add dataset resources + url = h.url_for(controller='package', + action='new_resource', + id=pkg_dict['name']) + redirect(url) + # Make sure we don't index this dataset + if request.params['save'] not in ['go-resource', 'go-metadata']: + data_dict['state'] = 'draft' + # allow the state to be changed + context['allow_state_change'] = True + data_dict['type'] = package_type context['message'] = data_dict.get('log_message', '') - pkg = get_action('package_create')(context, data_dict) + pkg_dict = get_action('package_create')(context, data_dict) + + if ckan_phase: + # redirect to add dataset resources + url = h.url_for(controller='package', + action='new_resource', + id=pkg_dict['name']) + redirect(url) - self._form_save_redirect(pkg['name'], 'new') + self._form_save_redirect(pkg_dict['name'], 'new') except NotAuthorized: abort(401, _('Unauthorized to read package %s') % '') except NotFound, e: @@ -613,6 +917,14 @@ def _save_new(self, context, package_type=None): except ValidationError, e: errors = e.error_dict error_summary = e.error_summary + if is_an_update: + # we need to get the state of the dataset to show the stage we + # are on. + pkg_dict = get_action('package_show')(context, data_dict) + data_dict['state'] = pkg_dict['state'] + return self.edit(data_dict['id'], data_dict, + errors, error_summary) + data_dict['state'] = 'none' return self.new(data_dict, errors, error_summary) def _save_edit(self, name_or_id, context): @@ -622,6 +934,14 @@ def _save_edit(self, name_or_id, context): try: data_dict = clean_dict(unflatten( tuplize_dict(parse_params(request.POST)))) + if '_ckan_phase' in data_dict: + context['api_version'] = 3 + # we allow partial updates to not destroy existing resources + context['allow_partial_update'] = True + data_dict['tags'] = self._tag_string_to_list( + data_dict['tag_string']) + del data_dict['_ckan_phase'] + del data_dict['save'] context['message'] = data_dict.get('log_message', '') if not context['moderated']: context['pending'] = False @@ -704,6 +1024,57 @@ def authz(self, id): return render('package/authz.html') + def delete(self, id): + + if 'cancel' in request.params: + h.redirect_to(controller='package', action='edit', id=id) + + context = {'model': model, 'session': model.Session, + 'user': c.user or c.author} + + try: + check_access('package_delete', context, {'id': id}) + except NotAuthorized: + abort(401, _('Unauthorized to delete package %s') % '') + + try: + if request.method == 'POST': + get_action('package_delete')(context, {'id': id}) + h.flash_notice(_('Dataset has been deleted.')) + h.redirect_to(controller='package', action='search') + c.pkg_dict = get_action('package_show')(context, {'id': id}) + except NotAuthorized: + abort(401, _('Unauthorized to delete package %s') % '') + except NotFound: + abort(404, _('Dataset not found')) + return render('package/confirm_delete.html') + + def resource_delete(self, id, resource_id): + + if 'cancel' in request.params: + h.redirect_to(controller='package', action='resource_edit', resource_id=resource_id, id=id) + + context = {'model': model, 'session': model.Session, + 'user': c.user or c.author} + + try: + check_access('package_delete', context, {'id': id}) + except NotAuthorized: + abort(401, _('Unauthorized to delete package %s') % '') + + try: + if request.method == 'POST': + get_action('resource_delete')(context, {'id': resource_id}) + h.flash_notice(_('Resource has been deleted.')) + h.redirect_to(controller='package', action='read', id=id) + c.resource_dict = get_action('resource_show')(context, {'id': resource_id}) + c.pkg_id = id + except NotAuthorized: + abort(401, _('Unauthorized to delete resource %s') % '') + except NotFound: + abort(404, _('Resource not found')) + return render('package/confirm_delete_resource.html') + def autocomplete(self): # DEPRECATED in favour of /api/2/util/dataset/autocomplete q = unicode(request.params.get('q', '')) @@ -814,6 +1185,10 @@ def resource_download(self, id, resource_id): abort(404, _('No download is available')) redirect(rsc['url']) + def api_data(self, id=None): + url = h.url_for('datastore_read', id=id, qualified=True) + return render('package/resource_api_data.html', {'datastore_root_url': url}) + def followers(self, id=None): context = {'model': model, 'session': model.Session, 'user': c.user or c.author, 'for_view': True} diff --git a/ckan/controllers/related.py b/ckan/controllers/related.py index 8168650192e..d624801cc5e 100644 --- a/ckan/controllers/related.py +++ b/ckan/controllers/related.py @@ -1,17 +1,27 @@ - - import ckan.model as model import ckan.logic as logic import ckan.lib.base as base import ckan.lib.helpers as h +import ckan.lib.navl.dictization_functions as df + +import pylons.i18n as i18n + +_ = i18n._ import urllib c = base.c +abort = base.abort _get_action=logic.get_action class RelatedController(base.BaseController): + def new(self, id): + return self._edit_or_new(id, None, False) + + def edit(self, id, related_id): + return self._edit_or_new(id, related_id, True) + def dashboard(self): """ List all related items regardless of dataset """ context = {'model': model, 'session': model.Session, @@ -56,6 +66,13 @@ def pager_url(q=None, page=None): c.filters = dict(params_nopage) + c.type_options = self._type_options() + c.sort_options = ({'value': '', 'text': _('Most viewed')}, + {'value': 'view_count_desc', 'text': _('Most Viewed')}, + {'value': 'view_count_asc', 'text': _('Least Viewed')}, + {'value': 'created_desc', 'text': _('Newest')}, + {'value': 'created_asc', 'text': _('Oldest')}) + return base.render( "related/dashboard.html") def read(self, id): @@ -106,7 +123,122 @@ def list(self, id): base.abort(401, base._('Unauthorized to read package %s') % id) c.action = 'related' - c.related_count = c.pkg.related_count - c.num_followers = _get_action('dataset_follower_count')(context, - {'id':c.pkg.id}) - return base.render( "related/related_list.html") + return base.render("package/related_list.html") + + def _edit_or_new(self, id, related_id, is_edit): + """ + Edit and New were too similar and so I've put the code together + and try and do as much up front as possible. + """ + context = {'model': model, 'session': model.Session, + 'user': c.user or c.author, 'for_view': True} + data_dict = {} + + if is_edit: + tpl = 'related/edit.html' + auth_name = 'related_update' + auth_dict = {'id': related_id} + action_name = 'related_update' + + try: + related = logic.get_action('related_show')( + context, {'id': related_id}) + except logic.NotFound: + base.abort(404, _('Related item not found')) + else: + tpl = 'related/new.html' + auth_name = 'related_create' + auth_dict = {} + action_name = 'related_create' + + try: + logic.check_access(auth_name, context, auth_dict) + except logic.NotAuthorized: + base.abort(401, base._('Not authorized')) + + try: + c.pkg_dict = logic.get_action('package_show')(context, {'id': id}) + except logic.NotFound: + base.abort(404, _('Package not found')) + + data, errors, error_summary = {}, {}, {} + + if base.request.method == "POST": + try: + data = logic.clean_dict( + df.unflatten( + logic.tuplize_dict( + logic.parse_params(base.request.params) + ))) + + if is_edit: + data['id'] = related_id + else: + data['dataset_id'] = id + data['owner_id'] = c.userobj.id + + related = logic.get_action(action_name)(context, data) + + if not is_edit: + h.flash_success(_("Related item was successfully created")) + else: + h.flash_success(_("Related item was successfully updated")) + + h.redirect_to(controller='related', + action='list', + id=c.pkg_dict['name']) + except df.DataError: + base.abort(400, _(u'Integrity Error')) + except logic.ValidationError, e: + errors = e.error_dict + error_summary = e.error_summary + else: + if is_edit: + data = related + + c.types = self._type_options() + + c.pkg_id = id + vars = {'data': data, 'errors': errors, 'error_summary': error_summary} + c.form = base.render("related/edit_form.html", extra_vars=vars) + return base.render(tpl) + + def delete(self, id, related_id): + + if 'cancel' in base.request.params: + h.redirect_to(controller='related', action='edit', + id=id, related_id=related_id) + + context = {'model': model, 'session': model.Session, + 'user': c.user or c.author} + + try: + logic.check_access('related_delete', context, {'id': id}) + except logic.NotAuthorized: + base.abort(401, _('Unauthorized to delete package %s') % '') + + try: + if base.request.method == 'POST': + logic.get_action('related_delete')(context, {'id': related_id}) + h.flash_notice(_('Related item has been deleted.')) + h.redirect_to(controller='package', action='read', id=id) + c.related_dict = logic.get_action('related_show')(context, {'id': related_id}) + c.pkg_id = id + except logic.NotAuthorized: + base.abort(401, _('Unauthorized to delete related item %s') % '') + except logic.NotFound: + base.abort(404, _('Related item not found')) + return base.render('related/confirm_delete.html') + + def _type_options(self): + ''' + A tuple of options for the different related types for use in + the form.select() template macro. + ''' + return ({"text": _("API"), "value": "api"}, + {"text": _("Application"), "value": "application"}, + {"text": _("Idea"), "value": "idea"}, + {"text": _("News Article"), "value": "news_article"}, + {"text": _("Paper"), "value": "paper"}, + {"text": _("Post"), "value": "post"}, + {"text": _("Visualization"), "value": "visualization"}) diff --git a/ckan/controllers/user.py b/ckan/controllers/user.py index 13e60a93441..f59273b92bb 100644 --- a/ckan/controllers/user.py +++ b/ckan/controllers/user.py @@ -120,8 +120,10 @@ def me(self, locale=None): h.redirect_to(locale=locale, controller='user', action='login', id=None) user_ref = c.userobj.get_reference_preferred_for_uri() - h.redirect_to(locale=locale, controller='user', action='dashboard', - id=user_ref) + if asbool(config.get('ckan.legacy_templates', 'false')): + h.redirect_to(locale=locale, controller='user', + action='dashboard', id=user_ref) + return self.read(id=c.username) def register(self, data=None, errors=None, error_summary=None): return self.new(data, errors, error_summary) @@ -266,7 +268,7 @@ def _save_edit(self, id, context): error_summary = e.error_summary return self.edit(id, data_dict, errors, error_summary) - def login(self): + def login(self, error=None): lang = session.pop('lang', None) if lang: session.save() @@ -281,9 +283,15 @@ def login(self): g.openid_enabled = False if not c.user: + came_from = request.params.get('came_from', '') c.login_handler = h.url_for( - self._get_repoze_handler('login_handler_path')) - return render('user/login.html') + self._get_repoze_handler('login_handler_path'), + came_from=came_from) + if error: + vars = {'error_summary': {'':error}} + else: + vars = {} + return render('user/login.html', extra_vars=vars) else: return render('user/logout_first.html') @@ -291,6 +299,7 @@ def logged_in(self): # we need to set the language via a redirect lang = session.pop('lang', None) session.save() + came_from = request.params.get('came_from', '') # we need to set the language explicitly here or the flash # messages will not be translated. @@ -306,14 +315,20 @@ def logged_in(self): h.flash_success(_("%s is now logged in") % user_dict['display_name']) + if came_from: + return h.redirect_to(str(came_from)) return self.me(locale=lang) else: err = _('Login failed. Bad username or password.') if g.openid_enabled: err += _(' (Or if using OpenID, it hasn\'t been associated ' 'with a user account.)') - h.flash_error(err) - h.redirect_to(locale=lang, controller='user', action='login') + if asbool(config.get('ckan.legacy_templates', 'false')): + h.flash_error(err) + h.redirect_to(locale=lang, controller='user', + action='login', came_from=came_from) + else: + return self.login(error=err) def logout(self): # save our language in the session so we don't lose it diff --git a/ckan/controllers/util.py b/ckan/controllers/util.py new file mode 100644 index 00000000000..bb6a40f2dd1 --- /dev/null +++ b/ckan/controllers/util.py @@ -0,0 +1,32 @@ +import re + +import ckan.lib.base as base +import ckan.lib.i18n as i18n + + +class UtilController(base.BaseController): + ''' Controller for functionality that has no real home''' + + def redirect(self): + ''' redirect to the url parameter. ''' + url = base.request.params.get('url') + return base.redirect(url) + + def primer(self): + ''' Render all html components out onto a single page. + This is useful for development/styling of ckan. ''' + return base.render('development/primer.html') + + def markup(self): + ''' Render all html elements out onto a single page. + This is useful for development/styling of ckan. ''' + return base.render('development/markup.html') + + def i18_js_strings(self, lang): + ''' This is used to produce the translations for javascript. ''' + i18n.set_lang(lang) + html = base.render('js_strings.html', cache_force=True) + html = re.sub('<[^\>]*>', '', html) + header = "text/javascript; charset=utf-8" + base.response.headers['Content-type'] = header + return html diff --git a/ckan/include/README.txt b/ckan/include/README.txt new file mode 100644 index 00000000000..207800aa9e6 --- /dev/null +++ b/ckan/include/README.txt @@ -0,0 +1,37 @@ +rjsmin.py +is taken from the rjsmin project and licensed under Apache License, Version 2 +http://opensource.perlig.de/rjsmin/ + +# Copyright 2011, 2012 +# Andr\xe9 Malo or his licensors, as applicable +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +rcssmin.py +is taken from the rcssmin project and licensed under Apache License, Version 2 +http://opensource.perlig.de/rcssmin/ + +# Copyright 2011, 2012 +# Andr\xe9 Malo or his licensors, as applicable +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/ckanext/stats/templates/__init__.py b/ckan/include/__init__.py similarity index 100% rename from ckanext/stats/templates/__init__.py rename to ckan/include/__init__.py diff --git a/ckan/include/rcssmin.py b/ckan/include/rcssmin.py new file mode 100755 index 00000000000..3f0435f0074 --- /dev/null +++ b/ckan/include/rcssmin.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python +# -*- coding: ascii -*- +# +# Copyright 2011, 2012 +# Andr\xe9 Malo or his licensors, as applicable +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +r""" +============== + CSS Minifier +============== + +CSS Minifier. + +The minifier is based on the semantics of the `YUI compressor`_\, which itself +is based on `the rule list by Isaac Schlueter`_\. + +This module is a re-implementation aiming for speed instead of maximum +compression, so it can be used at runtime (rather than during a preprocessing +step). RCSSmin does syntactical compression only (removing spaces, comments +and possibly semicolons). It does not provide semantic compression (like +removing empty blocks, collapsing redundant properties etc). It does, however, +support various CSS hacks (by keeping them working as intended). + +Here's a feature list: + +- Strings are kept, except that escaped newlines are stripped +- Space/Comments before the very end or before various characters are + stripped: ``:{});=>+],!`` (The colon (``:``) is a special case, a single + space is kept if it's outside a ruleset.) +- Space/Comments at the very beginning or after various characters are + stripped: ``{}(=:>+[,!`` +- Optional space after unicode escapes is kept, resp. replaced by a simple + space +- whitespaces inside ``url()`` definitions are stripped +- Comments starting with an exclamation mark (``!``) can be kept optionally. +- All other comments and/or whitespace characters are replaced by a single + space. +- Multiple consecutive semicolons are reduced to one +- The last semicolon within a ruleset is stripped +- CSS Hacks supported: + + - IE7 hack (``>/**/``) + - Mac-IE5 hack (``/*\*/.../**/``) + - The boxmodelhack is supported naturally because it relies on valid CSS2 + strings + - Between ``:first-line`` and the following comma or curly brace a space is + inserted. (apparently it's needed for IE6) + - Same for ``:first-letter`` + +rcssmin.c is a reimplementation of rcssmin.py in C and improves runtime up to +factor 50 or so (depending on the input). + +Both python 2 (>= 2.4) and python 3 are supported. + +.. _YUI compressor: https://github.com/yui/yuicompressor/ + +.. _the rule list by Isaac Schlueter: https://github.com/isaacs/cssmin/tree/ +""" +__author__ = "Andr\xe9 Malo" +__author__ = getattr(__author__, 'decode', lambda x: __author__)('latin-1') +__docformat__ = "restructuredtext en" +__license__ = "Apache License, Version 2.0" +__version__ = '1.0.0' +__all__ = ['cssmin'] + +import re as _re + + +def _make_cssmin(python_only=False): + """ + Generate CSS minifier. + + :Parameters: + `python_only` : ``bool`` + Use only the python variant. If true, the c extension is not even + tried to be loaded. + + :Return: Minifier + :Rtype: ``callable`` + """ + # pylint: disable = W0612 + # ("unused" variables) + + # pylint: disable = R0911, R0912, R0914, R0915 + # (too many anything) + + if not python_only: + try: + import _rcssmin + except ImportError: + pass + else: + return _rcssmin.cssmin + + nl = r'(?:[\n\f]|\r\n?)' # pylint: disable = C0103 + spacechar = r'[\r\n\f\040\t]' + + unicoded = r'[0-9a-fA-F]{1,6}(?:[\040\n\t\f]|\r\n?)?' + escaped = r'[^\n\r\f0-9a-fA-F]' + escape = r'(?:\\(?:%(unicoded)s|%(escaped)s))' % locals() + + nmchar = r'[^\000-\054\056\057\072-\100\133-\136\140\173-\177]' + #nmstart = r'[^\000-\100\133-\136\140\173-\177]' + #ident = (r'(?:' + # r'-?(?:%(nmstart)s|%(escape)s)%(nmchar)s*(?:%(escape)s%(nmchar)s*)*' + #r')') % locals() + + comment = r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)' + + # only for specific purposes. The bang is grouped: + _bang_comment = r'(?:/\*(!?)[^*]*\*+(?:[^/*][^*]*\*+)*/)' + + string1 = \ + r'(?:\047[^\047\\\r\n\f]*(?:\\[^\r\n\f][^\047\\\r\n\f]*)*\047)' + string2 = r'(?:"[^"\\\r\n\f]*(?:\\[^\r\n\f][^"\\\r\n\f]*)*")' + strings = r'(?:%s|%s)' % (string1, string2) + + nl_string1 = \ + r'(?:\047[^\047\\\r\n\f]*(?:\\(?:[^\r]|\r\n?)[^\047\\\r\n\f]*)*\047)' + nl_string2 = r'(?:"[^"\\\r\n\f]*(?:\\(?:[^\r]|\r\n?)[^"\\\r\n\f]*)*")' + nl_strings = r'(?:%s|%s)' % (nl_string1, nl_string2) + + uri_nl_string1 = r'(?:\047[^\047\\]*(?:\\(?:[^\r]|\r\n?)[^\047\\]*)*\047)' + uri_nl_string2 = r'(?:"[^"\\]*(?:\\(?:[^\r]|\r\n?)[^"\\]*)*")' + uri_nl_strings = r'(?:%s|%s)' % (uri_nl_string1, uri_nl_string2) + + nl_escaped = r'(?:\\%(nl)s)' % locals() + + space = r'(?:%(spacechar)s|%(comment)s)' % locals() + + ie7hack = r'(?:>/\*\*/)' + + uri = (r'(?:' + r'(?:[^\000-\040"\047()\\\177]*' + r'(?:%(escape)s[^\000-\040"\047()\\\177]*)*)' + r'(?:' + r'(?:%(spacechar)s+|%(nl_escaped)s+)' + r'(?:' + r'(?:[^\000-\040"\047()\\\177]|%(escape)s|%(nl_escaped)s)' + r'[^\000-\040"\047()\\\177]*' + r'(?:%(escape)s[^\000-\040"\047()\\\177]*)*' + r')+' + r')*' + r')') % locals() + + nl_unesc_sub = _re.compile(nl_escaped).sub + + uri_space_sub = _re.compile(( + r'(%(escape)s+)|%(spacechar)s+|%(nl_escaped)s+' + ) % locals()).sub + uri_space_subber = lambda m: m.groups()[0] or '' + + space_sub_simple = _re.compile(( + r'[\r\n\f\040\t;]+|(%(comment)s+)' + ) % locals()).sub + space_sub_banged = _re.compile(( + r'[\r\n\f\040\t;]+|(%(_bang_comment)s+)' + ) % locals()).sub + + post_esc_sub = _re.compile(r'[\r\n\f\t]+').sub + + main_sub = _re.compile(( + r'([^\\"\047u>@\r\n\f\040\t/;:{}]+)' + r'|(?<=[{}(=:>+[,!])(%(space)s+)' + r'|^(%(space)s+)' + r'|(%(space)s+)(?=(([:{});=>+\],!])|$)?)' + r'|;(%(space)s*(?:;%(space)s*)*)(?=(\})?)' + r'|(\{)' + r'|(\})' + r'|(%(strings)s)' + r'|(?@\r\n\f\040\t/;:{}]*)' + ) % locals()).sub + + #print main_sub.__self__.pattern + + def main_subber(keep_bang_comments): + """ Make main subber """ + in_macie5, in_rule, at_media = [0], [0], [0] + + if keep_bang_comments: + space_sub = space_sub_banged + def space_subber(match): + """ Space|Comment subber """ + if match.lastindex: + group1, group2 = match.group(1, 2) + if group2: + if group1.endswith(r'\*/'): + in_macie5[0] = 1 + else: + in_macie5[0] = 0 + return group1 + elif group1: + if group1.endswith(r'\*/'): + if in_macie5[0]: + return '' + in_macie5[0] = 1 + return r'/*\*/' + elif in_macie5[0]: + in_macie5[0] = 0 + return '/**/' + return '' + else: + space_sub = space_sub_simple + def space_subber(match): + """ Space|Comment subber """ + if match.lastindex: + if match.group(1).endswith(r'\*/'): + if in_macie5[0]: + return '' + in_macie5[0] = 1 + return r'/*\*/' + elif in_macie5[0]: + in_macie5[0] = 0 + return '/**/' + return '' + + def fn_space_post(group): + """ space with token after """ + if group(5) is None or ( + group(6) == ':' and not in_rule[0] and not at_media[0]): + return ' ' + space_sub(space_subber, group(4)) + return space_sub(space_subber, group(4)) + + def fn_semicolon(group): + """ ; handler """ + return ';' + space_sub(space_subber, group(7)) + + def fn_semicolon2(group): + """ ; handler """ + if in_rule[0]: + return space_sub(space_subber, group(7)) + return ';' + space_sub(space_subber, group(7)) + + def fn_open(group): + """ { handler """ + # pylint: disable = W0613 + if at_media[0]: + at_media[0] -= 1 + else: + in_rule[0] = 1 + return '{' + + def fn_close(group): + """ } handler """ + # pylint: disable = W0613 + in_rule[0] = 0 + return '}' + + def fn_media(group): + """ @media handler """ + at_media[0] += 1 + return group(13) + + def fn_ie7hack(group): + """ IE7 Hack handler """ + if not in_rule[0] and not at_media[0]: + in_macie5[0] = 0 + return group(14) + space_sub(space_subber, group(15)) + return '>' + space_sub(space_subber, group(15)) + + table = ( + None, + None, + None, + None, + fn_space_post, # space with token after + fn_space_post, # space with token after + fn_space_post, # space with token after + fn_semicolon, # semicolon + fn_semicolon2, # semicolon + fn_open, # { + fn_close, # } + lambda g: g(11), # string + lambda g: 'url(%s)' % uri_space_sub(uri_space_subber, g(12)), + # url(...) + fn_media, # @media + None, + fn_ie7hack, # ie7hack + None, + lambda g: g(16) + ' ' + space_sub(space_subber, g(17)), + # :first-line|letter followed + # by [{,] (apparently space + # needed for IE6) + lambda g: nl_unesc_sub('', g(18)), # nl_string + lambda g: post_esc_sub(' ', g(19)), # escape + ) + + def func(match): + """ Main subber """ + idx, group = match.lastindex, match.group + if idx > 3: + return table[idx](group) + + # shortcuts for frequent operations below: + elif idx == 1: # not interesting + return group(1) + #else: # space with token before or at the beginning + return space_sub(space_subber, group(idx)) + + return func + + def cssmin(style, keep_bang_comments=False): # pylint: disable = W0621 + """ + Minify CSS. + + :Parameters: + `style` : ``str`` + CSS to minify + + `keep_bang_comments` : ``bool`` + Keep comments starting with an exclamation mark? (``/*!...*/``) + + :Return: Minified style + :Rtype: ``str`` + """ + return main_sub(main_subber(keep_bang_comments), style) + + return cssmin + +cssmin = _make_cssmin() + + +if __name__ == '__main__': + def main(): + """ Main """ + import sys as _sys + keep_bang_comments = ( + '-b' in _sys.argv[1:] + or '-bp' in _sys.argv[1:] + or '-pb' in _sys.argv[1:] + ) + if '-p' in _sys.argv[1:] or '-bp' in _sys.argv[1:] \ + or '-pb' in _sys.argv[1:]: + global cssmin # pylint: disable = W0603 + cssmin = _make_cssmin(python_only=True) + _sys.stdout.write(cssmin( + _sys.stdin.read(), keep_bang_comments=keep_bang_comments + )) + main() diff --git a/ckan/include/rjsmin.py b/ckan/include/rjsmin.py new file mode 100644 index 00000000000..13e66e0e421 --- /dev/null +++ b/ckan/include/rjsmin.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python +# -*- coding: ascii -*- +# +# Copyright 2011, 2012 +# Andr\xe9 Malo or his licensors, as applicable +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +r""" +===================== + Javascript Minifier +===================== + +rJSmin is a javascript minifier written in python. + +The minifier is based on the semantics of `jsmin.c by Douglas Crockford`_\. + +The module is a re-implementation aiming for speed, so it can be used at +runtime (rather than during a preprocessing step). Usually it produces the +same results as the original ``jsmin.c``. It differs in the following ways: + +- there is no error detection: unterminated string, regex and comment + literals are treated as regular javascript code and minified as such. +- Control characters inside string and regex literals are left untouched; they + are not converted to spaces (nor to \n) +- Newline characters are not allowed inside string and regex literals, except + for line continuations in string literals (ECMA-5). +- "return /regex/" is recognized correctly. +- "+ ++" and "- --" sequences are not collapsed to '+++' or '---' +- rJSmin does not handle streams, but only complete strings. (However, the + module provides a "streamy" interface). + +Since most parts of the logic are handled by the regex engine it's way +faster than the original python port of ``jsmin.c`` by Baruch Even. The speed +factor varies between about 6 and 55 depending on input and python version +(it gets faster the more compressed the input already is). Compared to the +speed-refactored python port by Dave St.Germain the performance gain is less +dramatic but still between 1.2 and 7. See the docs/BENCHMARKS file for +details. + +rjsmin.c is a reimplementation of rjsmin.py in C and speeds it up even more. + +Both python 2 and python 3 are supported. + +.. _jsmin.c by Douglas Crockford: + http://www.crockford.com/javascript/jsmin.c +""" +__author__ = "Andr\xe9 Malo" +__author__ = getattr(__author__, 'decode', lambda x: __author__)('latin-1') +__docformat__ = "restructuredtext en" +__license__ = "Apache License, Version 2.0" +__version__ = '1.0.3' +__all__ = ['jsmin'] + +import re as _re + + +def _make_jsmin(python_only=False): + """ + Generate JS minifier based on `jsmin.c by Douglas Crockford`_ + + .. _jsmin.c by Douglas Crockford: + http://www.crockford.com/javascript/jsmin.c + + :Parameters: + `python_only` : ``bool`` + Use only the python variant. If true, the c extension is not even + tried to be loaded. + + :Return: Minifier + :Rtype: ``callable`` + """ + # pylint: disable = R0912, R0914, W0612 + if not python_only: + try: + import _rjsmin + except ImportError: + pass + else: + return _rjsmin.jsmin + try: + xrange + except NameError: + xrange = range # pylint: disable = W0622 + + space_chars = r'[\000-\011\013\014\016-\040]' + + line_comment = r'(?://[^\r\n]*)' + space_comment = r'(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)' + string1 = \ + r'(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|\r)[^\047\\\r\n]*)*\047)' + string2 = r'(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|\r)[^"\\\r\n]*)*")' + strings = r'(?:%s|%s)' % (string1, string2) + + charclass = r'(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]*)*\])' + nospecial = r'[^/\\\[\r\n]' + regex = r'(?:/(?![\r\n/*])%s*(?:(?:\\[^\r\n]|%s)%s*)*/)' % ( + nospecial, charclass, nospecial + ) + space = r'(?:%s|%s)' % (space_chars, space_comment) + newline = r'(?:%s?[\r\n])' % line_comment + + def fix_charclass(result): + """ Fixup string of chars to fit into a regex char class """ + pos = result.find('-') + if pos >= 0: + result = r'%s%s-' % (result[:pos], result[pos + 1:]) + + def sequentize(string): + """ + Notate consecutive characters as sequence + + (1-4 instead of 1234) + """ + first, last, result = None, None, [] + for char in map(ord, string): + if last is None: + first = last = char + elif last + 1 == char: + last = char + else: + result.append((first, last)) + first = last = char + if last is not None: + result.append((first, last)) + return ''.join(['%s%s%s' % ( + chr(first), + last > first + 1 and '-' or '', + last != first and chr(last) or '' + ) for first, last in result]) + + return _re.sub(r'([\000-\040\047])', # for better portability + lambda m: '\\%03o' % ord(m.group(1)), (sequentize(result) + .replace('\\', '\\\\') + .replace('[', '\\[') + .replace(']', '\\]') + ) + ) + + def id_literal_(what): + """ Make id_literal like char class """ + match = _re.compile(what).match + result = ''.join([ + chr(c) for c in xrange(127) if not match(chr(c)) + ]) + return '[^%s]' % fix_charclass(result) + + def not_id_literal_(keep): + """ Make negated id_literal like char class """ + match = _re.compile(id_literal_(keep)).match + result = ''.join([ + chr(c) for c in xrange(127) if not match(chr(c)) + ]) + return r'[%s]' % fix_charclass(result) + + not_id_literal = not_id_literal_(r'[a-zA-Z0-9_$]') + preregex1 = r'[(,=:\[!&|?{};\r\n]' + preregex2 = r'%(not_id_literal)sreturn' % locals() + + id_literal = id_literal_(r'[a-zA-Z0-9_$]') + id_literal_open = id_literal_(r'[a-zA-Z0-9_${\[(+-]') + id_literal_close = id_literal_(r'[a-zA-Z0-9_$}\])"\047+-]') + + space_sub = _re.compile(( + r'([^\047"/\000-\040]+)' + r'|(%(strings)s[^\047"/\000-\040]*)' + r'|(?:(?<=%(preregex1)s)%(space)s*(%(regex)s[^\047"/\000-\040]*))' + r'|(?:(?<=%(preregex2)s)%(space)s*(%(regex)s[^\047"/\000-\040]*))' + r'|(?<=%(id_literal_close)s)' + r'%(space)s*(?:(%(newline)s)%(space)s*)+' + r'(?=%(id_literal_open)s)' + r'|(?<=%(id_literal)s)(%(space)s)+(?=%(id_literal)s)' + r'|(?<=\+)(%(space)s)+(?=\+\+)' + r'|(?<=-)(%(space)s)+(?=--)' + r'|%(space)s+' + r'|(?:%(newline)s%(space)s*)+' + ) % locals()).sub + #print space_sub.__self__.pattern + + def space_subber(match): + """ Substitution callback """ + # pylint: disable = C0321, R0911 + groups = match.groups() + if groups[0]: return groups[0] + elif groups[1]: return groups[1] + elif groups[2]: return groups[2] + elif groups[3]: return groups[3] + elif groups[4]: return '\n' + elif groups[5] or groups[6] or groups[7]: return ' ' + else: return '' + + def jsmin(script): # pylint: disable = W0621 + r""" + Minify javascript based on `jsmin.c by Douglas Crockford`_\. + + Instead of parsing the stream char by char, it uses a regular + expression approach which minifies the whole script with one big + substitution regex. + + .. _jsmin.c by Douglas Crockford: + http://www.crockford.com/javascript/jsmin.c + + :Parameters: + `script` : ``str`` + Script to minify + + :Return: Minified script + :Rtype: ``str`` + """ + return space_sub(space_subber, '\n%s\n' % script).strip() + + return jsmin + +jsmin = _make_jsmin() + + +def jsmin_for_posers(script): + r""" + Minify javascript based on `jsmin.c by Douglas Crockford`_\. + + Instead of parsing the stream char by char, it uses a regular + expression approach which minifies the whole script with one big + substitution regex. + + .. _jsmin.c by Douglas Crockford: + http://www.crockford.com/javascript/jsmin.c + + :Warning: This function is the digest of a _make_jsmin() call. It just + utilizes the resulting regex. It's just for fun here and may + vanish any time. Use the `jsmin` function instead. + + :Parameters: + `script` : ``str`` + Script to minify + + :Return: Minified script + :Rtype: ``str`` + """ + def subber(match): + """ Substitution callback """ + groups = match.groups() + return ( + groups[0] or + groups[1] or + groups[2] or + groups[3] or + (groups[4] and '\n') or + (groups[5] and ' ') or + (groups[6] and ' ') or + (groups[7] and ' ') or + '' + ) + + return _re.sub( + r'([^\047"/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^\r\n]|\r?' + r'\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^\r\n]|\r?\n|' + r'\r)[^"\\\r\n]*)*"))[^\047"/\000-\040]*)|(?:(?<=[(,=:\[!&|?{};\r\n]' + r')(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/' + r'))*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*' + r'(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*' + r'))|(?:(?<=[\000-#%-,./:-@\[-^`{-~-]return)(?:[\000-\011\013\014\01' + r'6-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*((?:/(?![\r\n/*])[^/' + r'\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]' + r'*)*\]))[^/\\\[\r\n]*)*/)[^\047"/\000-\040]*))|(?<=[^\000-!#%&(*,./' + r':-@\[\\^`{|~])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/' + r'*][^*]*\*+)*/))*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\01' + r'4\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-#%-\04' + r'7)*,./:-@\\-^`|-~])|(?<=[^\000-#%-,./:-@\[-^`{-~-])((?:[\000-\011' + r'\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=[^\000-' + r'#%-,./:-@\[-^`{-~-])|(?<=\+)((?:[\000-\011\013\014\016-\040]|(?:/' + r'\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=\+\+)|(?<=-)((?:[\000-\011\013' + r'\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=--)|(?:[\00' + r'0-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))+|(?:(' + r'?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]' + r'*\*+(?:[^/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script + ).strip() + + +if __name__ == '__main__': + import sys as _sys + _sys.stdout.write(jsmin(_sys.stdin.read())) diff --git a/ckan/lib/app_globals.py b/ckan/lib/app_globals.py index b508353e7bc..92fd5c9469e 100644 --- a/ckan/lib/app_globals.py +++ b/ckan/lib/app_globals.py @@ -1,32 +1,148 @@ -"""The application's Globals object""" +''' The application's Globals object ''' + +import logging +import time +from threading import Lock from paste.deploy.converters import asbool from pylons import config -class Globals(object): +import ckan.model as model + +log = logging.getLogger(__name__) + + +# mappings translate between config settings and globals because our naming +# conventions are not well defined and/or implemented +mappings = { +# 'config_key': 'globals_key', +} + +# these config settings will get updated from system_info +auto_update = [ + 'ckan.site_title', + 'ckan.site_logo', + 'ckan.site_url', + 'ckan.site_description', + 'ckan.site_about', + 'ckan.site_intro_text', + 'ckan.site_custom_css', +] + +# A place to store the origional config options of we override them +_CONFIG_CACHE = {} + +def set_main_css(css_file): + ''' Sets the main_css using debug css if needed. The css_file + must be of the form file.css ''' + assert css_file.endswith('.css') + if config.debug and css_file == '/base/css/main.css': + new_css = '/base/css/main.debug.css' + else: + new_css = css_file + # FIXME we should check the css file exists + app_globals.main_css = str(new_css) + + +def set_global(key, value): + ''' helper function for getting value from database or config file ''' + model.set_system_info(key, value) + setattr(app_globals, get_globals_key(key), value) + model.set_system_info('ckan.config_update', str(time.time())) + # update the config + config[key] = value + log.info('config `%s` set to `%s`' % (key, value)) + +def delete_global(key): + model.delete_system_info(key) + log.info('config `%s` deleted' % (key)) + +def get_globals_key(key): + # create our globals key + # these can be specified in mappings or else we remove + # the `ckan.` part this is to keep the existing namings + # set the value + if key in mappings: + return mappings[key] + elif key.startswith('ckan.'): + return key[5:] - """Globals acts as a container for objects available throughout the - life of the application +def reset(): + ''' set updatable values from config ''' + def get_config_value(key, default=''): + if model.meta.engine.has_table('system_info'): + value = model.get_system_info(key) + else: + value = None + # we want to store the config the first time we get here so we can + # reset them if needed + config_value = config.get(key) + if key not in _CONFIG_CACHE: + _CONFIG_CACHE[key] = config_value + if value is not None: + log.debug('config `%s` set to `%s` from db' % (key, value)) + else: + value = _CONFIG_CACHE[key] + if value: + log.debug('config `%s` set to `%s` from config' % (key, value)) + else: + value = default + setattr(app_globals, get_globals_key(key), value) + # update the config + config[key] = value + return value - """ + # update the config settings in auto update + for key in auto_update: + get_config_value(key) + + # cusom styling + main_css = get_config_value('ckan.main_css', '/base/css/main.css') + set_main_css(main_css) + # site_url_nice + site_url_nice = app_globals.site_url.replace('http://', '') + site_url_nice = site_url_nice.replace('www.', '') + app_globals.site_url_nice = site_url_nice + + if app_globals.site_logo: + app_globals.header_class = 'header-image' + elif not app_globals.site_description: + app_globals.header_class = 'header-text-logo' + else: + app_globals.header_class = 'header-text-logo-tagline' + + + + +class _Globals(object): + + ''' Globals acts as a container for objects available throughout the + life of the application. ''' def __init__(self): - """One instance of Globals is created during application + '''One instance of Globals is created during application initialization and is available during requests via the 'app_globals' variable + ''' + self._init() + self._config_update = None + self._mutex = Lock() + + def _check_uptodate(self): + ''' check the config is uptodate needed when several instances are + running ''' + value = model.get_system_info('ckan.config_update') + if self._config_update != value: + if self._mutex.acquire(False): + reset() + self._config_update = value + self._mutex.release() + + def _init(self): + self.favicon = config.get('ckan.favicon', '/images/icons/ckan.ico') + facets = config.get('search.facets', 'groups tags res_format license') + self.facets = facets.split() - """ - self.site_title = config.get('ckan.site_title', '') - self.favicon = config.get('ckan.favicon', - '/images/icons/ckan.ico') - self.site_logo = config.get('ckan.site_logo', '') - self.site_url = config.get('ckan.site_url', '') - self.site_url_nice = self.site_url.replace('http://','').replace('www.','') - self.site_description = config.get('ckan.site_description', '') - self.site_about = config.get('ckan.site_about', '') - - self.facets = config.get('search.facets', 'groups tags res_format license').split() - # has been setup in load_environment(): self.site_id = config.get('ckan.site_id') @@ -34,11 +150,16 @@ def __init__(self): self.template_footer_end = config.get('ckan.template_footer_end', '') # hide these extras fields on package read - self.package_hide_extras = config.get('package_hide_extras', '').split() + package_hide_extras = config.get('package_hide_extras', '').split() + self.package_hide_extras = package_hide_extras self.openid_enabled = asbool(config.get('openid_enabled', 'true')) self.recaptcha_publickey = config.get('ckan.recaptcha.publickey', '') self.recaptcha_privatekey = config.get('ckan.recaptcha.privatekey', '') - - self.datasets_per_page = int(config.get('ckan.datasets_per_page', '20')) + + datasets_per_page = int(config.get('ckan.datasets_per_page', '20')) + self.datasets_per_page = datasets_per_page + +app_globals = _Globals() +del _Globals diff --git a/ckan/lib/base.py b/ckan/lib/base.py index c9872eea322..edc4264e2f1 100644 --- a/ckan/lib/base.py +++ b/ckan/lib/base.py @@ -7,6 +7,7 @@ import logging import os import urllib +import time from paste.deploy.converters import asbool from pylons import c, cache, config, g, request, response, session @@ -14,9 +15,10 @@ from pylons.controllers.util import abort as _abort from pylons.controllers.util import redirect_to, redirect from pylons.decorators import jsonify, validate -from pylons.i18n import _, ungettext, N_, gettext +from pylons.i18n import _, ungettext, N_, gettext, ngettext from pylons.templating import cached_template, pylons_globals from genshi.template import MarkupTemplate +from genshi.template.base import TemplateSyntaxError from genshi.template.text import NewTextTemplate from webhelpers.html import literal @@ -24,7 +26,9 @@ import ckan import ckan.authz as authz from ckan.lib import i18n +import lib.render import ckan.lib.helpers as h +import ckan.lib.app_globals as app_globals from ckan.plugins import PluginImplementations, IGenshiStreamFilter from ckan.lib.helpers import json import ckan.model as model @@ -58,7 +62,8 @@ def render_snippet(template_name, **kw): the extra template variables. ''' # allow cache_force to be set in render function cache_force = kw.pop('cache_force', None) - output = render(template_name, extra_vars=kw, cache_force=cache_force) + output = render(template_name, extra_vars=kw, cache_force=cache_force, + renderer='snippet') output = '\n\n%s\n\n' % ( template_name, output, template_name) return literal(output) @@ -74,23 +79,59 @@ def render_text(template_name, extra_vars=None, cache_force=None): loader_class=NewTextTemplate) +def render_jinja2(template_name, extra_vars): + env = config['pylons.app_globals'].jinja_env + template = env.get_template(template_name) + return template.render(**extra_vars) + + def render(template_name, extra_vars=None, cache_key=None, cache_type=None, cache_expire=None, method='xhtml', loader_class=MarkupTemplate, - cache_force=None): - ''' Main genshi template rendering function. ''' + cache_force = None, renderer=None): + ''' Main template rendering function. ''' def render_template(): globs = extra_vars or {} globs.update(pylons_globals()) globs['actions'] = model.Action - # add the template name to the context to help us know where we are - # used in depreciating functions etc - c.__template_name = template_name # Using pylons.url() directly destroys the localisation stuff so # we remove it so any bad templates crash and burn del globs['url'] + try: + template_path, template_type = lib.render.template_info(template_name) + except lib.render.TemplateNotFound: + template_type = 'genshi' + template_path = '' + + # snippets should not pass the context + # but allow for legacy genshi templates + if renderer == 'snippet' and template_type != 'genshi': + del globs['c'] + del globs['tmpl_context'] + + log.debug('rendering %s [%s]' % (template_path, template_type)) + if config.get('debug'): + context_vars = globs.get('c') + if context_vars: + context_vars = dir(context_vars) + debug_info = {'template_name' : template_name, + 'template_path' : template_path, + 'template_type' : template_type, + 'vars' : globs, + 'c_vars': context_vars, + 'renderer' : renderer,} + if 'CKAN_DEBUG_INFO' not in request.environ: + request.environ['CKAN_DEBUG_INFO'] = [] + request.environ['CKAN_DEBUG_INFO'].append(debug_info) + + # Jinja2 templates + if template_type == 'jinja2': + # TODO should we raise error if genshi filters?? + return render_jinja2(template_name, globs) + + # Genshi templates template = globs['app_globals'].genshi_loader.load(template_name, cls=loader_class) stream = template.generate(**globs) @@ -167,7 +208,9 @@ class BaseController(WSGIController): log = logging.getLogger(__name__) def __before__(self, action, **params): + c.__timer = time.time() c.__version__ = ckan.__version__ + app_globals.app_globals._check_uptodate() self._identify_user() i18n.handle_request(request, c) @@ -274,6 +317,9 @@ def __call__(self, environ, start_response): def __after__(self, action, **params): self._set_cors() + r_time = time.time() - c.__timer + url = request.environ['CKAN_CURRENT_URL'].split('?')[0] + log.info(' %s render time %.3f seconds' % (url, r_time)) def _set_cors(self): response.headers['Access-Control-Allow-Origin'] = "*" diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py index c20296b3517..f91de5b5484 100644 --- a/ckan/lib/cli.py +++ b/ckan/lib/cli.py @@ -1230,3 +1230,416 @@ def profile_url(url): profile_command = "profile_url('%s')" % url cProfile.runctx(profile_command, globals(), locals(), filename=output_filename) print 'Written profile to: %s' % output_filename + + +class CreateColorSchemeCommand(CkanCommand): + ''' Create or remove a color scheme. + + less will need to generate the css files after this has been run + + color - creates a random color scheme + color clear - clears any color scheme + color '' - uses as base color eg '#ff00ff' must be quoted. + color - a float between 0.0 and 1.0 used as base hue + color - html color name used for base color eg lightblue + ''' + + summary = __doc__.split('\n')[0] + usage = __doc__ + max_args = 1 + min_args = 0 + + rules = [ + '@layoutLinkColor', + '@mastheadBackgroundColorStart', + '@mastheadBackgroundColorEnd', + '@btnPrimaryBackground', + '@btnPrimaryBackgroundHighlight', + ] + + # list of predefined colors + color_list = { + 'aliceblue': '#f0fff8', + 'antiquewhite': '#faebd7', + 'aqua': '#00ffff', + 'aquamarine': '#7fffd4', + 'azure': '#f0ffff', + 'beige': '#f5f5dc', + 'bisque': '#ffe4c4', + 'black': '#000000', + 'blanchedalmond': '#ffebcd', + 'blue': '#0000ff', + 'blueviolet': '#8a2be2', + 'brown': '#a52a2a', + 'burlywood': '#deb887', + 'cadetblue': '#5f9ea0', + 'chartreuse': '#7fff00', + 'chocolate': '#d2691e', + 'coral': '#ff7f50', + 'cornflowerblue': '#6495ed', + 'cornsilk': '#fff8dc', + 'crimson': '#dc143c', + 'cyan': '#00ffff', + 'darkblue': '#00008b', + 'darkcyan': '#008b8b', + 'darkgoldenrod': '#b8860b', + 'darkgray': '#a9a9a9', + 'darkgrey': '#a9a9a9', + 'darkgreen': '#006400', + 'darkkhaki': '#bdb76b', + 'darkmagenta': '#8b008b', + 'darkolivegreen': '#556b2f', + 'darkorange': '#ff8c00', + 'darkorchid': '#9932cc', + 'darkred': '#8b0000', + 'darksalmon': '#e9967a', + 'darkseagreen': '#8fbc8f', + 'darkslateblue': '#483d8b', + 'darkslategray': '#2f4f4f', + 'darkslategrey': '#2f4f4f', + 'darkturquoise': '#00ced1', + 'darkviolet': '#9400d3', + 'deeppink': '#ff1493', + 'deepskyblue': '#00bfff', + 'dimgray': '#696969', + 'dimgrey': '#696969', + 'dodgerblue': '#1e90ff', + 'firebrick': '#b22222', + 'floralwhite': '#fffaf0', + 'forestgreen': '#228b22', + 'fuchsia': '#ff00ff', + 'gainsboro': '#dcdcdc', + 'ghostwhite': '#f8f8ff', + 'gold': '#ffd700', + 'goldenrod': '#daa520', + 'gray': '#808080', + 'grey': '#808080', + 'green': '#008000', + 'greenyellow': '#adff2f', + 'honeydew': '#f0fff0', + 'hotpink': '#ff69b4', + 'indianred ': '#cd5c5c', + 'indigo ': '#4b0082', + 'ivory': '#fffff0', + 'khaki': '#f0e68c', + 'lavender': '#e6e6fa', + 'lavenderblush': '#fff0f5', + 'lawngreen': '#7cfc00', + 'lemonchiffon': '#fffacd', + 'lightblue': '#add8e6', + 'lightcoral': '#f08080', + 'lightcyan': '#e0ffff', + 'lightgoldenrodyellow': '#fafad2', + 'lightgray': '#d3d3d3', + 'lightgrey': '#d3d3d3', + 'lightgreen': '#90ee90', + 'lightpink': '#ffb6c1', + 'lightsalmon': '#ffa07a', + 'lightseagreen': '#20b2aa', + 'lightskyblue': '#87cefa', + 'lightslategray': '#778899', + 'lightslategrey': '#778899', + 'lightsteelblue': '#b0c4de', + 'lightyellow': '#ffffe0', + 'lime': '#00ff00', + 'limegreen': '#32cd32', + 'linen': '#faf0e6', + 'magenta': '#ff00ff', + 'maroon': '#800000', + 'mediumaquamarine': '#66cdaa', + 'mediumblue': '#0000cd', + 'mediumorchid': '#ba55d3', + 'mediumpurple': '#9370d8', + 'mediumseagreen': '#3cb371', + 'mediumslateblue': '#7b68ee', + 'mediumspringgreen': '#00fa9a', + 'mediumturquoise': '#48d1cc', + 'mediumvioletred': '#c71585', + 'midnightblue': '#191970', + 'mintcream': '#f5fffa', + 'mistyrose': '#ffe4e1', + 'moccasin': '#ffe4b5', + 'navajowhite': '#ffdead', + 'navy': '#000080', + 'oldlace': '#fdf5e6', + 'olive': '#808000', + 'olivedrab': '#6b8e23', + 'orange': '#ffa500', + 'orangered': '#ff4500', + 'orchid': '#da70d6', + 'palegoldenrod': '#eee8aa', + 'palegreen': '#98fb98', + 'paleturquoise': '#afeeee', + 'palevioletred': '#d87093', + 'papayawhip': '#ffefd5', + 'peachpuff': '#ffdab9', + 'peru': '#cd853f', + 'pink': '#ffc0cb', + 'plum': '#dda0dd', + 'powderblue': '#b0e0e6', + 'purple': '#800080', + 'red': '#ff0000', + 'rosybrown': '#bc8f8f', + 'royalblue': '#4169e1', + 'saddlebrown': '#8b4513', + 'salmon': '#fa8072', + 'sandybrown': '#f4a460', + 'seagreen': '#2e8b57', + 'seashell': '#fff5ee', + 'sienna': '#a0522d', + 'silver': '#c0c0c0', + 'skyblue': '#87ceeb', + 'slateblue': '#6a5acd', + 'slategray': '#708090', + 'slategrey': '#708090', + 'snow': '#fffafa', + 'springgreen': '#00ff7f', + 'steelblue': '#4682b4', + 'tan': '#d2b48c', + 'teal': '#008080', + 'thistle': '#d8bfd8', + 'tomato': '#ff6347', + 'turquoise': '#40e0d0', + 'violet': '#ee82ee', + 'wheat': '#f5deb3', + 'white': '#ffffff', + 'whitesmoke': '#f5f5f5', + 'yellow': '#ffff00', + 'yellowgreen': '#9acd32', + } + + def create_colors(self, hue, num_colors=5, saturation=None, lightness=None): + if saturation is None: + saturation = 0.9 + if lightness is None: + lightness = 40 + else: + lightness *= 100 + + import math + saturation -= math.trunc(saturation) + + print hue, saturation + import colorsys + ''' Create n related colours ''' + colors=[] + for i in xrange(num_colors): + ix = i * (1.0/num_colors) + _lightness = (lightness + (ix * 40))/100. + if _lightness > 1.0: + _lightness = 1.0 + color = colorsys.hls_to_rgb(hue, _lightness, saturation) + hex_color = '#' + for part in color: + hex_color += '%02x' % int(part * 255) + # check and remove any bad values + if not re.match('^\#[0-9a-f]{6}$', hex_color): + hex_color='#FFFFFF' + colors.append(hex_color) + return colors + + def command(self): + + hue = None + saturation = None + lightness = None + + path = os.path.dirname(__file__) + path = os.path.join(path, '..', 'public', 'base', 'less', 'custom.less') + + if self.args: + arg = self.args[0] + rgb = None + if arg == 'clear': + os.remove(path) + print 'custom colors removed.' + elif arg.startswith('#'): + color = arg[1:] + if len(color) == 3: + rgb = [int(x, 16) * 16 for x in color] + elif len(color) == 6: + rgb = [int(x, 16) for x in re.findall('..', color)] + else: + print 'ERROR: invalid color' + elif arg.lower() in self.color_list: + color = self.color_list[arg.lower()][1:] + rgb = [int(x, 16) for x in re.findall('..', color)] + else: + try: + hue = float(self.args[0]) + except ValueError: + print 'ERROR argument `%s` not recognised' % arg + if rgb: + import colorsys + hue, lightness, saturation = colorsys.rgb_to_hls(*rgb) + lightness = lightness / 340 + # deal with greys + if not (hue == 0.0 and saturation == 0.0): + saturation = None + else: + import random + hue = random.random() + if hue is not None: + f = open(path, 'w') + colors = self.create_colors(hue, saturation=saturation, lightness=lightness) + for i in xrange(len(self.rules)): + f.write('%s: %s;\n' % (self.rules[i], colors[i])) + print '%s: %s;\n' % (self.rules[i], colors[i]) + f.close + print 'Color scheme has been created.' + print 'Make sure less is run for changes to take effect.' + + +class TranslationsCommand(CkanCommand): + '''Translation helper functions + + trans js - generate the javascript translations + trans mangle - mangle the zh_TW translations for testing + ''' + + summary = __doc__.split('\n')[0] + usage = __doc__ + max_args = 1 + min_args = 1 + + def command(self): + self._load_config() + from pylons import config + self.ckan_path = os.path.join(os.path.dirname(__file__), '..') + i18n_path = os.path.join(self.ckan_path, 'i18n') + self.i18n_path = config.get('ckan.i18n_directory', i18n_path) + command = self.args[0] + if command == 'mangle': + self.mangle_po() + elif command == 'js': + self.build_js_translations() + else: + print 'command not recognised' + + + def po2dict(self, po, lang): + '''Convert po object to dictionary data structure (ready for JSON). + + This function is from pojson + https://bitbucket.org/obviel/pojson + +Copyright (c) 2010, Fanstatic Developers +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL FANSTATIC DEVELOPERS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +''' + result = {} + + result[''] = {} + result['']['plural-forms'] = po.metadata['Plural-Forms'] + result['']['lang'] = lang + result['']['domain'] = 'ckan' + + for entry in po: + if entry.obsolete: + continue + # check if used in js file we only include these + occurrences = entry.occurrences + js_use = False + for occurrence in occurrences: + if occurrence[0].endswith('.js'): + js_use = True + continue + if not js_use: + continue + if entry.msgstr: + result[entry.msgid] = [None, entry.msgstr] + elif entry.msgstr_plural: + plural = [entry.msgid_plural] + result[entry.msgid] = plural + ordered_plural = sorted(entry.msgstr_plural.items()) + for order, msgstr in ordered_plural: + plural.append(msgstr) + return result + + def build_js_translations(self): + import polib + import simplejson as json + + def create_js(source, lang): + print 'Generating', lang + po = polib.pofile(source) + data = self.po2dict(po, lang) + data = json.dumps(data, sort_keys=True, + ensure_ascii=False, indent=2 * ' ') + out_dir = os.path.abspath(os.path.join(self.ckan_path, 'public', + 'base', 'i18n')) + out_file = open(os.path.join(out_dir, '%s.js' % lang), 'w') + out_file.write(data.encode('utf-8')) + out_file.close() + + for l in os.listdir(self.i18n_path): + if os.path.isdir(os.path.join(self.i18n_path, l)): + f = os.path.join(self.i18n_path, l, 'LC_MESSAGES', 'ckan.po') + create_js(f, l) + print 'Completed generating JavaScript translations' + + def mangle_po(self): + ''' This will mangle the zh_TW translations for translation coverage + testing. + + NOTE: This will destroy the current translations fot zh_TW + ''' + import polib + pot_path = os.path.join(self.i18n_path, 'ckan.pot') + po = polib.pofile(pot_path) + # we don't want to mangle the following items in strings + # %(...)s %s %0.3f %1$s %2$0.3f [1:...] {...} etc + + # sprintf bit after % + spf_reg_ex = "\+?(0|'.)?-?\d*(.\d*)?[\%bcdeufosxX]" + + extract_reg_ex = '(\%\([^\)]*\)' + spf_reg_ex + \ + '|\[\d*\:[^\]]*\]' + \ + '|\{[^\}]*\}' + \ + '|<[^>}]*>' + \ + '|\%((\d)*\$)?' + spf_reg_ex + ')' + + for entry in po: + msg = entry.msgid.encode('utf-8') + matches = re.finditer(extract_reg_ex, msg) + length = len(msg) + position = 0 + translation = u'' + for match in matches: + translation += '-' * (match.start() - position) + position = match.end() + translation += match.group(0) + translation += '-' * (length - position) + entry.msgstr = translation + out_dir = os.path.join(self.i18n_path, 'zh_TW', 'LC_MESSAGES') + try: + os.makedirs(out_dir) + except OSError: + pass + po.metadata['Plural-Forms'] = "nplurals=1; plural=0\n" + out_po = os.path.join(out_dir, 'ckan.po') + out_mo = os.path.join(out_dir, 'ckan.mo') + po.save(out_po) + po.save_as_mofile(out_mo) + print 'zh_TW has been mangled' diff --git a/ckan/lib/dictization/model_dictize.py b/ckan/lib/dictization/model_dictize.py index cda8b7f69c9..97869588391 100644 --- a/ckan/lib/dictization/model_dictize.py +++ b/ckan/lib/dictization/model_dictize.py @@ -93,6 +93,31 @@ def extras_list_dictize(extras_list, context): return sorted(result_list, key=lambda x: x["key"]) +def _unified_resource_format(format_): + ''' Convert resource formats into a more uniform set. + eg .json, json, JSON, text/json all converted to JSON.''' + + format_clean = format_.lower().split('/')[-1].replace('.', '') + formats = { + 'csv' : 'CSV', + 'zip' : 'ZIP', + 'pdf' : 'PDF', + 'xls' : 'XLS', + 'json' : 'JSON', + 'kml' : 'KML', + 'xml' : 'XML', + 'shape' : 'SHAPE', + 'rdf' : 'RDF', + 'txt' : 'TXT', + 'text' : 'TEXT', + 'html' : 'HTML', + } + if format_clean in formats: + format_new = formats[format_clean] + else: + format_new = format_.lower() + return format_new + def resource_dictize(res, context): resource = d.table_dictize(res, context) extras = resource.pop("extras", None) @@ -103,6 +128,7 @@ def resource_dictize(res, context): model = context['model'] tracking = model.TrackingSummary.get_for_resource(res.url) resource['tracking_summary'] = tracking + resource['format'] = _unified_resource_format(res.format) # some urls do not have the protocol this adds http:// to these url = resource['url'] if not (url.startswith('http://') or url.startswith('https://')): diff --git a/ckan/lib/dictization/model_save.py b/ckan/lib/dictization/model_save.py index 6d39733c2a9..d19ad918dee 100644 --- a/ckan/lib/dictization/model_save.py +++ b/ckan/lib/dictization/model_save.py @@ -37,6 +37,12 @@ def resource_dict_save(res_dict, context): continue if key == 'url' and not new and obj.url <> value: obj.url_changed = True + # this is an internal field so ignore + # FIXME This helps get the tests to pass but is a hack and should + # be fixed properly. basically don't update the format if not needed + if (key == 'format' and value == obj.format + or value == d.model_dictize._unified_resource_format(obj.format)): + continue setattr(obj, key, value) else: # resources save extras directly onto the object, instead diff --git a/ckan/lib/extract.py b/ckan/lib/extract.py new file mode 100644 index 00000000000..89675a394a5 --- /dev/null +++ b/ckan/lib/extract.py @@ -0,0 +1,51 @@ +import re +from genshi.filters.i18n import extract as extract_genshi +from jinja2.ext import babel_extract as extract_jinja2 +import lib.jinja_extensions + +jinja_extensions = ''' + jinja2.ext.do, jinja2.ext.with_, + ckan.lib.jinja_extensions.SnippetExtension, + ckan.lib.jinja_extensions.CkanExtend, + ckan.lib.jinja_extensions.LinkForExtension, + ckan.lib.jinja_extensions.ResourceExtension, + ckan.lib.jinja_extensions.UrlForStaticExtension, + ckan.lib.jinja_extensions.UrlForExtension + ''' + +def jinja2_cleaner(fileobj, *args, **kw): + # We want to format the messages correctly and intercepting here seems + # the best location + # add our custom tags + kw['options']['extensions'] = jinja_extensions + + raw_extract = extract_jinja2(fileobj, *args, **kw) + + for lineno, func, message, finder in raw_extract: + + if isinstance(message, basestring): + message = lib.jinja_extensions.regularise_html(message) + elif message is not None: + message = (lib.jinja_extensions.regularise_html(message[0]) + ,lib.jinja_extensions.regularise_html(message[1])) + + yield lineno, func, message, finder + + +def extract_ckan(fileobj, *args, **kw): + ''' Determine the type of file (Genshi or Jinja2) and then call the + correct extractor function. + + Basically we just look for genshi.edgewall.org which all genshi XML + templates should contain. ''' + + source = fileobj.read() + if re.search('genshi\.edgewall\.org', source): + # genshi + output = extract_genshi(fileobj, *args, **kw) + else: + # jinja2 + output = jinja2_cleaner(fileobj, *args, **kw) + # we've eaten the file so we need to get back to the start + fileobj.seek(0) + return output diff --git a/ckan/lib/fanstatic_extensions.py b/ckan/lib/fanstatic_extensions.py new file mode 100644 index 00000000000..5bf5f016071 --- /dev/null +++ b/ckan/lib/fanstatic_extensions.py @@ -0,0 +1,114 @@ +import fanstatic.core as core + + +class CkanCustomRenderer(object): + ''' Allows for in-line js and IE conditionals via fanstatic. ''' + def __init__(self, script=None, renderer=None, condition=None, + other_browsers=False): + self.script = script + self.other_browsers = other_browsers + self.renderer = renderer + start = '' + end = '' + # IE conditionals + if condition: + start = '' + if other_browsers: + start += '' + end = ' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ckan/public/base/test/primer/index.html b/ckan/public/base/test/primer/index.html new file mode 100644 index 00000000000..0ce2ad5e5bc --- /dev/null +++ b/ckan/public/base/test/primer/index.html @@ -0,0 +1,647 @@ + + + + + CKAN Primer + + + + + +
+

CKAN CSS Primer

+
+

This is an empty module

+
+
+

Module Heading

+
+

This is an example module with a heading and stuff

+
+
+
+

Module Heading

+ +
+
+

Module Heading

+ + +
+
+

General Prose

+
+

Sections Linked

+

The main page header of this guide is an h1 element. Any header elements may include links, as depicted in the example.

+

The secondary header above is an h2 element, which may be used for any form of important page-level header. More than one may be used per page. Consider using an h2 unless you need a header level of less importance, or as a sub-header to an existing h2 element.

+

Third-Level Header Linked

+

The header above is an h3 element, which may be used for any form of page-level header which falls below the h2 header in a document hierarchy.

+

Fourth-Level Header Linked

+

The header above is an h4 element, which may be used for any form of page-level header which falls below the h3 header in a document hierarchy.

+
Fifth-Level Header Linked
+

The header above is an h5 element, which may be used for any form of page-level header which falls below the h4 header in a document hierarchy.

+
Sixth-Level Header Linked
+

The header above is an h6 element, which may be used for any form of page-level header which falls below the h5 header in a document hierarchy.

+ +

Grouping content

+

Paragraphs

+

All paragraphs are wrapped in p tags. Additionally, p elements can be wrapped with a blockquote element if the p element is indeed a quote. Historically, blockquote has been used purely to force indents, but this is now achieved using CSS. Reserve blockquote for quotes.

+ +

Horizontal rule

+

The hr element represents a paragraph-level thematic break, e.g. a scene change in a story, or a transition to another topic within a section of a reference book. The following extract from Pandora’s Star by Peter F. Hamilton shows two paragraphs that precede a scene change and the paragraph that follows it:

+
+

Dudley was ninety-two, in his second life, and fast approaching time for another rejuvenation. Despite his body having the physical age of a standard fifty-year-old, the prospect of a long degrading campaign within academia was one he regarded with dread. For a supposedly advanced civilization, the Intersolar Commonwearth could be appallingly backward at times, not to mention cruel.

+

Maybe it won’t be that bad, he told himself. The lie was comforting enough to get him through the rest of the night’s shift.

+
+

The Carlton AllLander drove Dudley home just after dawn. Like the astronomer, the vehicle was old and worn, but perfectly capable of doing its job. It had a cheap diesel engine, common enough on a semi-frontier world like Gralmond, although its drive array was a thoroughly modern photoneural processor. With its high suspension and deep-tread tyres it could plough along the dirt track to the observatory in all weather and seasons, including the metre-deep snow of Gralmond’s winters.

+
+ +

Pre-formatted text

+

The pre element represents a block of pre-formatted text, in which structure is represented by typographic conventions rather than by elements. Such examples are an e-mail (with paragraphs indicated by blank lines, lists indicated by lines prefixed with a bullet), fragments of computer code (with structure indicated according to the conventions of that language) or displaying ASCII art. Here’s an example showing the printable characters of ASCII:

+
+
  ! " # $ % & ' ( ) * + , - . /
+    0 1 2 3 4 5 6 7 8 9 : ; < = > ?
+    @ A B C D E F G H I J K L M N O
+    P Q R S T U V W X Y Z [ \ ] ^ _
+    ` a b c d e f g h i j k l m n o
+    p q r s t u v w x y z { | } ~ 
+
+ +

Blockquotes

+

The blockquote element represents a section that is being quoted from another source.

+
+
+

Many forms of Government have been tried, and will be tried in this world of sin and woe. No one pretends that democracy is perfect or all-wise. Indeed, it has been said that democracy is the worst form of government except all those other forms that have been tried from time to time.

+
+

Winston Churchill, in a speech to the House of Commons. 11th November 1947

+
+

Additionally, you might wish to cite the source, as in the above example. The correct method involves including the cite attribute on the blockquote element, but since no browser makes any use of that information, it’s useful to link to the source also.

+ +

Ordered list

+

The ol element denotes an ordered list, and various numbering schemes are available through the CSS (including 1,2,3… a,b,c… i,ii,iii… and so on). Each item requires a surrounding <li> and </li> tag, to denote individual items within the list (as you may have guessed, li stands for list item).

+
+
    +
  1. This is an ordered list.
  2. +
  3. + This is the second item, which contains a sub list +
      +
    1. This is the sub list, which is also ordered.
    2. +
    3. It has two items.
    4. +
    +
  4. +
  5. This is the final item on this list.
  6. +
+
+ +

Unordered list

+

The ul element denotes an unordered list (ie. a list of loose items that don’t require numbering, or a bulleted list). Again, each item requires a surrounding <li> and </li> tag, to denote individual items. Here is an example list showing the constituent parts of the British Isles:

+
+
    +
  • + United Kingdom of Great Britain and Northern Ireland: +
      +
    • England
    • +
    • Scotland
    • +
    • Wales
    • +
    • Northern Ireland
    • +
    +
  • +
  • Republic of Ireland
  • +
  • Isle of Man
  • +
  • + Channel Islands: +
      +
    • Bailiwick of Guernsey
    • +
    • Bailiwick of Jersey
    • +
    +
  • +
+
+

Sometimes we may want each list item to contain block elements, typically a paragraph or two.

+
+
    +
  • +

    The British Isles is an archipelago consisting of the two large islands of Great Britain and Ireland, and many smaller surrounding islands.

    +
  • +
  • +

    Great Britain is the largest island of the archipelago. Ireland is the second largest island of the archipelago and lies directly to the west of Great Britain.

    +
  • +
  • +

    The full list of islands in the British Isles includes over 1,000 islands, of which 51 have an area larger than 20 km2.

    +
  • +
+
+ +

Definition list

+

The dl element is for another type of list called a definition list. Instead of list items, the content of a dl consists of dt (Definition Term) and dd (Definition description) pairs. Though it may be called a “definition list”, dl can apply to other scenarios where a parent/child relationship is applicable. For example, it may be used for marking up dialogues, with each dt naming a speaker, and each dd containing his or her words.

+
+
+
This is a term.
+
This is the definition of that term, which both live in a dl.
+
Here is another term.
+
And it gets a definition too, which is this line.
+
Here is term that shares a definition with the term below.
+
Here is a defined term.
+
dt terms may stand on their own without an accompanying dd, but in that case they share descriptions with the next available dt. You may not have a dd without a parent dt.
+
+
+ +

Figures

+

Figures are usually used to refer to images:

+
+
+ Example image +
+

This is a placeholder image, with supporting caption.

+
+
+
+

Here, a part of a poem is marked up using figure:

+
+
+

‘Twas brillig, and the slithy toves
+ Did gyre and gimble in the wabe;
+ All mimsy were the borogoves,
+ And the mome raths outgrabe.

+
+

Jabberwocky (first verse). Lewis Carroll, 1832-98

+
+
+
+ +

Text-level Semantics

+

There are a number of inline HTML elements you may use anywhere within other elements.

+ +

Links and anchors

+

The a element is used to hyperlink text, be that to another page, a named fragment on the current page or any other location on the web. Example:

+ + +

Stressed emphasis

+

The em element is used to denote text with stressed emphasis, i.e., something you’d pronounce differently. Where italicizing is required for stylistic differentiation, the i element may be preferable. Example:

+
+

You simply must try the negitoro maki!

+
+ +

Strong importance

+

The strong element is used to denote text with strong importance. Where bolding is used for stylistic differentiation, the b element may be preferable. Example:

+
+

Don’t stick nails in the electrical outlet.

+
+ +

Small print

+

The small element is used to represent disclaimers, caveats, legal restrictions, or copyrights (commonly referred to as ‘small print’). It can also be used for attributions or satisfying licensing requirements. Example:

+
+

Copyright © 1922-2011 Acme Corporation. All Rights Reserved.

+
+ +

Strikethrough

+

The s element is used to represent content that is no longer accurate or relevant. When indicating document edits i.e., marking a span of text as having been removed from a document, use the del element instead. Example:

+
+

Recommended retail price: £3.99 per bottle
Now selling for just £2.99 a bottle!

+
+ +

Citations

+

The cite element is used to represent the title of a work (e.g. a book, essay, poem, song, film, TV show, sculpture, painting, musical, exhibition, etc). This can be a work that is being quoted or referenced in detail (i.e. a citation), or it can just be a work that is mentioned in passing. Example:

+
+

Universal Declaration of Human Rights, United Nations, December 1948. Adopted by General Assembly resolution 217 A (III).

+
+ +

Inline quotes

+

The q element is used for quoting text inline. Example showing nested quotations:

+
+

John said, I saw Lucy at lunch, she told me Mary wants you to get some ice cream on your way home. I think I will get some at Ben and Jerry’s, on Gloucester Road.

+
+ +

Definition

+

The dfn element is used to highlight the first use of a term. The title attribute can be used to describe the term. Example:

+
+

Bob’s canine mother and equine father sat him down and carefully explained that he was an allopolyploid organism.

+
+ +

Abbreviation

+

The abbr element is used for any abbreviated text, whether it be acronym, initialism, or otherwise. Generally, it’s less work and useful (enough) to mark up only the first occurrence of any particular abbreviation on a page, and ignore the rest. Any text in the title attribute will appear when the user’s mouse hovers the abbreviation (although notably, this does not work in Internet Explorer for Windows). Example abbreviations:

+
+

BBC, HTML, and Staffs.

+
+ +

Time

+

The time element is used to represent either a time on a 24 hour clock, or a precise date in the proleptic Gregorian calendar, optionally with a time and a time-zone offset. Example:

+
+

Queen Elizabeth II was proclaimed sovereign of each of the Commonwealth realms on and , after the death of her father, King George VI.

+
+ +

Code

+

The code element is used to represent fragments of computer code. Useful for technology-oriented sites, not so useful otherwise. Example:

+
+

When you call the activate() method on the robotSnowman object, the eyes glow.

+
+

Used in conjunction with the pre element:

+
+
function getJelly() {
+        echo $aDeliciousSnack;
+    }
+
+ +

Variable

+

The var element is used to denote a variable in a mathematical expression or programming context, but can also be used to indicate a placeholder where the contents should be replaced with your own value. Example:

+
+

If there are n pipes leading to the ice cream factory then I expect at least n flavours of ice cream to be available for purchase!

+
+ +

Sample output

+

The samp element is used to represent (sample) output from a program or computing system. Useful for technology-oriented sites, not so useful otherwise. Example:

+
+

The computer said Too much cheese in tray two but I didn’t know what that meant.

+
+ +

Keyboard entry

+

The kbd element is used to denote user input (typically via a keyboard, although it may also be used to represent other input methods, such as voice commands). Example:

+

+

To take a screenshot on your Mac, press ⌘ Cmd + ⇧ Shift + 3.

+
+ +

Superscript and subscript text

+

The sup element represents a superscript and the sub element represents a sub. These elements must be used only to mark up typographical conventions with specific meanings, not for typographical presentation. As a guide, only use these elements if their absence would change the meaning of the content. Example:

+
+

The coordinate of the ith point is (xi, yi). For example, the 10th point has coordinate (x10, y10).

+

f(x, n) = log4xn

+
+ +

Italicised

+

The i element is used for text in an alternate voice or mood, or otherwise offset from the normal prose. Examples include taxonomic designations, technical terms, idiomatic phrases from another language, the name of a ship or other spans of text whose typographic presentation is typically italicised. Example:

+
+

There is a certain je ne sais quoi in the air.

+
+ +

Emboldened

+

The b element is used for text stylistically offset from normal prose without conveying extra importance, such as key words in a document abstract, product names in a review, or other spans of text whose typographic presentation is typically emboldened. Example:

+
+

You enter a small room. Your sword glows brighter. A rat scurries past the corner wall.

+
+ +

Marked or highlighted text

+

The mark element is used to represent a run of text marked or highlighted for reference purposes. When used in a quotation it indicates a highlight not originally present but added to bring the reader’s attention to that part of the text. When used in the main prose of a document, it indicates a part of the document that has been highlighted due to its relevance to the user’s current activity. Example:

+
+

I also have some kittens who are visiting me these days. They’re really cute. I think they like my garden! Maybe I should adopt a kitten.

+
+ +

Edits

+

The del element is used to represent deleted or retracted text which still must remain on the page for some reason. Meanwhile its counterpart, the ins element, is used to represent inserted text. Both del and ins have a datetime attribute which allows you to include a timestamp directly in the element. Example inserted text and usage:

+
+

She bought two five pairs of shoes.

+
+ +

Tabular data

+

Tables should be used when displaying tabular data. The thead, tfoot and tbody elements enable you to group rows within each a table.

+

If you use these elements, you must use every element. They should appear in this order: thead, tfoot and tbody, so that browsers can render the foot before receiving all the data. You must use these tags within the table element.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
The Very Best Eggnog
IngredientsServes 12Serves 24
Milk1 quart2 quart
Cinnamon Sticks21
Vanilla Bean, Split12
Cloves510
Mace10 blades20 blades
Egg Yolks1224
Cups Sugar1 ½ cups3 cups
Dark Rum1 ½ cups3 cups
Brandy1 ½ cups3 cups
Vanilla1 tbsp2 tbsp
Half-and-half or Light Cream1 quart2 quart
Freshly grated nutmeg to taste
+
+ +

Forms

+

Forms can be used when you wish to collect data from users. The fieldset element enables you to group related fields within a form, and each one should contain a corresponding legend. The label element ensures field descriptions are associated with their corresponding form widgets.

+
+
+
+ Legend +
+ + + Note about this field +
+
+ + + Note about this field +
+
+ + + Note about this field +
+
+ + + Note about this field +
+
+ + + Note about this field +
+
+ + +
+
+ + + Note about this selection +
+
+ Checkbox * +
+ + + +
+
+
+
+ Radio + +
+
+
+ + +
+
+
+
+ +

This block is copyright © 2012 Paul Robert Lloyd. Code covered by the MIT license.

+
+
+

Dataset Results

+
+
+
    +
  • +

    Counselling in London Central, Camden

    + +
    +

    This is an application programming interface (API) that opens up + core EU legislative data for further use. The interface uses JSON, + meaning that you have easy to use machine-readable access to + metadata on European Union legislation.

    +

    It will be useful if you + want to use or analyze European Union legislative data in a way + that the official databases are not originally build for. The API + extracts, organize and connects data from various official + sources.

    +
    +
  • +
+
+
+
+
+
    +
  • +

    Counselling in London Central, Camden

    + +
    +

    This is an application programming interface (API) that opens up + core EU legislative data for further use. The interface uses JSON, + meaning that you have easy to use machine-readable access to + metadata on European Union legislation.

    +

    It will be useful if you + want to use or analyze European Union legislative data in a way + that the official databases are not originally build for. The API + extracts, organize and connects data from various official + sources.

    +
    +
  • +
  • +

    Counselling in London Central, Camden

    + +
    +

    This is an application programming interface (API) that opens up + core EU legislative data for further use. The interface uses JSON, + meaning that you have easy to use machine-readable access to + metadata on European Union legislation.

    +

    It will be useful if you + want to use or analyze European Union legislative data in a way + that the official databases are not originally build for. The API + extracts, organize and connects data from various official + sources.

    +
    +
  • +
  • +

    Counselling in London Central, Camden

    + +
    +

    This is an application programming interface (API) that opens up + core EU legislative data for further use. The interface uses JSON, + meaning that you have easy to use machine-readable access to + metadata on European Union legislation.

    +

    It will be useful if you + want to use or analyze European Union legislative data in a way + that the official databases are not originally build for. The API + extracts, organize and connects data from various official + sources.

    +
    +
  • +
+
+ +
+
+

+ 4 datasets found for "London" + + + Tags: + camden [remove] + Format: + HTML [remove] + CSV [remove] +

+
+
+ +
+

Masthead (Site Header)

+
+
+

My Site Title

+

My Site Tagline

+
+ +
+
+
+
+

My Site Title

+

My Site Tagline

+
+ + +
+
+ +
+ + diff --git a/ckan/public/base/test/spec/ckan.spec.js b/ckan/public/base/test/spec/ckan.spec.js new file mode 100644 index 00000000000..2af2e759dd7 --- /dev/null +++ b/ckan/public/base/test/spec/ckan.spec.js @@ -0,0 +1,70 @@ +describe('ckan.initialize()', function () { + beforeEach(function () { + this.promise = jQuery.Deferred(); + this.target = sinon.stub(ckan.Client.prototype, 'getLocaleData').returns(this.promise); + }); + + afterEach(function () { + this.target.restore(); + }); + + it('should load the localisations for the current page', function () { + ckan.initialize() + assert.called(this.target); + }); + + it('should load the localisations into the i18n library', function () { + var target = sinon.stub(ckan.i18n, 'load'); + var data = {lang: {}}; + + ckan.initialize(); + this.promise.resolve(data); + + assert.called(target); + assert.calledWith(target, data); + + target.restore(); + }); + + it('should initialize the module on the page', function () { + var target = sinon.stub(ckan.module, 'initialize'); + + ckan.initialize(); + this.promise.resolve(); + + assert.called(target); + target.restore(); + }); +}); + +describe('ckan.url()', function () { + beforeEach(function () { + ckan.SITE_ROOT = 'http://example.com'; + ckan.LOCALE_ROOT = ckan.SITE_ROOT + '/en'; + }); + + it('should return the ckan.SITE_ROOT', function () { + var target = ckan.url(); + assert.equal(target, ckan.SITE_ROOT); + }); + + it('should return the ckan.LOCALE_ROOT if true is passed', function () { + var target = ckan.url(true); + assert.equal(target, ckan.LOCALE_ROOT); + }); + + it('should append the path provided', function () { + var target = ckan.url('/test.html'); + assert.equal(target, ckan.SITE_ROOT + '/test.html'); + }); + + it('should append the path to the locale provided', function () { + var target = ckan.url('/test.html', true); + assert.equal(target, ckan.LOCALE_ROOT + '/test.html'); + }); + + it('should handle missing preceding slashes', function () { + var target = ckan.url('test.html'); + assert.equal(target, ckan.SITE_ROOT + '/test.html'); + }); +}); diff --git a/ckan/public/base/test/spec/client.spec.js b/ckan/public/base/test/spec/client.spec.js new file mode 100644 index 00000000000..9ed18a49210 --- /dev/null +++ b/ckan/public/base/test/spec/client.spec.js @@ -0,0 +1,405 @@ +/*globals describe beforeEach afterEach it assert sinon ckan jQuery */ +describe('ckan.Client()', function () { + var Client = ckan.Client; + + beforeEach(function () { + this.client = new Client(); + }); + + it('should add a new instance to each client', function () { + var target = ckan.sandbox().client; + + assert.instanceOf(target, Client); + }); + + it('should set the .endpoint property to options.endpoint', function () { + var client = new Client({endpoint: 'http://example.com'}); + assert.equal(client.endpoint, 'http://example.com'); + }); + + it('should default the endpoint to a blank string', function () { + assert.equal(this.client.endpoint, ''); + }); + + describe('.url(path)', function () { + beforeEach(function () { + this.client.endpoint = 'http://api.example.com'; + }); + + it('should return the path with the enpoint prepended', function () { + assert.equal(this.client.url('/api/endpoint'), 'http://api.example.com/api/endpoint'); + }); + + it('should normalise preceding slashes in the path', function () { + assert.equal(this.client.url('api/endpoint'), 'http://api.example.com/api/endpoint'); + }); + + it('should return the string if it already has a protocol', function () { + assert.equal(this.client.url('http://example.com/my/endpoint'), 'http://example.com/my/endpoint'); + }); + }); + + describe('.getTemplate(filename, params, success, error)', function () { + beforeEach(function () { + this.fakePromise = sinon.stub(jQuery.Deferred()); + this.fakePromise.then.returns(this.fakePromise); + sinon.stub(jQuery, 'get').returns(this.fakePromise); + }); + + afterEach(function () { + jQuery.get.restore(); + }); + + it('should return a jQuery promise', function () { + var target = this.client.getTemplate('test.html'); + assert.ok(target === this.fakePromise, 'target === this.fakePromise'); + }); + + it('should request the template file', function () { + var target = this.client.getTemplate('test.html'); + assert.called(jQuery.get); + assert.calledWith(jQuery.get, '/api/1/util/snippet/test.html', {}); + }); + + it('should request the template file with any provided params', function () { + var options = {limit: 5, page: 2}; + var target = this.client.getTemplate('test.html', options); + assert.called(jQuery.get); + assert.calledWith(jQuery.get, '/api/1/util/snippet/test.html', options); + }); + }); + + describe('.getLocaleData(locale, success, error)', function () { + beforeEach(function () { + this.fakePromise = sinon.stub(jQuery.Deferred()); + this.fakePromise.then.returns(this.fakePromise); + sinon.stub(jQuery, 'getJSON').returns(this.fakePromise); + }); + + afterEach(function () { + jQuery.getJSON.restore(); + }); + + it('should return a jQuery promise', function () { + var target = this.client.getLocaleData('en'); + assert.ok(target === this.fakePromise, 'target === this.fakePromise'); + }); + + it('should request the locale provided', function () { + var target = this.client.getLocaleData('en'); + assert.called(jQuery.getJSON); + assert.calledWith(jQuery.getJSON, '/api/i18n/en'); + }); + }); + + describe('.getCompletions(url, options, success, error)', function () { + beforeEach(function () { + this.fakePiped = sinon.stub(jQuery.Deferred()); + this.fakePiped.then.returns(this.fakePiped); + this.fakePiped.promise.returns(this.fakePiped); + + this.fakePromise = sinon.stub(jQuery.Deferred()); + this.fakePromise.pipe.returns(this.fakePiped); + + sinon.stub(jQuery, 'ajax').returns(this.fakePromise); + }); + + afterEach(function () { + jQuery.ajax.restore(); + }); + + it('should return a jQuery promise', function () { + var target = this.client.getCompletions('url'); + assert.ok(target === this.fakePiped, 'target === this.fakePiped'); + }); + + it('should make an ajax request for the url provided', function () { + function success() {} + function error() {} + + var target = this.client.getCompletions('url', success, error); + + assert.called(jQuery.ajax); + assert.calledWith(jQuery.ajax, {url: '/url'}); + + assert.called(this.fakePiped.then); + assert.calledWith(this.fakePiped.then, success, error); + }); + + it('should pipe the result through .parseCompletions()', function () { + var target = this.client.getCompletions('url'); + + assert.called(this.fakePromise.pipe); + assert.calledWith(this.fakePromise.pipe, this.client.parseCompletions); + }); + + it('should allow a custom format option to be provided', function () { + function format() {} + + var target = this.client.getCompletions('url', {format: format}); + + assert.called(this.fakePromise.pipe); + assert.calledWith(this.fakePromise.pipe, format); + }); + + }); + + describe('.parseCompletions(data, options)', function () { + it('should return a string of tags for a ResultSet collection', function () { + var data = { + ResultSet: { + Result: [ + {"Name": "1 percent"}, {"Name": "18thc"}, {"Name": "19thcentury"} + ] + } + }; + + var target = this.client.parseCompletions(data); + + assert.deepEqual(target, ["1 percent", "18thc", "19thcentury"]); + }); + + it('should return a string of formats for a ResultSet collection', function () { + var data = { + ResultSet: { + Result: [ + {"Format": "json"}, {"Format": "csv"}, {"Format": "text"} + ] + } + }; + + var target = this.client.parseCompletions(data); + + assert.deepEqual(target, ["json", "csv", "text"]); + }); + + it('should strip out duplicates with a case insensitive comparison', function () { + var data = { + ResultSet: { + Result: [ + {"Name": " Test"}, {"Name": "test"}, {"Name": "TEST"} + ] + } + }; + + var target = this.client.parseCompletions(data); + + assert.deepEqual(target, ["Test"]); + }); + + it('should return an array of objects if options.objects is true', function () { + var data = { + ResultSet: { + Result: [ + {"Format": "json"}, {"Format": "csv"}, {"Format": "text"} + ] + } + }; + + var target = this.client.parseCompletions(data, {objects: true}); + + assert.deepEqual(target, [ + {id: "json", text: "json"}, + {id: "csv", text: "csv"}, + {id: "text", text: "text"} + ]); + }); + + it('should call .parsePackageCompletions() id data is a string', function () { + var data = 'Name|id'; + var target = sinon.stub(this.client, 'parsePackageCompletions'); + + this.client.parseCompletions(data, {objects: true}); + + assert.called(target); + assert.calledWith(target, data); + }); + }); + + describe('.parseCompletionsForPlugin(data)', function () { + it('should return a string of tags for a ResultSet collection', function () { + var data = { + ResultSet: { + Result: [ + {"Name": "1 percent"}, {"Name": "18thc"}, {"Name": "19thcentury"} + ] + } + }; + + var target = this.client.parseCompletionsForPlugin(data); + + assert.deepEqual(target, { + results: [ + {id: "1 percent", text: "1 percent"}, + {id: "18thc", text: "18thc"}, + {id: "19thcentury", text: "19thcentury"} + ] + }); + }); + }); + + describe('.parsePackageCompletions(string, options)', function () { + it('should parse the package completions string', function () { + var data = 'Package 1|package-1\nPackage 2|package-2\nPackage 3|package-3\n'; + var target = this.client.parsePackageCompletions(data); + + assert.deepEqual(target, ['package-1', 'package-2', 'package-3']); + }); + + it('should return an object if options.object is true', function () { + var data = 'Package 1|package-1\nPackage 2|package-2\nPackage 3|package-3\n'; + var target = this.client.parsePackageCompletions(data, {objects: true}); + + assert.deepEqual(target, [ + {id: 'package-1', text: 'Package 1'}, + {id: 'package-2', text: 'Package 2'}, + {id: 'package-3', text: 'Package 3'} + ]); + }); + }); + + describe('.getStorageAuth()', function () { + beforeEach(function () { + this.fakePromise = sinon.mock(jQuery.Deferred()); + sinon.stub(jQuery, 'ajax').returns(this.fakePromise); + }); + + afterEach(function () { + jQuery.ajax.restore(); + }); + + it('should return a jQuery promise', function () { + var target = this.client.getStorageAuth('filename'); + assert.equal(target, this.fakePromise); + }); + + it('should call request a new auth token', function () { + function success() {} + function error() {} + + var target = this.client.getStorageAuth('filename', success, error); + + assert.called(jQuery.ajax); + assert.calledWith(jQuery.ajax, { + url: '/api/storage/auth/form/filename', + success: success, + error: error + }); + }); + }); + + describe('.getStorageMetadata()', function () { + beforeEach(function () { + this.fakePromise = sinon.mock(jQuery.Deferred()); + sinon.stub(jQuery, 'ajax').returns(this.fakePromise); + }); + + afterEach(function () { + jQuery.ajax.restore(); + }); + + it('should return a jQuery promise', function () { + var target = this.client.getStorageMetadata('filename'); + assert.equal(target, this.fakePromise); + }); + + it('should call request a new auth token', function () { + function success() {} + function error() {} + + var target = this.client.getStorageMetadata('filename', success, error); + + assert.called(jQuery.ajax); + assert.calledWith(jQuery.ajax, { + url: '/api/storage/metadata/filename', + success: success, + error: error + }); + }); + + it('should throw an error if no filename is provided', function () { + var client = this.client; + assert.throws(function () { + client.getStorageMetadata(); + }); + }); + }); + + describe('.convertStorageMetadataToResource(meta)', function () { + beforeEach(function () { + this.meta = { + "_checksum": "md5:527c97d2aa3ed1b40aea4b7ddf98692e", + "_content_length": 122632, + "_creation_date": "2012-07-17T14:35:35", + "_label": "2012-07-17T13:35:35.540Z/cat.jpg", + "_last_modified": "2012-07-17T14:35:35", + "_location": "http://example.com/storage/f/2012-07-17T13%3A35%3A35.540Z/cat.jpg", + "filename-original": "cat.jpg", + "key": "2012-07-17T13:35:35.540Z/cat.jpg", + "uploaded-by": "user" + }; + }); + + it('should return a representation for a resource', function () { + var target = this.client.convertStorageMetadataToResource(this.meta); + + assert.deepEqual(target, { + url: 'http://example.com/storage/f/2012-07-17T13%3A35%3A35.540Z/cat.jpg', + key: '2012-07-17T13:35:35.540Z/cat.jpg', + name: 'cat.jpg', + size: 122632, + created: "2012-07-17T14:35:35", + last_modified: "2012-07-17T14:35:35", + format: 'jpg', + mimetype: null, + resource_type: 'file.upload', // Is this standard? + owner: 'user', + hash: 'md5:527c97d2aa3ed1b40aea4b7ddf98692e', + cache_url: 'http://example.com/storage/f/2012-07-17T13%3A35%3A35.540Z/cat.jpg', + cache_url_updated: '2012-07-17T14:35:35' + }); + }); + + it('should provide a full url', function () { + ckan.SITE_ROOT = 'http://example.com'; + + this.meta._location = "/storage/f/2012-07-17T13%3A35%3A35.540Z/cat.jpg"; + var target = this.client.convertStorageMetadataToResource(this.meta); + assert.equal(target.url, 'http://example.com/storage/f/2012-07-17T13%3A35%3A35.540Z/cat.jpg'); + }); + + it('should not include microseconds or timezone in timestamps', function () { + ckan.SITE_ROOT = 'http://example.com'; + + var target = this.client.convertStorageMetadataToResource(this.meta); + assert.ok(!(/\.\d\d\d/).test(target.last_modified), 'no microseconds'); + assert.ok(!(/((\+|\-)\d{4}|Z)$/).test(target.last_modified), 'no timezone'); + }); + + it('should use the mime type for the format if found', function () { + this.meta._format = 'image/jpeg'; + var target = this.client.convertStorageMetadataToResource(this.meta); + + assert.equal(target.format, 'image/jpeg', 'format'); + assert.equal(target.mimetype, 'image/jpeg', 'mimetype'); + }); + }); + + describe('.normalizeTimestamp(timestamp)', function () { + it('should add a timezone to a timestamp without one', function () { + var target = this.client.normalizeTimestamp("2012-07-17T14:35:35"); + assert.equal(target, "2012-07-17T14:35:35Z"); + }); + + it('should not add a timezone to a timestamp with one already', function () { + var target = this.client.normalizeTimestamp("2012-07-17T14:35:35Z"); + assert.equal(target, "2012-07-17T14:35:35Z", 'timestamp with Z'); + + target = this.client.normalizeTimestamp("2012-07-17T14:35:35+0100"); + assert.equal(target, "2012-07-17T14:35:35+0100", 'timestamp with +0100'); + + target = this.client.normalizeTimestamp("2012-07-17T14:35:35-0400"); + assert.equal(target, "2012-07-17T14:35:35-0400", 'timestamp with -0400'); + }); + }); +}); diff --git a/ckan/public/base/test/spec/module.spec.js b/ckan/public/base/test/spec/module.spec.js new file mode 100644 index 00000000000..94b0e82e080 --- /dev/null +++ b/ckan/public/base/test/spec/module.spec.js @@ -0,0 +1,394 @@ +/*globals describe beforeEach afterEach it assert sinon ckan jQuery */ +describe('ckan.module(id, properties|callback)', function () { + beforeEach(function () { + ckan.module.registry = {}; + ckan.module.instances = {}; + this.factory = {}; + }); + + it('should add a new item to the registry', function () { + ckan.module('test', this.factory); + + assert.instanceOf(new ckan.module.registry.test(), ckan.module.BaseModule); + }); + + it('should allow a function to be provided', function () { + var target = sinon.stub().returns({}); + ckan.module('test', target); + + assert.called(target); + }); + + it('should pass jQuery, i18n.translate() and i18n into the function', function () { + var target = sinon.stub().returns({}); + ckan.module('test', target); + + assert.calledWith(target, jQuery, ckan.i18n.translate, ckan.i18n); + }); + + it('should throw an exception if the module is already defined', function () { + ckan.module('name', this.factory); + assert.throws(function () { + ckan.module('name', this.factory); + }); + }); + + it('should return the ckan object', function () { + assert.equal(ckan.module('name', this.factory), ckan); + }); + + describe('.initialize()', function () { + beforeEach(function () { + this.element1 = jQuery('
').appendTo(this.fixture); + this.element2 = jQuery('
').appendTo(this.fixture); + this.element3 = jQuery('
').appendTo(this.fixture); + + this.test1 = sinon.spy(); + + // Add test1 to the registry. + ckan.module.registry = { + test1: this.test1 + }; + + this.target = sinon.stub(ckan.module, 'createInstance'); + }); + + afterEach(function () { + this.target.restore(); + }); + + it('should find all elements with the "data-module" attribute', function () { + ckan.module.initialize(); + assert.called(this.target); + }); + + it('should skip modules that are not functions', function () { + ckan.module.initialize(); + assert.calledTwice(this.target); + }); + + it('should call module.createInstance() with the element and factory', function () { + ckan.module.initialize(); + assert.calledWith(this.target, this.test1, this.element1[0]); + assert.calledWith(this.target, this.test1, this.element2[0]); + }); + + it('should return the module object', function () { + assert.equal(ckan.module.initialize(), ckan.module); + }); + + it('should initialize more than one module sepearted by a space', function () { + this.fixture.empty(); + this.element4 = jQuery('
').appendTo(this.fixture); + this.test2 = ckan.module.registry.test2 = sinon.spy(); + + ckan.module.initialize(); + + assert.calledWith(this.target, this.test1, this.element4[0]); + assert.calledWith(this.target, this.test2, this.element4[0]); + }); + + it('should defer all published events untill all modules have loaded', function () { + var pubsub = ckan.pubsub; + var callbacks = []; + + // Ensure each module is loaded. Three in total. + ckan.module.registry = { + test1: function () {}, + test2: function () {} + }; + + // Call a function to publish and subscribe to an event on each instance. + this.target.restore(); + this.target = sinon.stub(ckan.module, 'createInstance', function () { + var callback = sinon.spy(); + + pubsub.publish('test'); + pubsub.subscribe('test', callback); + + callbacks.push(callback); + }); + + ckan.module.initialize(); + + // Ensure that all subscriptions received all messages. + assert.ok(callbacks.length, 'no callbacks were created'); + jQuery.each(callbacks, function () { + assert.calledThrice(this); + }); + }); + }); + + describe('.createInstance(Module, element)', function () { + beforeEach(function () { + this.element = document.createElement('div'); + this.factory = ckan.module.BaseModule; + this.factory.options = this.defaults = {test1: 'a', test2: 'b', test3: 'c'}; + + this.sandbox = { + i18n: { + translate: sinon.spy() + } + }; + sinon.stub(ckan, 'sandbox').returns(this.sandbox); + + this.extractedOptions = {test1: 1, test2: 2}; + sinon.stub(ckan.module, 'extractOptions').returns(this.extractedOptions); + }); + + afterEach(function () { + ckan.sandbox.restore(); + ckan.module.extractOptions.restore(); + }); + + it('should extract the options from the element', function () { + ckan.module.createInstance(this.factory, this.element); + + assert.called(ckan.module.extractOptions); + assert.calledWith(ckan.module.extractOptions, this.element); + }); + + it('should not modify the defaults object', function () { + var clone = jQuery.extend({}, this.defaults); + ckan.module.createInstance(this.factory, this.element); + + assert.deepEqual(this.defaults, clone); + }); + + it('should create a sandbox object', function () { + ckan.module.createInstance(this.factory, this.element); + assert.called(ckan.sandbox); + assert.calledWith(ckan.sandbox, this.element); + }); + + it('should initialize the module factory with the sandbox, options and translate function', function () { + var target = sinon.spy(); + ckan.module.createInstance(target, this.element); + + assert.called(target); + assert.calledWith(target, this.element, this.extractedOptions, this.sandbox); + }); + + it('should initialize the module as a constructor', function () { + var target = sinon.spy(); + ckan.module.createInstance(target, this.element); + + assert.calledWithNew(target); + + }); + + it('should call the .initialize() method if one exists', function () { + var init = sinon.spy(); + var target = sinon.stub().returns({ + initialize: init + }); + + ckan.module.createInstance(target, this.element); + + assert.called(init); + }); + + it('should push the new instance into an array under ckan.module.instances', function () { + var target = function MyModule() { return {'mock': 'instance'}; }; + target.namespace = 'test'; + + ckan.module.createInstance(target, this.element); + + assert.deepEqual(ckan.module.instances.test, [{'mock': 'instance'}]); + }); + + it('should push further instances into the existing array under ckan.module.instances', function () { + var target = function MyModule() { return {'mock': 'instance3'}; }; + target.namespace = 'test'; + + ckan.module.instances.test = [{'mock': 'instance1'}, {'mock': 'instance2'}]; + ckan.module.createInstance(target, this.element); + + assert.deepEqual(ckan.module.instances.test, [ + {'mock': 'instance1'}, {'mock': 'instance2'}, {'mock': 'instance3'} + ]); + }); + + }); + + describe('.extractOptions(element)', function () { + it('should extract the data keys from the element', function () { + var element = jQuery('
', { + 'data-not-module': 'skip', + 'data-module': 'skip', + 'data-module-a': 'capture', + 'data-module-b': 'capture', + 'data-module-c': 'capture' + })[0]; + + var target = ckan.module.extractOptions(element); + + assert.deepEqual(target, {a: 'capture', b: 'capture', c: 'capture'}); + }); + + it('should convert JSON contents of keys into JS primitives', function () { + var element = jQuery('
', { + 'data-module-null': 'null', + 'data-module-int': '100', + 'data-module-arr': '[1, 2, 3]', + 'data-module-obj': '{"a": 1, "b":2, "c": 3}', + 'data-module-str': 'hello' + })[0]; + + var target = ckan.module.extractOptions(element); + + assert.deepEqual(target, { + 'null': null, + 'int': 100, + 'arr': [1, 2, 3], + 'obj': {"a": 1, "b": 2, "c": 3}, + 'str': 'hello' + }); + }); + + it('should simply use strings for content that it cannot parse as JSON', function () { + var element = jQuery('
', { + 'data-module-url': 'http://example.com/path/to.html', + 'data-module-bad': '{oh: 1, no' + })[0]; + + var target = ckan.module.extractOptions(element); + + assert.deepEqual(target, { + 'url': 'http://example.com/path/to.html', + 'bad': '{oh: 1, no' + }); + }); + + it('should convert keys with hyphens into camelCase', function () { + var element = jQuery('
', { + 'data-module-long-property': 'long', + 'data-module-really-very-long-property': 'longer' + })[0]; + + var target = ckan.module.extractOptions(element); + + assert.deepEqual(target, { + 'longProperty': 'long', + 'reallyVeryLongProperty': 'longer' + }); + }); + + it('should set boolean attributes to true', function () { + var element = jQuery('
', { + 'data-module-long-property': '' + })[0]; + + var target = ckan.module.extractOptions(element); + + assert.deepEqual(target, {'longProperty': true}); + }); + }); + + describe('BaseModule(element, options, sandbox)', function () { + var BaseModule = ckan.module.BaseModule; + + beforeEach(function () { + this.el = jQuery('
'); + this.options = {}; + this.sandbox = ckan.sandbox(); + this.module = new BaseModule(this.el, this.options, this.sandbox); + }); + + it('should assign .el as the element option', function () { + assert.ok(this.module.el === this.el); + }); + + it('should wrap .el in jQuery if not already wrapped', function () { + var element = document.createElement('div'); + var target = new BaseModule(element, this.options, this.sandbox); + + assert.ok(target.el instanceof jQuery); + }); + + it('should deep extend the options object', function () { + // Lazy check :/ + var target = sinon.stub(jQuery, 'extend'); + new BaseModule(this.el, this.options, this.sandbox); + + assert.called(target); + assert.calledWith(target, true, {}, BaseModule.prototype.options, this.options); + + target.restore(); + }); + + it('should assign the sandbox property', function () { + assert.equal(this.module.sandbox, this.sandbox); + }); + + describe('.$(selector)', function () { + it('should find children within the module element', function () { + this.module.el.append(jQuery('')); + assert.equal(this.module.$('input').length, 2); + }); + }); + + describe('.i18n()', function () { + beforeEach(function () { + this.i18n = { + first: 'first string', + second: {fetch: sinon.stub().returns('second string')}, + third: sinon.stub().returns('third string') + }; + + this.module.options.i18n = this.i18n; + }); + + it('should return the translation string', function () { + var target = this.module.i18n('first'); + assert.equal(target, 'first string'); + }); + + it('should call fetch on the translation string if it exists', function () { + var target = this.module.i18n('second'); + assert.equal(target, 'second string'); + }); + + it('should return just the key if no translation exists', function () { + var target = this.module.i18n('missing'); + assert.equal(target, 'missing'); + }); + + it('should call the translation function if one is provided', function () { + var target = this.module.i18n('third'); + assert.equal(target, 'third string'); + }); + + it('should pass the argments after the key into trans.fetch()', function () { + var target = this.module.options.i18n.second.fetch; + this.module.i18n('second', 1, 2, 3); + assert.called(target); + assert.calledWith(target, 1, 2, 3); + }); + + it('should pass the argments after the key into the translation function', function () { + var target = this.module.options.i18n.third; + this.module.i18n('third', 1, 2, 3); + assert.called(target); + assert.calledWith(target, 1, 2, 3); + }); + }); + + describe('.remove()', function () { + it('should teardown the module', function () { + var target = sinon.stub(this.module, 'teardown'); + + this.module.remove(); + + assert.called(target); + }); + + it('should remove the element from the page', function () { + this.fixture.append(this.module.el); + this.module.remove(); + + assert.equal(this.fixture.children().length, 0); + }); + }); + }); +}); diff --git a/ckan/public/base/test/spec/modules/autocomplete.spec.js b/ckan/public/base/test/spec/modules/autocomplete.spec.js new file mode 100644 index 00000000000..82b1c964cd6 --- /dev/null +++ b/ckan/public/base/test/spec/modules/autocomplete.spec.js @@ -0,0 +1,321 @@ +/*globals describe beforeEach afterEach it assert sinon ckan jQuery */ +describe('ckan.modules.AutocompleteModule()', function () { + var Autocomplete = ckan.module.registry['autocomplete']; + + beforeEach(function () { + // Stub select2 plugin if loaded. + if (jQuery.fn.select2) { + this.select2 = sinon.stub(jQuery.fn, 'select2'); + } else { + this.select2 = jQuery.fn.select2 = sinon.stub().returns({ + data: sinon.stub().returns({ + on: sinon.stub() + }) + }); + } + + this.el = document.createElement('input'); + this.sandbox = ckan.sandbox(); + this.sandbox.body = this.fixture; + this.module = new Autocomplete(this.el, {}, this.sandbox); + }); + + afterEach(function () { + this.module.teardown(); + + if (this.select2.restore) { + this.select2.restore(); + } else { + delete jQuery.fn.select2; + } + }); + + describe('.initialize()', function () { + it('should bind callback methods to the module', function () { + var target = sinon.stub(jQuery, 'proxyAll'); + + this.module.initialize(); + + assert.called(target); + assert.calledWith(target, this.module, /_on/, /format/); + + target.restore(); + }); + + it('should setup the autocomplete plugin', function () { + var target = sinon.stub(this.module, 'setupAutoComplete'); + + this.module.initialize(); + + assert.called(target); + }); + }); + + describe('.setupAutoComplete()', function () { + it('should initialize the autocomplete plugin', function () { + this.module.setupAutoComplete(); + + assert.called(this.select2); + assert.calledWith(this.select2, { + width: 'resolve', + query: this.module._onQuery, + dropdownCssClass: '', + containerCssClass: '', + formatResult: this.module.formatResult, + formatNoMatches: this.module.formatNoMatches, + formatInputTooShort: this.module.formatInputTooShort, + createSearchChoice: this.module.formatTerm, // Not used by tags. + initSelection: this.module.formatInitialValue + }); + }); + + it('should initialize the autocomplete plugin with a tags callback if options.tags is true', function () { + this.module.options.tags = true; + this.module.setupAutoComplete(); + + assert.called(this.select2); + assert.calledWith(this.select2, { + width: 'resolve', + tags: this.module._onQuery, + dropdownCssClass: '', + containerCssClass: '', + formatResult: this.module.formatResult, + formatNoMatches: this.module.formatNoMatches, + formatInputTooShort: this.module.formatInputTooShort, + initSelection: this.module.formatInitialValue + }); + + it('should watch the keydown event on the select2 input'); + + it('should allow a custom css class to be added to the dropdown', function () { + this.module.options.dropdownClass = 'tags'; + this.module.setupAutoComplete(); + + assert.called(this.select2); + assert.calledWith(this.select2, { + width: 'resolve', + tags: this.module._onQuery, + dropdownCssClass: 'tags', + containerCssClass: '', + formatResult: this.module.formatResult, + formatNoMatches: this.module.formatNoMatches, + formatInputTooShort: this.module.formatInputTooShort, + initSelection: this.module.formatInitialValue + }); + }); + + it('should allow a custom css class to be added to the container', function () { + this.module.options.containerClass = 'tags'; + this.module.setupAutoComplete(); + + assert.called(this.select2); + assert.calledWith(this.select2, { + width: 'resolve', + tags: this.module._onQuery, + dropdownCssClass: '', + containerCssClass: 'tags', + formatResult: this.module.formatResult, + formatNoMatches: this.module.formatNoMatches, + formatInputTooShort: this.module.formatInputTooShort, + initSelection: this.module.formatInitialValue + }); + }); + + }); + }); + + describe('.getCompletions(term, fn)', function () { + beforeEach(function () { + this.term = 'term'; + this.module.options.source = 'http://example.com?term=?'; + + this.target = sinon.stub(this.sandbox.client, 'getCompletions'); + }); + + it('should get the completions from the client', function () { + this.module.getCompletions(this.term); + assert.called(this.target); + }); + + it('should replace the last ? in the source url with the term', function () { + this.module.getCompletions(this.term); + assert.calledWith(this.target, 'http://example.com?term=term'); + }); + + it('should escape special characters in the term', function () { + this.module.getCompletions('term with spaces'); + assert.calledWith(this.target, 'http://example.com?term=term%20with%20spaces'); + }); + + it('should set the formatter to work with the plugin', function () { + this.module.getCompletions(this.term); + assert.calledWith(this.target, 'http://example.com?term=term', { + format: this.sandbox.client.parseCompletionsForPlugin + }); + }); + }); + + describe('.lookup(term, fn)', function () { + beforeEach(function () { + sinon.stub(this.module, 'getCompletions'); + this.target = sinon.spy(); + }); + + it('should set the _lastTerm property', function () { + this.module.lookup('term'); + assert.equal(this.module._lastTerm, 'term'); + }); + + it('should call the fn immediately if there is no term', function () { + this.module.lookup('', this.target); + assert.called(this.target); + assert.calledWith(this.target, {results: []}); + }); + + it('should debounce the request if there is a term'); + it('should cancel the last request'); + }); + + describe('.formatResult(state)', function () { + beforeEach(function () { + this.module._lastTerm = 'term'; + }); + + it('should return the string with the last term wrapped in bold tags', function () { + var target = this.module.formatResult({id: 'we have termites', text: 'we have termites'}); + assert.equal(target, 'we have termites'); + }); + + it('should return the string with each instance of the term wrapped in bold tags', function () { + var target = this.module.formatResult({id: 'we have a termite terminology', text: 'we have a termite terminology'}); + assert.equal(target, 'we have a termite terminology'); + }); + + it('should return the term if there is no last term saved', function () { + delete this.module._lastTerm; + var target = this.module.formatResult({id: 'we have a termite terminology', text: 'we have a termite terminology'}); + assert.equal(target, 'we have a termite terminology'); + }); + }); + + describe('.formatNoMatches(term)', function () { + it('should return the no matches string if there is a term', function () { + var target = this.module.formatNoMatches('term'); + assert.equal(target, 'No matches found'); + }); + + it('should return the empty string if there is no term', function () { + var target = this.module.formatNoMatches(''); + assert.equal(target, 'Start typing…'); + }); + }); + + describe('.formatInputTooShort(term, min)', function () { + it('should return the plural input too short string', function () { + var target = this.module.formatInputTooShort('term', 2); + assert.equal(target, 'Input is too short, must be at least 2 characters'); + }); + + it('should return the singular input too short string', function () { + var target = this.module.formatInputTooShort('term', 1); + assert.equal(target, 'Input is too short, must be at least one character'); + }); + }); + + describe('.formatTerm()', function () { + it('should return an item object with id and text properties', function () { + assert.deepEqual(this.module.formatTerm('test'), {id: 'test', text: 'test'}); + }); + + it('should trim whitespace from the value', function () { + assert.deepEqual(this.module.formatTerm(' test '), {id: 'test', text: 'test'}); + }); + + it('should convert commas in ids into unicode characters', function () { + assert.deepEqual(this.module.formatTerm('test, test'), {id: 'test\u002C test', text: 'test, test'}); + }); + }); + + describe('.formatInitialValue(element, callback)', function () { + beforeEach(function () { + this.callback = sinon.spy(); + }); + + it('should pass an item object with id and text properties into the callback', function () { + var target = jQuery(''); + + this.module.formatInitialValue(target, this.callback); + assert.calledWith(this.callback, {id: 'test', text: 'test'}); + }); + + it('should pass an array of properties into the callback if options.tags is true', function () { + this.module.options.tags = true; + var target = jQuery('', {value: "test, test"}); + + this.module.formatInitialValue(target, this.callback); + assert.calledWith(this.callback, [{id: 'test', text: 'test'}, {id: 'test', text: 'test'}]); + }); + + it('should return the value if no callback is provided (to support select2 v2.1)', function () { + var target = jQuery(''); + + assert.deepEqual(this.module.formatInitialValue(target), {id: 'test', text: 'test'}); + }); + }); + + describe('._onQuery(options)', function () { + it('should lookup the current term with the callback', function () { + var target = sinon.stub(this.module, 'lookup'); + + this.module._onQuery({term: 'term', callback: 'callback'}); + + assert.called(target); + assert.calledWith(target, 'term', 'callback'); + }); + + it('should do nothing if there is no options object', function () { + var target = sinon.stub(this.module, 'lookup'); + this.module._onQuery(); + assert.notCalled(target); + }); + }); + + describe('._onKeydown(event)', function () { + beforeEach(function () { + this.keyDownEvent = jQuery.Event("keydown", { which: 188 }); + this.fakeEvent = {}; + this.clock = sinon.useFakeTimers(); + this.jQuery = sinon.stub(jQuery.fn, 'init', jQuery.fn.init); + this.Event = sinon.stub(jQuery, 'Event').returns(this.fakeEvent); + this.trigger = sinon.stub(jQuery.fn, 'trigger'); + }); + + afterEach(function () { + this.clock.restore(); + this.jQuery.restore(); + this.Event.restore(); + this.trigger.restore(); + }); + + it('should trigger fake "return" keypress if a comma is pressed', function () { + this.module._onKeydown(this.keyDownEvent); + + this.clock.tick(100); + + assert.called(this.jQuery); + assert.called(this.Event); + assert.called(this.trigger); + assert.calledWith(this.trigger, this.fakeEvent); + }); + + it('should do nothing if another key is pressed', function () { + this.keyDownEvent.which = 200; + + this.module._onKeydown(this.keyDownEvent); + + this.clock.tick(100); + + assert.notCalled(this.Event); + }); + }); +}); diff --git a/ckan/public/base/test/spec/modules/basic-form.spec.js b/ckan/public/base/test/spec/modules/basic-form.spec.js new file mode 100644 index 00000000000..3153c44b7b6 --- /dev/null +++ b/ckan/public/base/test/spec/modules/basic-form.spec.js @@ -0,0 +1,25 @@ +/*globals describe beforeEach afterEach it assert sinon ckan jQuery */ +describe('ckan.module.BasicFormModule()', function () { + var BasicFormModule = ckan.module.registry['basic-form']; + + beforeEach(function () { + sinon.stub(jQuery.fn, 'incompleteFormWarning'); + + this.el = document.createElement('button'); + this.sandbox = ckan.sandbox(); + this.sandbox.body = this.fixture; + this.module = new BasicFormModule(this.el, {}, this.sandbox); + }); + + afterEach(function () { + this.module.teardown(); + jQuery.fn.incompleteFormWarning.restore(); + }); + + describe('.initialize()', function () { + it('should attach the jQuery.fn.incompleteFormWarning() to the form', function () { + this.module.initialize(); + assert.called(jQuery.fn.incompleteFormWarning); + }); + }); +}); diff --git a/ckan/public/base/test/spec/modules/confirm-action.spec.js b/ckan/public/base/test/spec/modules/confirm-action.spec.js new file mode 100644 index 00000000000..0185afd8fc8 --- /dev/null +++ b/ckan/public/base/test/spec/modules/confirm-action.spec.js @@ -0,0 +1,121 @@ +/*globals describe beforeEach afterEach it assert sinon ckan jQuery */ +describe('ckan.module.ConfirmActionModule()', function () { + var ConfirmActionModule = ckan.module.registry['confirm-action']; + + beforeEach(function () { + jQuery.fn.modal = sinon.spy(); + + this.el = document.createElement('button'); + this.sandbox = ckan.sandbox(); + this.sandbox.body = this.fixture; + this.module = new ConfirmActionModule(this.el, {}, this.sandbox); + }); + + afterEach(function () { + this.module.teardown(); + }); + + describe('.initialize()', function () { + it('should watch for clicks on the module element', function () { + var target = sinon.stub(this.module.el, 'on'); + this.module.initialize(); + assert.called(target); + assert.calledWith(target, 'click', this.module._onClick); + }); + }); + + describe('.confirm()', function () { + it('should append the modal to the document body', function () { + this.module.confirm(); + assert.equal(this.fixture.children().length, 1); + assert.equal(this.fixture.find('.modal').length, 1); + }); + + it('should show the modal dialog', function () { + this.module.confirm(); + assert.called(jQuery.fn.modal); + assert.calledWith(jQuery.fn.modal, 'show'); + }); + }); + + describe('.performAction()', function () { + it('should submit the action'); + }); + + describe('.createModal()', function () { + it('should create the modal element', function () { + var target = this.module.createModal(); + + assert.ok(target.hasClass('modal')); + }); + + it('should set the module.modal property', function () { + var target = this.module.createModal(); + + assert.ok(target === this.module.modal); + }); + + it('should bind the success/cancel listeners', function () { + var target = sinon.stub(jQuery.fn, 'on'); + + this.module.createModal(); + + // Not an ideal check as this implementation could be done in many ways. + assert.calledTwice(target); + assert.calledWith(target, 'click', '.btn-primary', this.module._onConfirmSuccess); + assert.calledWith(target, 'click', '.btn-cancel', this.module._onConfirmCancel); + + target.restore(); + }); + + it('should initialise the modal plugin', function () { + this.module.createModal(); + assert.called(jQuery.fn.modal); + assert.calledWith(jQuery.fn.modal, {show: false}); + }); + + it('should insert the localized strings', function () { + var target = this.module.createModal(); + var i18n = this.module.options.i18n; + + assert.equal(target.find('h3').text(), i18n.heading.fetch()); + assert.equal(target.find('.modal-body').text(), i18n.content.fetch()); + assert.equal(target.find('.btn-primary').text(), i18n.confirm.fetch()); + assert.equal(target.find('.btn-cancel').text(), i18n.cancel.fetch()); + }); + }); + + describe('._onClick()', function () { + it('should prevent the default action', function () { + var target = {preventDefault: sinon.spy()}; + this.module._onClick(target); + + assert.called(target.preventDefault); + }); + + it('should display the confirmation dialog', function () { + var target = sinon.stub(this.module, 'confirm'); + this.module._onClick({preventDefault: sinon.spy()}); + assert.called(target); + }); + }); + + describe('._onConfirmSuccess()', function () { + it('should perform the action', function () { + var target = sinon.stub(this.module, 'performAction'); + this.module._onConfirmSuccess(jQuery.Event('click')); + assert.called(target); + }); + }); + + describe('._onConfirmCancel()', function () { + it('should hide the modal', function () { + this.module.modal = jQuery('
'); + this.module._onConfirmCancel(jQuery.Event('click')); + + assert.called(jQuery.fn.modal); + assert.calledWith(jQuery.fn.modal, 'hide'); + }); + }); + +}); diff --git a/ckan/public/base/test/spec/modules/custom-fields.spec.js b/ckan/public/base/test/spec/modules/custom-fields.spec.js new file mode 100644 index 00000000000..6be05c9f5af --- /dev/null +++ b/ckan/public/base/test/spec/modules/custom-fields.spec.js @@ -0,0 +1,200 @@ +/*globals describe before beforeEach afterEach it assert sinon ckan jQuery */ +describe('ckan.module.CustomFieldsModule()', function () { + var CustomFieldsModule = ckan.module.registry['custom-fields']; + + before(function (done) { + this.loadFixture('custom_fields.html', function (template) { + this.template = template; + done(); + }); + }); + + beforeEach(function () { + this.fixture.html(this.template); + this.el = this.fixture.find('[data-module]'); + this.sandbox = ckan.sandbox(); + this.sandbox.body = this.fixture; + this.module = new CustomFieldsModule(this.el, {}, this.sandbox); + }); + + afterEach(function () { + this.module.teardown(); + }); + + describe('.initialize()', function () { + it('should bind all functions beginning with _on to the module scope', function () { + var target = sinon.stub(jQuery, 'proxyAll'); + + this.module.initialize(); + + assert.called(target); + assert.calledWith(target, this.module, /_on/); + + target.restore(); + }); + + it('should listen for changes to the last "key" input', function () { + var target = sinon.stub(this.module, '_onChange'); + + this.module.initialize(); + this.module.$('input[name*=key]').change(); + + assert.calledOnce(target); + }); + + it('should listen for changes to all checkboxes', function () { + var target = sinon.stub(this.module, '_onRemove'); + + this.module.initialize(); + this.module.$(':checkbox').trigger('change'); + + assert.calledOnce(target); + }); + + it('should add "button" classes to the remove input', function () { + this.module.initialize(); + + assert.equal(this.module.$('.checkbox.btn').length, 1, 'each item should have the .btn class'); + assert.equal(this.module.$('.checkbox.icon-remove').length, 1, 'each item shoud have the .icon-remove class'); + }); + }); + + describe('.newField(element)', function () { + it('should append a new field to the element', function () { + var element = document.createElement('div'); + sinon.stub(this.module, 'cloneField').returns(element); + + this.module.newField(); + + assert.ok(jQuery.contains(this.module.el, element)); + }); + }); + + describe('.cloneField(element)', function () { + it('should clone the provided field', function () { + var element = document.createElement('div'); + var init = sinon.stub(jQuery.fn, 'init', jQuery.fn.init); + var clone = sinon.stub(jQuery.fn, 'clone', jQuery.fn.clone); + + this.module.cloneField(element); + + assert.called(init); + assert.calledWith(init, element); + assert.called(clone); + + init.restore(); + clone.restore(); + }); + + it('should return the cloned element', function () { + var element = document.createElement('div'); + var cloned = document.createElement('div'); + var init = sinon.stub(jQuery.fn, 'init', jQuery.fn.init); + var clone = sinon.stub(jQuery.fn, 'clone').returns(jQuery(cloned)); + + assert.ok(this.module.cloneField(element)[0] === cloned); + + init.restore(); + clone.restore(); + }); + }); + + describe('.resetField(element)', function () { + beforeEach(function () { + this.field = jQuery('
'); + }); + + it('should empty all input values', function () { + var target = this.module.resetField(this.field); + assert.equal(target.find(':input').val(), ''); + }); + + it('should increment any integers in the input names by one', function () { + var target = this.module.resetField(this.field); + assert.equal(target.find(':input').attr('name'), 'field-2'); + }); + + it('should increment any numbers in the label text by one', function () { + var target = this.module.resetField(this.field); + assert.equal(target.find('label').text(), 'Field 2'); + }); + + it('should increment any numbers in the label for by one', function () { + var target = this.module.resetField(this.field); + assert.equal(target.find('label').attr('for'), 'field-2'); + }); + }); + + describe('.disableField(field, disable)', function () { + beforeEach(function () { + this.target = this.module.$('.control-custom:first'); + }); + + it('should add a .disable class to the element', function () { + this.module.disableField(this.target); + assert.isTrue(this.target.hasClass('disabled')); + }); + + it('should set the disabled property on the input elements', function () { + this.module.disableField(this.target); + + this.target.find(':input').each(function () { + if (jQuery(this).is(':checkbox')) { + assert.isFalse(this.disabled, 'checkbox should not be disabled'); + } else { + assert.isTrue(this.disabled, 'input should be disabled'); + } + }); + }); + + it('should remove a .disable class to the element if disable is false', function () { + this.target.addClass('disable'); + + this.module.disableField(this.target, false); + assert.isFalse(this.target.hasClass('disabled')); + }); + + it('should unset the disabled property on the input elements if disable is false', function () { + this.target.find(':input:not(:checkbox)').prop('disabled', true); + + this.module.disableField(this.target, false); + + this.target.find(':input').each(function () { + if (jQuery(this).is(':checkbox')) { + assert.isFalse(this.disabled, 'checkbox should not be disabled'); + } else { + assert.isFalse(this.disabled, 'input should not be disabled'); + } + }); + }); + }); + + describe('._onChange(event)', function () { + it('should call .newField() with the custom control', function () { + var target = sinon.stub(this.module, 'newField'); + var field = this.module.$('[name*=key]:last').val('test'); + + this.module._onChange(jQuery.Event('change', {target: field[0]})); + + assert.called(target); + }); + + it('should not call .newField() if the target field is empty', function () { + var target = sinon.stub(this.module, 'newField'); + var field = this.module.$('[name*=key]:last').val(''); + + this.module._onChange(jQuery.Event('change', {target: field[0]})); + + assert.notCalled(target); + }); + }); + + describe('._onRemove(event)', function () { + it('should call .disableField() with the custom control', function () { + var target = sinon.stub(this.module, 'disableField'); + this.module._onRemove(jQuery.Event('change', {target: this.module.$(':checkbox')[0]})); + + assert.called(target); + }); + }); +}); diff --git a/ckan/public/base/test/spec/modules/related-item.spec.js b/ckan/public/base/test/spec/modules/related-item.spec.js new file mode 100644 index 00000000000..b1486e90040 --- /dev/null +++ b/ckan/public/base/test/spec/modules/related-item.spec.js @@ -0,0 +1,87 @@ +/*globals describe before beforeEach afterEach it assert sinon ckan jQuery */ +describe('ckan.module.RelatedItemModule()', function () { + var RelatedItemModule = ckan.module.registry['related-item']; + + before(function (done) { + // Load our fixture into the this.fixture element. + this.loadFixture('related-item.html', function (html) { + this.template = html; + done(); + }); + }); + + beforeEach(function () { + this.truncated = jQuery('
'); + jQuery.fn.truncate = sinon.stub().returns(this.truncated); + + // Grab the loaded fixture. + this.el = this.fixture.html(this.template).children(); + this.sandbox = ckan.sandbox(); + this.sandbox.body = this.fixture; + this.module = new RelatedItemModule(this.el, {}, this.sandbox); + }); + + afterEach(function () { + this.module.teardown(); + delete jQuery.fn.truncate; + }); + + describe('.initialize()', function () { + it('should truncate the .prose element', function () { + this.module.initialize(); + assert.called(jQuery.fn.truncate); + }); + + it('should pass the various options into the truncate plugin'); + + it('should cache the collapsed height of the plugin', function () { + this.module.initialize(); + assert.ok(this.module.collapsedHeight); + }); + + it('should listen for the "truncate" events', function () { + var target = sinon.stub(this.truncated, 'on'); + this.module.initialize(); + + assert.called(target); + assert.calledWith(target, 'expand.truncate', this.module._onExpand); + assert.calledWith(target, 'collapse.truncate', this.module._onCollapse); + }); + }); + + describe('._onExpand(event)', function () { + it('should add the "expanded" class to the element', function () { + this.module._onExpand(jQuery.Event()); + assert.isTrue(this.el.hasClass(this.module.options.expandedClass)); + }); + + it('should add a bottom margin to the element', function () { + this.module._onExpand(jQuery.Event()); + assert.ok(this.el.css('margin-bottom')); + }); + + it('should calcualte the difference between the current and cached height', function () { + var target = sinon.stub(this.el, 'css'); + sinon.stub(this.el, 'height').returns(30); + this.module.collapsedHeight = 10; + this.module._onExpand(jQuery.Event()); + + assert.called(target); + assert.calledWith(target, 'margin-bottom', -20); + }); + }); + + describe('._onCollapse(event)', function () { + it('should remove the "expanded" class from the element', function () { + this.el.addClass(this.module.options.expandedClass); + this.module._onCollapse(jQuery.Event()); + assert.isFalse(this.el.hasClass(this.module.options.expandedClass)); + }); + + it('should remove the bottom margin from the element', function () { + this.el.css('margin-bottom', -90); + this.module._onCollapse(jQuery.Event()); + assert.equal(this.el.css('margin-bottom'), '0px'); + }); + }); +}); diff --git a/ckan/public/base/test/spec/modules/resource-form.spec.js b/ckan/public/base/test/spec/modules/resource-form.spec.js new file mode 100644 index 00000000000..78975e32f8a --- /dev/null +++ b/ckan/public/base/test/spec/modules/resource-form.spec.js @@ -0,0 +1,74 @@ +/*globals describe beforeEach afterEach it assert sinon ckan jQuery */ +describe('ckan.modules.ResourceFormModule()', function () { + var ResourceFormModule = ckan.module.registry['resource-form']; + + beforeEach(function () { + this.el = document.createElement('form'); + this.sandbox = ckan.sandbox(); + this.module = new ResourceFormModule(this.el, {}, this.sandbox); + }); + + afterEach(function () { + this.module.teardown(); + }); + + describe('.initialize()', function () { + it('should subscribe to the "resource:uploaded" event', function () { + var target = sinon.stub(this.sandbox, 'subscribe'); + + this.module.initialize(); + + assert.called(target); + assert.calledWith(target, 'resource:uploaded', this.module._onResourceUploaded); + + target.restore(); + }); + }); + + describe('.teardown()', function () { + it('should unsubscribe from the "resource:uploaded" event', function () { + var target = sinon.stub(this.sandbox, 'unsubscribe'); + + this.module.teardown(); + + assert.called(target); + assert.calledWith(target, 'resource:uploaded', this.module._onResourceUploaded); + + target.restore(); + }); + }); + + describe('._onResourceUploaded()', function () { + beforeEach(function () { + this.module.el.html([ + '', + '', + '', + '', + '', + '' + ].join('')); + + this.resource = { + text: 'text', + checkbox: "check", + radio: "radio2", + hidden: "hidden", + select: "option1" + }; + }); + + it('should set the values on appropriate fields', function () { + var res = this.resource; + + this.module._onResourceUploaded(res); + + jQuery.each(this.module.el.serializeArray(), function (idx, field) { + assert.equal(field.value, res[field.name]); + }); + }); + }); +}); diff --git a/ckan/public/base/test/spec/modules/resource-upload-field.spec.js b/ckan/public/base/test/spec/modules/resource-upload-field.spec.js new file mode 100644 index 00000000000..ee8eb8513dc --- /dev/null +++ b/ckan/public/base/test/spec/modules/resource-upload-field.spec.js @@ -0,0 +1,290 @@ +/*globals describe beforeEach afterEach it assert sinon ckan jQuery */ +describe('ckan.modules.ResourceUploadFieldModule()', function () { + var ResourceFileUploadModule = ckan.module.registry['resource-upload-field']; + + beforeEach(function () { + jQuery.fn.fileupload = sinon.spy(); + + this.el = jQuery('
'); + this.sandbox = ckan.sandbox(); + this.module = new ResourceFileUploadModule(this.el, {}, this.sandbox); + this.module.initialize(); + }); + + afterEach(function () { + this.module.teardown(); + }); + + describe('.initialize()', function () { + beforeEach(function () { + // Create un-initialised module. + this.module.teardown(); + this.module = new ResourceFileUploadModule(this.el, {}, this.sandbox); + }); + + it('should create the #upload field', function () { + this.module.initialize(); + assert.ok(typeof this.module.upload === 'object'); + }); + + it('should append the upload field to the module element', function () { + this.module.initialize(); + + assert.ok(jQuery.contains(this.el, this.module.upload)); + }); + + it('should call .setupFileUpload()', function () { + var target = sinon.stub(this.module, 'setupFileUpload'); + + this.module.initialize(); + + assert.called(target); + }); + }); + + describe('.setupFileUpload()', function () { + it('should set the label text on the form input', function () { + this.module.initialize(); + this.module.setupFileUpload(); + + assert.equal(this.module.upload.find('label').text(), 'Upload a file'); + }); + + it('should setup the file upload with relevant options', function () { + this.module.initialize(); + this.module.setupFileUpload(); + + assert.called(jQuery.fn.fileupload); + assert.calledWith(jQuery.fn.fileupload, { + type: 'POST', + paramName: 'file', + forceIframeTransport: true, // Required for XDomain request. + replaceFileInput: true, + autoUpload: false, + add: this.module._onUploadAdd, + send: this.module._onUploadSend, + done: this.module._onUploadDone, + fail: this.module._onUploadFail, + always: this.module._onUploadComplete + }); + }); + }); + + describe('.loading(show)', function () { + it('should add a loading class to the upload element', function () { + this.module.loading(); + + assert.ok(this.module.upload.hasClass('loading')); + }); + + it('should remove the loading class if false is passed as an argument', function () { + this.module.upload.addClass('loading'); + this.module.loading(); + + assert.ok(!this.module.upload.hasClass('loading')); + }); + }); + + describe('.authenticate(key, data)', function () { + beforeEach(function () { + this.fakeThen = sinon.spy(); + this.fakeProxy = sinon.stub(jQuery, 'proxy').returns('onsuccess'); + + this.target = sinon.stub(this.sandbox.client, 'getStorageAuth'); + this.target.returns({ + then: this.fakeThen + }); + }); + + afterEach(function () { + jQuery.proxy.restore(); + }); + + it('should request authentication for the upload', function () { + this.module.authenticate('test', {}); + assert.called(this.target); + assert.calledWith(this.target, 'test'); + }); + + it('should register success and error callbacks', function () { + this.module.authenticate('test', {}); + assert.called(this.fakeThen); + assert.calledWith(this.fakeThen, 'onsuccess', this.module._onAuthError); + }); + + it('should save the key on the data object', function () { + var data = {}; + + this.module.authenticate('test', data); + + assert.equal(data.key, 'test'); + }); + }); + + describe('.lookupMetadata(key, data)', function () { + beforeEach(function () { + this.fakeThen = sinon.spy(); + this.fakeProxy = sinon.stub(jQuery, 'proxy').returns('onsuccess'); + + this.target = sinon.stub(this.sandbox.client, 'getStorageMetadata'); + this.target.returns({ + then: this.fakeThen + }); + }); + + afterEach(function () { + jQuery.proxy.restore(); + }); + + it('should request metadata for the upload key', function () { + this.module.lookupMetadata('test', {}); + assert.called(this.target); + assert.calledWith(this.target, 'test'); + }); + + it('should register success and error callbacks', function () { + this.module.lookupMetadata('test', {}); + assert.called(this.fakeThen); + assert.calledWith(this.fakeThen, 'onsuccess', this.module._onMetadataError); + }); + }); + + describe('.notify(message, type)', function () { + it('should call the sandbox.notify() method', function () { + var target = sinon.stub(this.sandbox, 'notify'); + + this.module.notify('this is an example message', 'info'); + + assert.called(target); + assert.calledWith(target, 'An Error Occurred', 'this is an example message', 'info'); + }); + }); + + describe('.generateKey(file)', function () { + it('should generate a unique filename prefixed with a timestamp', function () { + var now = new Date(); + var date = jQuery.date.toISOString(now); + var clock = sinon.useFakeTimers(now.getTime()); + var target = this.module.generateKey('this is my file.png'); + + assert.equal(target, date + '/this-is-my-file.png'); + + clock.restore(); + }); + }); + + describe('._onUploadAdd(event, data)', function () { + beforeEach(function () { + this.target = sinon.stub(this.module, 'authenticate'); + sinon.stub(this.module, 'generateKey').returns('stubbed'); + }); + + it('should authenticate the upload if a file is provided', function () { + var data = {files: [{name: 'my_file.jpg'}]}; + this.module._onUploadAdd({}, data); + + assert.called(this.target); + assert.calledWith(this.target, 'stubbed', data); + }); + + it('should not authenticate the upload if no file is provided', function () { + var data = {files: []}; + this.module._onUploadAdd({}, data); + + assert.notCalled(this.target); + }); + }); + + describe('._onUploadSend()', function () { + it('should display the loading spinner', function () { + var target = sinon.stub(this.module, 'loading'); + this.module._onUploadSend({}, {}); + + assert.called(target); + }); + }); + + describe('._onUploadDone()', function () { + it('should request the metadata for the file', function () { + var target = sinon.stub(this.module, 'lookupMetadata'); + this.module._onUploadDone({}, {result: {}}); + + assert.called(target); + }); + + it('should call the fail handler if the "result" key in the data is undefined', function () { + var target = sinon.stub(this.module, '_onUploadFail'); + this.module._onUploadDone({}, {result: undefined}); + + assert.called(target); + }); + + it('should call the fail handler if the "result" object has an "error" key', function () { + var target = sinon.stub(this.module, '_onUploadFail'); + this.module._onUploadDone({}, {result: {error: 'failed'}}); + + assert.called(target); + }); + }); + + describe('._onUploadComplete()', function () { + it('should hide the loading spinner', function () { + var target = sinon.stub(this.module, 'loading'); + this.module._onUploadComplete({}, {}); + + assert.called(target); + assert.calledWith(target, false); + }); + }); + + describe('._onAuthSuccess()', function () { + beforeEach(function () { + this.target = { + submit: sinon.spy() + }; + + this.response = { + action: 'action', + fields: [{name: 'name', value: 'value'}] + }; + }); + + it('should set the data url', function () { + this.module._onAuthSuccess(this.target, this.response); + + assert.equal(this.target.url, this.response.action); + }); + + it('should set the additional form data', function () { + this.module._onAuthSuccess(this.target, this.response); + + assert.deepEqual(this.target.formData, this.response.fields); + }); + + it('should merge the form data with the options', function () { + this.module.options.form.params = [{name: 'option', value: 'option'}]; + this.module._onAuthSuccess(this.target, this.response); + + assert.deepEqual(this.target.formData, [{name: 'option', value: 'option'}, {name: 'name', value: 'value'}]); + }); + + it('should call data.submit()', function () { + this.module._onAuthSuccess(this.target, this.response); + assert.called(this.target.submit); + }); + }); + + describe('._onMetadataSuccess()', function () { + it('should publish the "resource:uploaded" event', function () { + var resource = {url: 'http://', name: 'My File'}; + var target = sinon.stub(this.sandbox, 'publish'); + + sinon.stub(this.sandbox.client, 'convertStorageMetadataToResource').returns(resource); + + this.module._onMetadataSuccess(); + + assert.called(target); + assert.calledWith(target, "resource:uploaded", resource); + }); + }); +}); diff --git a/ckan/public/base/test/spec/notify.spec.js b/ckan/public/base/test/spec/notify.spec.js new file mode 100644 index 00000000000..48a2e612691 --- /dev/null +++ b/ckan/public/base/test/spec/notify.spec.js @@ -0,0 +1,46 @@ +/*globals describe beforeEach afterEach it assert sinon ckan jQuery */ +describe('ckan.notify()', function () { + beforeEach(function () { + this.element = jQuery('
'); + this.fixture.append(this.element); + + ckan.notify.el = this.element; + }); + + it('should append a notification to the element', function () { + ckan.notify('test'); + assert.equal(this.element.children().length, 1, 'should be one child'); + ckan.notify('test'); + assert.equal(this.element.children().length, 2, 'should be two children'); + }); + + it('should append a notification title', function () { + ckan.notify('test'); + assert.equal(this.element.find('strong').text(), 'test'); + }); + + it('should append a notification body', function () { + ckan.notify('test', 'this is a message'); + assert.equal(this.element.find('span').text(), 'this is a message'); + }); + + it('should escape all content', function () { + ckan.notify(' + {% endblock %} + #} + {%- block scripts %} + {% endblock -%} + + {# defined in the config.ini under "ckan.template_footer_end" #} + {% block body_extras -%} + {{ config.get('ckan.template_footer_end', '') | safe }} + {%- endblock %} + + diff --git a/ckan/templates/development/markup.html b/ckan/templates/development/markup.html new file mode 100644 index 00000000000..9c4fcffce30 --- /dev/null +++ b/ckan/templates/development/markup.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} + +{% block page %} +
+
+ {% snippet 'development/snippets/markup.html' %} +
+
+{% endblock %} diff --git a/ckan/templates/development/primer.html b/ckan/templates/development/primer.html new file mode 100644 index 00000000000..0641b057eec --- /dev/null +++ b/ckan/templates/development/primer.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block page %} + {% include "header.html" %} +
+ {% snippet 'development/snippets/breadcrumb.html', stage=1 %} + {% snippet 'development/snippets/breadcrumb.html', stage=2 %} + {% snippet 'development/snippets/breadcrumb.html', stage=3 %} + + {% snippet 'development/snippets/toolbar.html' %} + + {% snippet 'development/snippets/module.html', heading='Heading One' %} + {% snippet 'development/snippets/module.html', heading='Heading Two', heading_level=2 %} + {% snippet 'development/snippets/module.html', heading='Heading Three', heading_level=3 %} + {% snippet 'development/snippets/module.html', heading='Heading with link', heading_link=true %} + {% snippet 'development/snippets/module.html', heading='Heading with action', heading_action=true %} + {% snippet 'development/snippets/module.html', heading='Heading with icon', heading_icon=true %} + {% snippet 'development/snippets/module.html', heading='Module with footer', footer=true %} + {% snippet 'development/snippets/module.html', heading='Narrow Module (sidebar)', classes=['module-narrow'] %} + {% snippet 'development/snippets/module.html', heading='Narrow Shallow Module (sidebar text)', classes=['module-narrow', 'module-shallow'] %} + + {% snippet 'development/snippets/list.html', heading='Simple List' %} + + {% snippet 'development/snippets/nav.html', heading='Navigation' %} + {% snippet 'development/snippets/nav.html', heading='Active Navigation', show_active=true %} + {% snippet 'development/snippets/nav.html', heading='Icon Navigation', show_icons=true %} + + {% snippet 'development/snippets/facet.html', heading='Facet List', show_icons=true %} + + {% snippet 'development/snippets/simple-input.html', heading='Facet List', show_icons=true %} + + {% snippet 'development/snippets/form.html' %} + {% snippet 'development/snippets/form.html', error=['This field has an error'] %} + +
+
+

Top level heading (h1)

+

Some Rendered Markdown (h2)

+
+

Heading 1

+ {{ lipsum(1) }} +

Heading 2

+ {{ lipsum(1) }} +

Heading 3

+ {{ lipsum(1) }} +
+
+
+ + {% snippet 'development/snippets/form_stages.html' %} + + {% snippet 'snippets/package_grid.html', packages=[ + {'name': "test", 'title': 'Test', 'notes': lipsum(1), 'tracking_summary':{'recent': 10}}, + {'name': "test", 'title': 'Test', 'notes': lipsum(0), 'tracking_summary':{'recent': 5}}, + {'name': "test", 'title': 'Test', 'notes': lipsum(1), 'tracking_summary':{'recent': 10}}, + {'name': "test", 'title': 'Test', 'notes': lipsum(1), 'tracking_summary':{'recent': 10}} + ] %} + + {% snippet 'development/snippets/media_grid.html', groups=[ + {'name': "test", 'display_name': 'Test', 'type': 'group', 'description': lipsum(0), 'packages': 0}, + {'name': "test", 'display_name': 'Test', 'type': 'group', 'description': lipsum(1), 'packages': 1}, + {'name': "test", 'display_name': 'Test', 'type': 'group', 'description': lipsum(1), 'packages': 10}, + {'name': "test", 'display_name': 'Test', 'type': 'group', 'description': lipsum(1), 'packages': 200} + ] %} + + {% snippet 'development/snippets/pagination.html', total=5, current=1 %} + {% snippet 'development/snippets/pagination.html', total=5, current=2 %} + {% snippet 'development/snippets/pagination.html', total=5, current=3 %} + {% snippet 'development/snippets/pagination.html', total=5, current=4 %} + {% snippet 'development/snippets/pagination.html', total=5, current=5 %} +
+ + {% include "footer.html" %} +{% endblock %} diff --git a/ckan/templates/development/snippets/breadcrumb.html b/ckan/templates/development/snippets/breadcrumb.html new file mode 100644 index 00000000000..9a1e371bb83 --- /dev/null +++ b/ckan/templates/development/snippets/breadcrumb.html @@ -0,0 +1,7 @@ +
+ +
diff --git a/ckan/templates/development/snippets/facet.html b/ckan/templates/development/snippets/facet.html new file mode 100644 index 00000000000..5efc1d3abdd --- /dev/null +++ b/ckan/templates/development/snippets/facet.html @@ -0,0 +1,15 @@ +
+ {% with items=(("First", false), ("Second", true), ("Third", true), ("Fourth", false), ("Last", false)) %} +

Facet List Clear All

+ + + {% endwith %} +
diff --git a/ckan/templates/development/snippets/form.html b/ckan/templates/development/snippets/form.html new file mode 100644 index 00000000000..c3e6fb7baf6 --- /dev/null +++ b/ckan/templates/development/snippets/form.html @@ -0,0 +1,27 @@ +{% import 'macros/form.html' as form %} + +
+ + {{ form.input('standard', label=_('Standard'), placeholder=_('Standard Input'), value='', error=error, classes=[]) }} + {{ form.input('standard', label=_('Medium'), placeholder=_('Medium Width Input'), value='', error=error, classes=['control-medium']) }} + {{ form.input('standard', label=_('Full'), placeholder=_('Full Width Input'), value='', error=error, classes=['control-full']) }} + {{ form.input('standard', label=_('Large'), placeholder=_('Large Input'), value='', error=error, classes=['control-full', 'control-large']) }} + {{ form.prepend('slug', label=_('Prepend'), prepend='prefix', placeholder=_('Prepend Input'), value='', error=error, classes=[]) }} + {{ form.custom( + names=('custom_key', 'custom_value', 'custom_deleted'), + id='field-custom', + label=_('Custom Field (empty)'), + values=(), + error=error ) }} + {{ form.custom( + names=('custom_key', 'custom_value', 'custom_deleted'), + id='field-custom', + label=_('Custom Field'), + values=('key', 'value', true), + error=error ) }} + {{ form.markdown('desc', id='field-description', label=_('Markdown'), placeholder='Some nice placeholder text', error=error) }} + {{ form.textarea('desc', id='field-description', label=_('Textarea'), placeholder='Some nice placeholder text', error=error) }} + {{ form.select('year', label=_('Select'), options=[{'value': 2010}, {'value': 2011}], selected=2011, error=error) }} + {{ form.checkbox('remember', label="This is my checkbox", checked=true, error=error) }} + +
diff --git a/ckan/templates/development/snippets/form_stages.html b/ckan/templates/development/snippets/form_stages.html new file mode 100644 index 00000000000..409f0f5f4b9 --- /dev/null +++ b/ckan/templates/development/snippets/form_stages.html @@ -0,0 +1,30 @@ +
+
+ {% snippet 'package/snippets/stages.html', stages=['active'] %} +
+
+
+
+ {% snippet 'package/snippets/stages.html', stages=['complete', 'active'] %} +
+
+
+
+ {% snippet 'package/snippets/stages.html', stages=['complete', 'complete', 'active'] %} +
+
+
+
+ {% snippet 'package/snippets/stages.html', stages=['complete', 'active', 'complete'] %} +
+
+
+
+ {% snippet 'package/snippets/stages.html', stages=['active', 'complete', 'complete'] %} +
+
+
+
+ {% snippet 'package/snippets/stages.html', stages=['active', 'complete'] %} +
+
diff --git a/ckan/templates/development/snippets/list.html b/ckan/templates/development/snippets/list.html new file mode 100644 index 00000000000..f7e28879f1c --- /dev/null +++ b/ckan/templates/development/snippets/list.html @@ -0,0 +1,14 @@ +
+ {% with items=(("First", false), ("Second", true), ("Third", true), ("Fourth", false), ("Last", false)) %} +

{{ heading }}

+ + {% endwith %} +
diff --git a/ckan/templates/development/snippets/markup.html b/ckan/templates/development/snippets/markup.html new file mode 100644 index 00000000000..0e9ec65353f --- /dev/null +++ b/ckan/templates/development/snippets/markup.html @@ -0,0 +1,416 @@ +

General Prose

+
+

Sections Linked

+

The main page header of this guide is an h1 element. Any header elements may include links, as depicted in the example.

+

The secondary header above is an h2 element, which may be used for any form of important page-level header. More than one may be used per page. Consider using an h2 unless you need a header level of less importance, or as a sub-header to an existing h2 element.

+

Third-Level Header Linked

+

The header above is an h3 element, which may be used for any form of page-level header which falls below the h2 header in a document hierarchy.

+

Fourth-Level Header Linked

+

The header above is an h4 element, which may be used for any form of page-level header which falls below the h3 header in a document hierarchy.

+
Fifth-Level Header Linked
+

The header above is an h5 element, which may be used for any form of page-level header which falls below the h4 header in a document hierarchy.

+
Sixth-Level Header Linked
+

The header above is an h6 element, which may be used for any form of page-level header which falls below the h5 header in a document hierarchy.

+ +

Grouping content

+

Paragraphs

+

All paragraphs are wrapped in p tags. Additionally, p elements can be wrapped with a blockquote element if the p element is indeed a quote. Historically, blockquote has been used purely to force indents, but this is now achieved using CSS. Reserve blockquote for quotes.

+ +

Horizontal rule

+

The hr element represents a paragraph-level thematic break, e.g. a scene change in a story, or a transition to another topic within a section of a reference book. The following extract from Pandora’s Star by Peter F. Hamilton shows two paragraphs that precede a scene change and the paragraph that follows it:

+
+

Dudley was ninety-two, in his second life, and fast approaching time for another rejuvenation. Despite his body having the physical age of a standard fifty-year-old, the prospect of a long degrading campaign within academia was one he regarded with dread. For a supposedly advanced civilization, the Intersolar Commonwearth could be appallingly backward at times, not to mention cruel.

+

Maybe it won’t be that bad, he told himself. The lie was comforting enough to get him through the rest of the night’s shift.

+
+

The Carlton AllLander drove Dudley home just after dawn. Like the astronomer, the vehicle was old and worn, but perfectly capable of doing its job. It had a cheap diesel engine, common enough on a semi-frontier world like Gralmond, although its drive array was a thoroughly modern photoneural processor. With its high suspension and deep-tread tyres it could plough along the dirt track to the observatory in all weather and seasons, including the metre-deep snow of Gralmond’s winters.

+
+ +

Pre-formatted text

+

The pre element represents a block of pre-formatted text, in which structure is represented by typographic conventions rather than by elements. Such examples are an e-mail (with paragraphs indicated by blank lines, lists indicated by lines prefixed with a bullet), fragments of computer code (with structure indicated according to the conventions of that language) or displaying ASCII art. Here’s an example showing the printable characters of ASCII:

+
+
  ! " # $ % & ' ( ) * + , - . /
+0 1 2 3 4 5 6 7 8 9 : ; < = > ?
+@ A B C D E F G H I J K L M N O
+P Q R S T U V W X Y Z [ \ ] ^ _
+` a b c d e f g h i j k l m n o
+p q r s t u v w x y z { | } ~ 
+
+ +

Blockquotes

+

The blockquote element represents a section that is being quoted from another source.

+
+
+

Many forms of Government have been tried, and will be tried in this world of sin and woe. No one pretends that democracy is perfect or all-wise. Indeed, it has been said that democracy is the worst form of government except all those other forms that have been tried from time to time.

+
+

Winston Churchill, in a speech to the House of Commons. 11th November 1947

+
+

Additionally, you might wish to cite the source, as in the above example. The correct method involves including the cite attribute on the blockquote element, but since no browser makes any use of that information, it’s useful to link to the source also.

+ +

Ordered list

+

The ol element denotes an ordered list, and various numbering schemes are available through the CSS (including 1,2,3… a,b,c… i,ii,iii… and so on). Each item requires a surrounding <li> and </li> tag, to denote individual items within the list (as you may have guessed, li stands for list item).

+
+
    +
  1. This is an ordered list.
  2. +
  3. + This is the second item, which contains a sub list +
      +
    1. This is the sub list, which is also ordered.
    2. +
    3. It has two items.
    4. +
    +
  4. +
  5. This is the final item on this list.
  6. +
+
+ +

Unordered list

+

The ul element denotes an unordered list (ie. a list of loose items that don’t require numbering, or a bulleted list). Again, each item requires a surrounding <li> and </li> tag, to denote individual items. Here is an example list showing the constituent parts of the British Isles:

+
+
    +
  • + United Kingdom of Great Britain and Northern Ireland: +
      +
    • England
    • +
    • Scotland
    • +
    • Wales
    • +
    • Northern Ireland
    • +
    +
  • +
  • Republic of Ireland
  • +
  • Isle of Man
  • +
  • + Channel Islands: +
      +
    • Bailiwick of Guernsey
    • +
    • Bailiwick of Jersey
    • +
    +
  • +
+
+

Sometimes we may want each list item to contain block elements, typically a paragraph or two.

+
+
    +
  • +

    The British Isles is an archipelago consisting of the two large islands of Great Britain and Ireland, and many smaller surrounding islands.

    +
  • +
  • +

    Great Britain is the largest island of the archipelago. Ireland is the second largest island of the archipelago and lies directly to the west of Great Britain.

    +
  • +
  • +

    The full list of islands in the British Isles includes over 1,000 islands, of which 51 have an area larger than 20 km2.

    +
  • +
+
+ +

Definition list

+

The dl element is for another type of list called a definition list. Instead of list items, the content of a dl consists of dt (Definition Term) and dd (Definition description) pairs. Though it may be called a “definition list”, dl can apply to other scenarios where a parent/child relationship is applicable. For example, it may be used for marking up dialogues, with each dt naming a speaker, and each dd containing his or her words.

+
+
+
This is a term.
+
This is the definition of that term, which both live in a dl.
+
Here is another term.
+
And it gets a definition too, which is this line.
+
Here is term that shares a definition with the term below.
+
Here is a defined term.
+
dt terms may stand on their own without an accompanying dd, but in that case they share descriptions with the next available dt. You may not have a dd without a parent dt.
+
+
+ +

Figures

+

Figures are usually used to refer to images:

+
+
+ Example image +
+

This is a placeholder image, with supporting caption.

+
+
+
+

Here, a part of a poem is marked up using figure:

+
+
+

‘Twas brillig, and the slithy toves
+ Did gyre and gimble in the wabe;
+ All mimsy were the borogoves,
+ And the mome raths outgrabe.

+
+

Jabberwocky (first verse). Lewis Carroll, 1832-98

+
+
+
+ +

Text-level Semantics

+

There are a number of inline HTML elements you may use anywhere within other elements.

+ +

Links and anchors

+

The a element is used to hyperlink text, be that to another page, a named fragment on the current page or any other location on the web. Example:

+ + +

Stressed emphasis

+

The em element is used to denote text with stressed emphasis, i.e., something you’d pronounce differently. Where italicizing is required for stylistic differentiation, the i element may be preferable. Example:

+
+

You simply must try the negitoro maki!

+
+ +

Strong importance

+

The strong element is used to denote text with strong importance. Where bolding is used for stylistic differentiation, the b element may be preferable. Example:

+
+

Don’t stick nails in the electrical outlet.

+
+ +

Small print

+

The small element is used to represent disclaimers, caveats, legal restrictions, or copyrights (commonly referred to as ‘small print’). It can also be used for attributions or satisfying licensing requirements. Example:

+
+

Copyright © 1922-2011 Acme Corporation. All Rights Reserved.

+
+ +

Strikethrough

+

The s element is used to represent content that is no longer accurate or relevant. When indicating document edits i.e., marking a span of text as having been removed from a document, use the del element instead. Example:

+
+

Recommended retail price: £3.99 per bottle
Now selling for just £2.99 a bottle!

+
+ +

Citations

+

The cite element is used to represent the title of a work (e.g. a book, essay, poem, song, film, TV show, sculpture, painting, musical, exhibition, etc). This can be a work that is being quoted or referenced in detail (i.e. a citation), or it can just be a work that is mentioned in passing. Example:

+
+

Universal Declaration of Human Rights, United Nations, December 1948. Adopted by General Assembly resolution 217 A (III).

+
+ +

Inline quotes

+

The q element is used for quoting text inline. Example showing nested quotations:

+
+

John said, I saw Lucy at lunch, she told me Mary wants you to get some ice cream on your way home. I think I will get some at Ben and Jerry’s, on Gloucester Road.

+
+ +

Definition

+

The dfn element is used to highlight the first use of a term. The title attribute can be used to describe the term. Example:

+
+

Bob’s canine mother and equine father sat him down and carefully explained that he was an allopolyploid organism.

+
+ +

Abbreviation

+

The abbr element is used for any abbreviated text, whether it be acronym, initialism, or otherwise. Generally, it’s less work and useful (enough) to mark up only the first occurrence of any particular abbreviation on a page, and ignore the rest. Any text in the title attribute will appear when the user’s mouse hovers the abbreviation (although notably, this does not work in Internet Explorer for Windows). Example abbreviations:

+
+

BBC, HTML, and Staffs.

+
+ +

Time

+

The time element is used to represent either a time on a 24 hour clock, or a precise date in the proleptic Gregorian calendar, optionally with a time and a time-zone offset. Example:

+
+

Queen Elizabeth II was proclaimed sovereign of each of the Commonwealth realms on and , after the death of her father, King George VI.

+
+ +

Code

+

The code element is used to represent fragments of computer code. Useful for technology-oriented sites, not so useful otherwise. Example:

+
+

When you call the activate() method on the robotSnowman object, the eyes glow.

+
+

Used in conjunction with the pre element:

+
+
function getJelly() {
+    echo $aDeliciousSnack;
+}
+
+ +

Variable

+

The var element is used to denote a variable in a mathematical expression or programming context, but can also be used to indicate a placeholder where the contents should be replaced with your own value. Example:

+
+

If there are n pipes leading to the ice cream factory then I expect at least n flavours of ice cream to be available for purchase!

+
+ +

Sample output

+

The samp element is used to represent (sample) output from a program or computing system. Useful for technology-oriented sites, not so useful otherwise. Example:

+
+

The computer said Too much cheese in tray two but I didn’t know what that meant.

+
+ +

Keyboard entry

+

The kbd element is used to denote user input (typically via a keyboard, although it may also be used to represent other input methods, such as voice commands). Example:

+

+

To take a screenshot on your Mac, press ⌘ Cmd + ⇧ Shift + 3.

+
+ +

Superscript and subscript text

+

The sup element represents a superscript and the sub element represents a sub. These elements must be used only to mark up typographical conventions with specific meanings, not for typographical presentation. As a guide, only use these elements if their absence would change the meaning of the content. Example:

+
+

The coordinate of the ith point is (xi, yi). For example, the 10th point has coordinate (x10, y10).

+

f(x, n) = log4xn

+
+ +

Italicised

+

The i element is used for text in an alternate voice or mood, or otherwise offset from the normal prose. Examples include taxonomic designations, technical terms, idiomatic phrases from another language, the name of a ship or other spans of text whose typographic presentation is typically italicised. Example:

+
+

There is a certain je ne sais quoi in the air.

+
+ +

Emboldened

+

The b element is used for text stylistically offset from normal prose without conveying extra importance, such as key words in a document abstract, product names in a review, or other spans of text whose typographic presentation is typically emboldened. Example:

+
+

You enter a small room. Your sword glows brighter. A rat scurries past the corner wall.

+
+ +

Marked or highlighted text

+

The mark element is used to represent a run of text marked or highlighted for reference purposes. When used in a quotation it indicates a highlight not originally present but added to bring the reader’s attention to that part of the text. When used in the main prose of a document, it indicates a part of the document that has been highlighted due to its relevance to the user’s current activity. Example:

+
+

I also have some kittens who are visiting me these days. They’re really cute. I think they like my garden! Maybe I should adopt a kitten.

+
+ +

Edits

+

The del element is used to represent deleted or retracted text which still must remain on the page for some reason. Meanwhile its counterpart, the ins element, is used to represent inserted text. Both del and ins have a datetime attribute which allows you to include a timestamp directly in the element. Example inserted text and usage:

+
+

She bought two five pairs of shoes.

+
+ +

Tabular data

+

Tables should be used when displaying tabular data. The thead, tfoot and tbody elements enable you to group rows within each a table.

+

If you use these elements, you must use every element. They should appear in this order: thead, tfoot and tbody, so that browsers can render the foot before receiving all the data. You must use these tags within the table element.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
The Very Best Eggnog
IngredientsServes 12Serves 24
Milk1 quart2 quart
Cinnamon Sticks21
Vanilla Bean, Split12
Cloves510
Mace10 blades20 blades
Egg Yolks1224
Cups Sugar1 ½ cups3 cups
Dark Rum1 ½ cups3 cups
Brandy1 ½ cups3 cups
Vanilla1 tbsp2 tbsp
Half-and-half or Light Cream1 quart2 quart
Freshly grated nutmeg to taste
+
+ +

Forms

+

Forms can be used when you wish to collect data from users. The fieldset element enables you to group related fields within a form, and each one should contain a corresponding legend. The label element ensures field descriptions are associated with their corresponding form widgets.

+
+
+
+ Legend +
+ + + Note about this field +
+
+ + + Note about this field +
+
+ + + Note about this field +
+
+ + + Note about this field +
+
+ + + Note about this field +
+
+ + +
+
+ + + Note about this selection +
+
+ Checkbox * +
+ + + +
+
+
+
+ Radio + +
+
+
+ + +
+
+
+
+ +

This block is copyright © 2012 Paul Robert Lloyd. Code covered by the MIT license.

diff --git a/ckan/templates/development/snippets/media_grid.html b/ckan/templates/development/snippets/media_grid.html new file mode 100644 index 00000000000..f3f073d6744 --- /dev/null +++ b/ckan/templates/development/snippets/media_grid.html @@ -0,0 +1,5 @@ +
+
+ {% snippet 'group/snippets/group_list.html', groups=groups %} +
+
diff --git a/ckan/templates/development/snippets/module.html b/ckan/templates/development/snippets/module.html new file mode 100644 index 00000000000..7228c7d556a --- /dev/null +++ b/ckan/templates/development/snippets/module.html @@ -0,0 +1,21 @@ +{% with classes = classes or [], hn = heading_level or 1 %} +
+ {% if heading_link %} + {{ heading }} + {% elif heading_action %} + {{ heading }} Clear All + {% elif heading_icon %} + {{ heading }} + {% else %} + {{ heading }} + {% endif %} +
+ {{ lipsum(1) }} +
+ {% if footer %} + + {% endif %} +
+{% endwith %} diff --git a/ckan/templates/development/snippets/nav.html b/ckan/templates/development/snippets/nav.html new file mode 100644 index 00000000000..8db35586efd --- /dev/null +++ b/ckan/templates/development/snippets/nav.html @@ -0,0 +1,14 @@ +
+ {% with items=(("First", false), ("Second", true), ("Third", true), ("Fourth", false), ("Last", false)) %} +

{{ heading }}

+ + {% endwith %} +
diff --git a/ckan/templates/development/snippets/pagination.html b/ckan/templates/development/snippets/pagination.html new file mode 100644 index 00000000000..652857462f3 --- /dev/null +++ b/ckan/templates/development/snippets/pagination.html @@ -0,0 +1,11 @@ +
+ +
diff --git a/ckan/templates/development/snippets/simple-input.html b/ckan/templates/development/snippets/simple-input.html new file mode 100644 index 00000000000..cabe91dfedb --- /dev/null +++ b/ckan/templates/development/snippets/simple-input.html @@ -0,0 +1,4 @@ +
+

Module Narrow Input

+
+
diff --git a/ckan/templates/development/snippets/toolbar.html b/ckan/templates/development/snippets/toolbar.html new file mode 100644 index 00000000000..d2f3e4b087e --- /dev/null +++ b/ckan/templates/development/snippets/toolbar.html @@ -0,0 +1,8 @@ + diff --git a/ckan/templates/error_document_template.html b/ckan/templates/error_document_template.html index 6a1e62028ec..ddfccfac8eb 100644 --- a/ckan/templates/error_document_template.html +++ b/ckan/templates/error_document_template.html @@ -1,14 +1,15 @@ - - - Error ${c.code} +{% extends "page.html" %} -
+{% block subtitle %}{{ gettext('Error %(error_code)s', error_code=c.code[0]) }}{% endblock %} - ${c.content} +{% block primary_content %} + {{ c.content}} +{% endblock %} -
+{% block breadcrumb %} +{% endblock %} - - +{% block flash %} + {# eat the flash messages caused by the 404 #} + {% set flash_messages = h.flash.pop_messages() %} +{% endblock %} diff --git a/ckan/templates/footer.html b/ckan/templates/footer.html new file mode 100644 index 00000000000..dccb1095f86 --- /dev/null +++ b/ckan/templates/footer.html @@ -0,0 +1,36 @@ + diff --git a/ckan/templates/group/base_form_page.html b/ckan/templates/group/base_form_page.html new file mode 100644 index 00000000000..78c84c0babb --- /dev/null +++ b/ckan/templates/group/base_form_page.html @@ -0,0 +1,31 @@ +{% extends "page.html" %} + +{% block breadcrumb_content %} +
  • {{ h.nav_link(_('Groups'), controller='group', action='index') }}
  • +
  • {% block breadcrumb_link %}{{ h.nav_link(_('Add a Group'), controller='group', action='new') }}{% endblock %}
  • +{% endblock %} + +{% block primary_content %} +
    +
    +

    {% block page_heading %}{{ _('Group Form') }}{% endblock %}

    + {% block form %} + {{ c.form | safe }} + {% endblock %} +
    +
    +{% endblock %} + +{% block secondary_content %} +
    +

    {{ _('What are Groups?') }}

    +
    + {% trans %} +

    Whilst tags are great at collecting datasets together, there are + occasions when you want to restrict users from editing a collection.

    +

    A group can be set-up to specify which users have permission to add or + remove datasets from it.

    + {% endtrans %} +
    +
    +{% endblock %} diff --git a/ckan/templates/group/confirm_delete.html b/ckan/templates/group/confirm_delete.html new file mode 100644 index 00000000000..c9b15a89d5d --- /dev/null +++ b/ckan/templates/group/confirm_delete.html @@ -0,0 +1,19 @@ +{% extends "page.html" %} + +{% block subtitle %}{{ _("Confirm Delete") }}{% endblock %} + +{% block maintag %}
    {% endblock %} + +{% block main_content %} +
    +
    +

    {{ _('Are you sure you want to delete group - {name}?').format(name=c.group_dict.name) }}

    +

    +

    + + +
    +

    +
    +
    +{% endblock %} diff --git a/ckan/templates/group/edit.html b/ckan/templates/group/edit.html index 5456ce00356..90734091141 100644 --- a/ckan/templates/group/edit.html +++ b/ckan/templates/group/edit.html @@ -1,16 +1,7 @@ - - - Edit: ${c.group.display_name} - Edit: ${c.group.display_name} - ${h.literal('no-sidebar')} +{% extends "group/base_form_page.html" %} +{% block subtitle %}{{ _('Edit a Group') }}{% endblock %} -
    - ${Markup(c.form)} -
    - - - +{% block breadcrumb_link %}{% link_for _('Edit Group'), controller='group', action='edit', id=c.group.name %}{% endblock %} +{% block page_heading %}{{ _('Edit a Group') }}{% endblock %} diff --git a/ckan/templates/group/index.html b/ckan/templates/group/index.html index 8502df970ce..11367103cf0 100644 --- a/ckan/templates/group/index.html +++ b/ckan/templates/group/index.html @@ -1,24 +1,42 @@ - - - Groups of Datasets - Groups of Datasets +{% extends "page.html" %} - -
  • -

    What Are Groups?

    - Whilst tags are great at collecting datasets together, there are occasions when you want to restrict users from editing a collection. A group can be set-up to specify which users have permission to add or remove datasets from it. -
  • -
    +{% block subtitle %}{{ _('Groups of Datasets') }}{% endblock %} - -
    - ${c.page.pager()} - ${group_list_from_dict(c.page.items)} - ${c.page.pager()} +{% block breadcrumb_content %} +
  • {% link_for _('Groups'), controller='group', action='index' %}
  • +{% endblock %} + +{% block actions_content %} +
  • {% link_for _('Add Group'), controller='group', action='new', class_='btn', icon='plus' %}
  • +{% endblock %} + +{% block primary_content %} +
    +
    +

    {{ _('Groups') }}

    + {% if c.page.items %} + {% snippet "group/snippets/group_list.html", groups=c.page.items %} + {% else %} +

    + {{ _('There are currently no groups for this site') }}. + {% if h.check_access('package_create') %} + {% link_for _('How about creating one?'), controller='group', action='new' %}. + {% endif %} +

    + {% endif %} +
    + {{ c.page.pager() }}
    +{% endblock %} - - +{% block secondary_content %} +
    +

    {{ _('What are Groups?') }}

    +
    + {% trans %} +

    Groups allow you to group together datasets under a organisation (for example, the Department of Health) or topic (e.g. Transport, Health) so make it easier for users to browse datasets by theme.

    +

    Groups also enable you to assign roles and authorisation to members of the group - i.e. individuals can be given the right to publish datasets from a particular organisation.

    + {% endtrans %} +
    +
    +{% endblock %} diff --git a/ckan/templates/group/new.html b/ckan/templates/group/new.html index 3cbd4b972b8..d6c9d4ae319 100644 --- a/ckan/templates/group/new.html +++ b/ckan/templates/group/new.html @@ -1,14 +1,7 @@ - - - Add A Group - Add A Group +{% extends "group/base_form_page.html" %} -
    - ${Markup(c.form)} -
    +{% block subtitle %}{{ _('Create a Group') }}{% endblock %} - - +{% block breadcrumb_link %}{{ h.nav_link(_('Create Group'), controller='group', action='edit', id=c.group.name) }}{% endblock %} +{% block page_heading %}{{ _('Create a Group') }}{% endblock %} diff --git a/ckan/templates/group/new_group_form.html b/ckan/templates/group/new_group_form.html index 9abc6c13cc2..a330c973967 100644 --- a/ckan/templates/group/new_group_form.html +++ b/ckan/templates/group/new_group_form.html @@ -1,131 +1,25 @@ -
    +{% extends "group/snippets/group_form.html" %} - +{# +As the form is rendered as a seperate page we take advantage of this by +overriding the form blocks depending on the current context +#} +{% block dataset_fields %} + {% if action == "edit" %}{{ super() }}{% endif %} +{% endblock %} -
    -

    Errors in form

    -

    The form contains invalid entries:

    -
      -
    • ${"%s: %s" % (key if not key=='Name' else 'URL', error)}
    • -
    -
    +{% block custom_fields %} + {% if action == "edit" %}{{ super() }}{% endif %} +{% endblock %} -
    -
    - -
    - -
    -
    -
    - -
    -
    - ${h.url(controller='group', action='index')+'/'} - -
    -

     

    -

    Warning: URL is very long. Consider changing it to something shorter.

    -

    2+ characters, lowercase, using only 'a-z0-9' and '-_'

    -

    ${errors.get('name', '')}

    -
    -
    -
    - -
    - ${markdown_editor('description', data.get('description'), 'notes', _('Start with a summary sentence ...'))} -
    -
    -
    - -
    - -

    The URL for the image that is associated with this group.

    -
    -
    -
    - -
    - -
    -
    -
    +{% block save_text %} + {%- if action == "edit" -%} + {{ _('Update Group') }} + {%- else -%} + {{ _('Create Group') }} + {%- endif -%} +{% endblock %} -
    -

    Extras

    -
    - - -
    - -
    - - - -
    -
    -
    -
    - -
    - -
    - - -
    -
    -
    -
    -
    -
    - -
    -

    Datasets

    -
    - -
    -
    -
    - - -
    -
    -
    -
    -
    -

    There are no datasets currently in this group.

    - -

    Add datasets

    -
    - -
    - -
    -
    -
    - -
    - - - - -
    - +{% block delete_button %} + {% if action == "edit" %}{{ super() }}{% endif %} +{% endblock %} diff --git a/ckan/templates/group/read.html b/ckan/templates/group/read.html index c6c5839cc61..30160499aab 100644 --- a/ckan/templates/group/read.html +++ b/ckan/templates/group/read.html @@ -1,66 +1,46 @@ - - - - - ${c.group_dict.display_name} - ${c.group_dict.display_name} - - ${c.group.image_url} - - - - -
  • -
      - -
    • -

      Administrators

      -
        -
      • ${h.linked_user(admin)}
      • -
      -
    • -
      -
    -
  • - ${facet_div('tags', _('Tags'))} - ${facet_div('res_format', _('Resource Formats'))} -
    - - -

    State: ${c.group['state']}

    -
    -
    - ${c.description_formatted} -
    -
    - -
    -
    -

    Datasets

    - - ${field_list()} - -

    You searched for "${c.q}". ${c.page.item_count} datasets found.

    - ${c.page.pager()} - ${package_list_from_dict(c.page.items)} - ${c.page.pager()} +{% extends "page.html" %} + +{% block subtitle %}{{ c.group_dict.display_name }}{% endblock %} + +{% block breadcrumb_content %} +
  • {% link_for _('Groups'), controller='group', action='index' %}
  • +
  • {% link_for c.group_dict.display_name|truncate(35), controller='group', action='read', id=c.group_dict.name %}
  • +{% endblock %} + +{% block actions_content %} + {% if h.check_access('group_update', {'id': c.group.id}) %} +
  • {% link_for _('Add Dataset to Group'), controller='package', action='new', group=c.group_dict.id, class_='btn', icon='plus' %}
  • +
  • {% link_for _('Edit'), controller='group', action='edit', id=c.group_dict.name, class_='btn', icon='cog' %}
  • + {% endif %} + {#
  • {% link_for _('History'), controller='group', action='history', id=c.group_dict.name, class_='btn', icon='undo' %}
  • #} +{% endblock %} + +{% block primary_content %} +
    +
    + {% include "package/snippets/search_form.html" %}
    - - - - - - - - - - - + {{ c.page.pager(q=c.q) }} +
    +{% endblock %} + +{% block secondary_content %} + {% snippet 'snippets/group.html', group=c.group_dict %} + +
    +

    {{ _('Administrators') }}

    + +
    + + {{ h.snippet('snippets/facet_list.html', title='Tags', name='tags', extras={'id':c.group_dict.id}) }} + {{ h.snippet('snippets/facet_list.html', title='Formats', name='res_format', extras={'id':c.group_dict.id}) }} +{% endblock %} + +{% block links %} + {{ super() }} + {% include "group/snippets/feeds.html" %} +{% endblock %} diff --git a/ckan/templates/group/snippets/feeds.html b/ckan/templates/group/snippets/feeds.html new file mode 100644 index 00000000000..fb7c48c26c1 --- /dev/null +++ b/ckan/templates/group/snippets/feeds.html @@ -0,0 +1,4 @@ +{%- set dataset_feed = h.url(controller='feed', action='group', id=c.group_dict.name) -%} +{%- set history_feed = h.url(controller='revision', action='list', format='atom', days=1) -%} + + diff --git a/ckan/templates/group/snippets/group_form.html b/ckan/templates/group/snippets/group_form.html new file mode 100644 index 00000000000..6ae19c5b91c --- /dev/null +++ b/ckan/templates/group/snippets/group_form.html @@ -0,0 +1,81 @@ +{% import 'macros/form.html' as form %} + +
    + {% block error_summary %} + {{ form.errors(error_summary) }} + {% endblock %} + + {% block basic_fields %} + {% set attrs = {'data-module': 'slug-preview-target'} %} + {{ form.input('title', label=_('Title'), id='field-title', placeholder=_('My Group'), value=data.title, error=errors.title, classes=['control-full'], attrs=attrs) }} + + {# Perhaps these should be moved into the controller? #} + {% set prefix = h.url_for(controller='group', action='read', id='') %} + {% set domain = h.url_for(controller='group', action='read', id='', qualified=true) %} + {% set domain = domain|replace("http://", "")|replace("https://", "") %} + {% set attrs = {'data-module': 'slug-preview-slug', 'data-module-prefix': domain, 'data-module-placeholder': ''} %} + + {{ form.prepend('name', label=_('URL'), prepend=prefix, id='field-url', placeholder=_('my-group'), value=data.name, error=errors.name, attrs=attrs) }} + + {{ form.markdown('description', label=_('Description'), id='field-description', placeholder=_('A little information about my group...'), value=data.description, error=errors.description) }} + + {{ form.input('image_url', label=_('Image URL'), id='field-image-url', type='url', placeholder=_('http://example.com/my-image.jpg'), value=data.image_url, error=errors.image_url, classes=['control-full']) }} + + {% endblock %} + + {% block custom_fields %} + {% for extra in data.extras %} + {% set prefix = 'extras__%d__' % loop.index0 %} + {{ form.custom( + names=(prefix ~ 'key', prefix ~ 'value', prefix ~ 'deleted'), + id='field-extras-%d' % loop.index, + label=_('Custom Field'), + values=(extra.key, extra.value, extra.deleted), + error=errors[prefix ~ 'key'] or errors[prefix ~ 'value'] + ) }} + {% endfor %} + + {# Add a max if 3 empty columns #} + {% for extra in range(data.extras|count, 3) %} + {% set index = (loop.index0 + data.extras|count) %} + {% set prefix = 'extras__%d__' % index %} + {{ form.custom( + names=(prefix ~ 'key', prefix ~ 'value', prefix ~ 'deleted'), + id='field-extras-%d' % index, + label=_('Custom Field'), + values=(extra.key, extra.value, extra.deleted), + error=errors[prefix ~ 'key'] or errors[prefix ~ 'value'] + ) }} + {% endfor %} + {% endblock %} + + {% block dataset_fields %} + {% if data.packages %} +
    + +
    + {% for dataset in data.packages %} + + {% endfor %} +
    +
    + {% endif %} + + {% set dataset_name = 'packages__%s__name' % data.packages|length %} + {% set dataset_attrs = {'data-module': 'autocomplete', 'data-module-source': '/dataset/autocomplete?q=?'} %} + {{ form.input(dataset_name, label=_('Add Dataset'), id="field-dataset", value=data[dataset_name], classes=['control-medium'], attrs=dataset_attrs) }} + {% endblock %} + +
    + {% block delete_button %} + {% if h.check_access('group_delete', {'id': data.id}) %} + {% set locale = h.dump_json({'content': _('Are you sure you want to delete this Group?')}) %} + {% block delete_button_text %}{{ _('Delete') }}{% endblock %} + {% endif %} + {% endblock %} + +
    + diff --git a/ckan/templates/group/snippets/group_item.html b/ckan/templates/group/snippets/group_item.html new file mode 100644 index 00000000000..ae91e5aac53 --- /dev/null +++ b/ckan/templates/group/snippets/group_item.html @@ -0,0 +1,38 @@ +{# +Renders a media item for a group. This should be used in a list. + +group - A group dict. +first - Pass true if this is the first item in a row. +last - Pass true if this is the last item in a row. + +Example: + +
      + {% for group in groups %} + {% set first = loop.index0 % 3 == 0 %} + {% set last = loop.index0 % 3 == 2 %} + {% snippet "group/snippets/group_item.html", group=group, first=first, last=last %} + {% endfor %} +
    +#} +{% set url = h.url_for(group.type ~ '_read', action='read', id=group.name) %} +
  • + {{ group.name }} +
    +

    + + {{ group.display_name }} + +

    + {% if group.description %} +

    {{ h.truncate(group.description, length=80, whole_word=True) }}

    + {% else %} +

    {{ _('This group has no description') }}

    + {% endif %} + {% if group.packages %} + {{ ungettext('{num} Dataset', '{num} Datasets', group.packages).format(num=group.packages) }} + {% else %} + {{ _('0 Datasets') }} + {% endif %} +
    +
  • diff --git a/ckan/templates/group/snippets/group_list.html b/ckan/templates/group/snippets/group_list.html new file mode 100644 index 00000000000..7f235d0acbd --- /dev/null +++ b/ckan/templates/group/snippets/group_list.html @@ -0,0 +1,17 @@ +{# +Display a grid of group items. + +groups - A list of groups. + +Example: + + {% snippet "group/snippets/group_list.html" %} + +#} +
      + {% for group in groups %} + {% set first = loop.index0 % 3 == 0 %} + {% set last = loop.index0 % 3 == 2 %} + {% snippet "group/snippets/group_item.html", group=group, first=first, last=last %} + {% endfor %} +
    diff --git a/ckan/templates/header.html b/ckan/templates/header.html new file mode 100644 index 00000000000..bacfadfc4a3 --- /dev/null +++ b/ckan/templates/header.html @@ -0,0 +1,50 @@ +
    + {% if config.debug %} +
    Controller : {{ c.controller }}
    Action : {{ c.action }}
    + {% endif %} +
    + {# The .header-image class hides the main text and uses image replacement for the title #} +
    + {% if g.site_logo %} + + {% else %} +

    + {{ g.site_title }} +

    + {% if g.site_description %}

    {{ g.site_description }}

    {% endif %} + {% endif %} +
    +
    + {% if c.userobj %} + + {% else %} + + {% endif %} + + +
    +
    +
    diff --git a/ckan/templates/home/about.html b/ckan/templates/home/about.html index 05b5bc99fb3..b58d4ebf8af 100644 --- a/ckan/templates/home/about.html +++ b/ckan/templates/home/about.html @@ -1,39 +1,24 @@ - - - About - -
    -

    About ${g.site_title}

    - - - - -

    What was the average price of a house in the UK in 1935? When will India's projected population overtake that of China? Where can you see publicly-funded art in Seattle? Data to answer many, many questions like these is out there on the Internet somewhere - but it is not always easy to find.

    - -

    ${g.site_title} is a community-run catalogue of useful sets of data on the Internet. You can collect links here to data from around the web for yourself and others to use, or search for data that others have collected. Depending on the type of data (and its conditions of use), ${g.site_title} may also be able to store a copy of the data or host it in a database, and provide some basic visualisation tools.

    -
    - -

    ${Markup(g.site_about.replace('${g.site_title}', g.site_title))}

    -
    -
    - -

    How it works

    - -

    This site is running a powerful piece of open-source data cataloguing software called CKAN, written and maintained by the Open Knowledge Foundation. Each 'dataset' record on CKAN contains a description of the data and other useful information, such as what formats it is available in, who owns it and whether it is freely available, and what subject areas the data is about. Other users can improve or add to this information (CKAN keeps a fully versioned history).

    - -

    CKAN powers a number of data catalogues on the Internet. The Data Hub is an openly editable open data catalogue, in the style of Wikipedia. The UK Government uses CKAN to run data.gov.uk, which currently lists 8,000 government datasets. Official public data from most European countries is listed in a CKAN catalogue at publicdata.eu. There is a comprehensive list of catalogues like these around the world at datacatalogs.org, which is itself powered by CKAN. -

    - -

    Open data and the Open Knowledge Foundation

    - -

    Most of the data indexed at ${g.site_title} is openly licensed, meaning anyone is free to use or re-use it however they like. Perhaps someone will take that nice dataset of a city's public art that you found, and add it to a tourist map - or even make a neat app for your phone that'll help you find artworks when you visit the city. Open data means more enterprise, collaborative science and transparent government. You can read more about open data in the Open Data Handbook.

    - -

    The Open Knowledge Foundation is a non-profit organisation promoting open knowledge: writing and improving CKAN is one of the ways we do that. If you want to get involved with its design or code, join the discussion or development mailing lists, or take a look at the OKFN site to find out about our other projects.

    - -
    - - - +{% extends "page.html" %} + +{% block subtitle %}{{ _('About') }}{% endblock %} + +{% block breadcrumb_content %} +
  • {% link_for _('About'), controller='home', action='about' %}
  • +{% endblock %} + +{% block primary %} +
    +
    + {% block about %} + {% if g.site_about %} + {{ h.markdown_extract(g.site_about) }} + {% else %} +

    {{ _('About') }}

    + {% snippet 'home/snippets/about_text.html' %} + {% endif %} + {% endblock %} +
    +
    +{% endblock %} + +{% block sidebar %}{% endblock %} diff --git a/ckan/templates/home/index.html b/ckan/templates/home/index.html index 540deecf38a..763dd06646c 100644 --- a/ckan/templates/home/index.html +++ b/ckan/templates/home/index.html @@ -1,83 +1,91 @@ - +{% extends "page.html" %} - ${h.literal('no-sidebar')} - - Welcome +{% block subtitle %}{{ _("Welcome") }}{% endblock %} -
    -
    -

    Welcome to ${g.site_title}!

    +{% block maintag %}{% endblock %} + +{% block content %} +
    +
    + {{ self.flash() }} + {{ self.primary_content() }} +
    +
    +
    +
    + {{ self.secondary_content() }}
    -
    -
    -
    -
    -

    Find data

    +
    +{% endblock %} -
    - -
    -

    ${g.site_title} contains ${c.package_count} datasets that you can - browse, learn about and download.

    -
    -
    -
    -
    -
    - -
    -
    -

    Who else is here?

    -
    -
    -
    - -
    -
    -

    ${group_dict['title']}

    -

    - ${h.markdown_extract(group_dict['description'])} -

    - ${group_dict['title']} has ${group_dict['packages']} datasets. + {% block home_tags %} +
    +

    Popular {{ c.facet_titles.name }}

    + {% set tags = h.get_facet_items_dict('tags', limit=3) %} + {% snippet 'snippets/tag_list.html', tags=tags %}
    -
    - + {% endblock %} + {% endblock %}
    - - +{% endblock %} + +{% block secondary_content %} +
    + {% for group in c.group_package_stuff %} + {% snippet 'snippets/group_item.html', group=group.group_dict, truncate=100 %} + {% endfor %} +
    +{% endblock %} +{# Remove the toolbar. #} +{% block toolbar %}{% endblock %} diff --git a/ckan/templates/home/snippets/about_text.html b/ckan/templates/home/snippets/about_text.html new file mode 100644 index 00000000000..19e086aaa47 --- /dev/null +++ b/ckan/templates/home/snippets/about_text.html @@ -0,0 +1,20 @@ +{% trans %} +

    CKAN, is the world’s leading open-source data portal platform.

    + +

    CKAN is a complete out-of-the-box software solution that makes data +accessible and usable – by providing tools to streamline publishing, sharing, +finding and using data (including storage of data and provision of robust data +APIs). CKAN is aimed at data publishers (national and regional governments, +companies and organizations) wanting to make their data open and available.

    + +

    CKAN, is used by governments and user groups worldwide and powers a variety +of official and community data portals including portals for local, national +and international government, such as the UK’s data.gov.uk and the +European Union’s publicdata.eu, the Brazilian dados.gov.br, Dutch and +Netherland government portals, as well as city and municipal sites in the US, +UK, Argentina, Finland and elsewhere.

    + +

    CKAN: http://ckan.org/
    +CKAN Tour: http://ckan.org/tour/
    +Features overview: http://ckan.org/features/

    +{% endtrans %} diff --git a/ckan/templates/macros/autoform.html b/ckan/templates/macros/autoform.html new file mode 100644 index 00000000000..5774875afd7 --- /dev/null +++ b/ckan/templates/macros/autoform.html @@ -0,0 +1,60 @@ +{# +Builds a form from the supplied form_info list/tuple. All form info dicts +can also contain an "extra_info" key which will add some help text after the +input element. + +form_info - A list of dicts describing the form field to build. +data - The form data object. +errors - The form errors object. +error_summary - A list of errors to display above the fields. + +Example + + {% set form_info = [ + {'name': 'ckan.site_title', 'control': 'input', 'label': _('Site Title'), 'placeholder': _('')}, + {'name': 'ckan.main_css', 'control': 'select', 'options': styles, 'label': _('Style'), 'placeholder': _('')}, + {'name': 'ckan.site_description', 'control': 'input', 'label': _('Site Tag Line'), 'placeholder': _('')}, + {'name': 'ckan.site_logo', 'control': 'input', 'label': _('Site Tag Logo'), 'placeholder': _('')}, + {'name': 'ckan.site_about', 'control': 'markdown', 'label': _('About'), 'placeholder': _('About page text')}, + {'name': 'ckan.site_intro_text', 'control': 'markdown', 'label': _('Intro Text'), 'placeholder': _('Text on home page')}, + {'name': 'ckan.site_custom_css', 'control': 'textarea', 'label': _('Custom CSS'), 'placeholder': _('Customisable css inserted into the page header')}, + ] %} + + {% import 'macros/autoform.html' as autoform %} + {{ autoform.generate(form_info, data, errors) }} + +#} +{% import 'macros/form.html' as form %} +{%- macro generate(form_info=[], data={}, errors={}, error_summary=[]) -%} + {{ form.errors(error_summary) if error_summary }} + + {% for item in form_info %} + {% set name = item.name %} + {% set value = data.get(name) %} + {% set error = errors.get(name) %} + {% set id = 'field-%s' % (name|lower|replace('_', '-')|replace('.', '-')) %} + + {% set control = item.control or 'input' %} + {% set label = item.label %} + {% set placeholder = item.placeholder %} + + {% set classes = item.classes or [] %} + {% set classes = ['control-medium'] if not classes and control == 'input' %} + + {% if control == 'select' %} + {% call form.select(name, id=id, label=label, options=item.options, selected=value, error=error) %} + {% if item.extra_info %}{{ form.info(item.extra_info) }}{% endif %} + {% endcall %} + {% elif control == 'html' %} +
    +
    + {{ item.html }} +
    +
    + {% else %} + {% call form[control](name, id=id, label=label, placeholder=placeholder, value=value, error=error, classes=classes) %} + {% if item.extra_info %}{{ form.info(item.extra_info) }}{% endif %} + {% endcall %} + {% endif %} + {% endfor %} +{%- endmacro -%} diff --git a/ckan/templates/macros/form.html b/ckan/templates/macros/form.html new file mode 100644 index 00000000000..6eb41dae25b --- /dev/null +++ b/ckan/templates/macros/form.html @@ -0,0 +1,328 @@ +{# +Creates all the markup required for an input element. Handles matching labels to +inputs, error messages and other useful elements. + +name - The name of the form parameter. +id - The id to use on the input and label. Convention is to prefix with 'field-'. +label - The human readable label. +value - The value of the input. +placeholder - Some placeholder text. +type - The type of input eg. email, url, date (default: text). +error - A list of error strings for the field or just true to highlight the field. +classes - An array of classes to apply to the control-group. + +Examples: + + {% import 'macros/form.html' as form %} + {{ form.input('title', label=_('Title'), value=data.title, error=errors.title) }} + +#} +{% macro input(name, id='', label='', value='', placeholder='', type='text', error="", classes=[], attrs={}) %} + {%- set extra_html = caller() if caller -%} + + {% call input_block(id or name, label or name, error, classes, extra_html=extra_html) %} + + {% endcall %} +{% endmacro %} + +{# +Builds a single checkbox input. + +name - The name of the form parameter. +id - The id to use on the input and label. Convention is to prefix with 'field-'. +label - The human readable label. +value - The value of the input. +checked - If true the checkbox will be checked +error - An error string for the field or just true to highlight the field. +classes - An array of classes to apply to the control-group. + +Example: + + {% import 'macros/form.html' as form %} + {{ form.checkbox('remember', checked=true) }} + +#} +{% macro checkbox(name, id='', label='', value='', checked=false, placeholder='', error="", classes=[], attrs={}) %} + {%- set extra_html = caller() if caller -%} +
    +
    + + {{ extra_html }} +
    +
    +{% endmacro %} + +{# +Creates all the markup required for an select element. Handles matching labels to +inputs and error messages. + +A field should be a dict with a "value" key and an optional "text" key which +will be displayed to the user. We use a dict to easily allow extension in +future should extra options be required. + +name - The name of the form parameter. +id - The id to use on the input and label. Convention is to prefix with 'field-'. +label - The human readable label. +options - A list/tuple of fields to be used as . +selected - The value of the selected