diff --git a/ckan/config/environment.py b/ckan/config/environment.py index 46fe8d6ee13..013774f045e 100644 --- a/ckan/config/environment.py +++ b/ckan/config/environment.py @@ -81,8 +81,11 @@ def find_controller(self, controller): 'ckan.site_id for SOLR search-index rebuild to work.' config['ckan.site_id'] = ckan_host - # Check if SOLR schema is compatible - from ckan.lib.search import check_solr_schema_version + # Init SOLR settings and check if the schema is compatible + from ckan.lib.search import SolrSettings, check_solr_schema_version + SolrSettings.init(config.get('solr_url'), + config.get('solr_user'), + config.get('solr_password')) check_solr_schema_version() config['routes.map'] = make_map() diff --git a/ckan/config/routing.py b/ckan/config/routing.py index 5716981a2f4..a5629617dc8 100644 --- a/ckan/config/routing.py +++ b/ckan/config/routing.py @@ -8,14 +8,17 @@ from pylons import config from routes import Mapper from ckan.plugins import PluginImplementations, IRoutes -from ckan.controllers.package import register_pluggable_behaviour as register_package_behaviour -from ckan.controllers.group import register_pluggable_behaviour as register_group_behaviour routing_plugins = PluginImplementations(IRoutes) def make_map(): """Create, configure and return the routes Mapper""" + # import controllers here rather than at root level because + # pylons config is initialised by this point. + from ckan.controllers.package import register_pluggable_behaviour as register_package_behaviour + from ckan.controllers.group import register_pluggable_behaviour as register_group_behaviour + map = Mapper(directory=config['pylons.paths']['controllers'], always_scan=config['debug']) map.minimization = False @@ -81,6 +84,10 @@ def make_map(): controller='api', action='list', requirements=dict(register=register_list_str), conditions=dict(method=['GET'])) + map.connect('/api/{ver:1|2}/rest/{register}/{id}/:subregister', + controller='api', action='create', + requirements=dict(register=register_list_str), + conditions=dict(method=['POST'])) map.connect('/api/{ver:1|2}/rest/{register}/{id}/:subregister/{id2}', controller='api', action='create', requirements=dict(register=register_list_str), @@ -137,6 +144,10 @@ def make_map(): controller='api', action='list', requirements=dict(register=register_list_str), conditions=dict(method=['GET'])) + map.connect('/api/rest/{register}/{id}/:subregister', + controller='api', action='create', + requirements=dict(register=register_list_str), + conditions=dict(method=['POST'])) map.connect('/api/rest/{register}/{id}/:subregister/{id2}', controller='api', action='create', requirements=dict(register=register_list_str), diff --git a/ckan/controllers/api.py b/ckan/controllers/api.py index 728b464428b..02e59f30d57 100644 --- a/ckan/controllers/api.py +++ b/ckan/controllers/api.py @@ -271,8 +271,8 @@ def _represent_package(self, package): def create(self, ver=None, register=None, subregister=None, id=None, id2=None): action_map = { - ('dataset', 'relationships'): get_action('package_relationship_create'), - ('package', 'relationships'): get_action('package_relationship_create'), + ('dataset', 'relationships'): get_action('package_relationship_create_rest'), + ('package', 'relationships'): get_action('package_relationship_create_rest'), 'group': get_action('group_create_rest'), 'dataset': get_action('package_create_rest'), 'package': get_action('package_create_rest'), @@ -280,8 +280,8 @@ def create(self, ver=None, register=None, subregister=None, id=None, id2=None): } for type in model.PackageRelationship.get_all_types(): - action_map[('dataset', type)] = get_action('package_relationship_create') - action_map[('package', type)] = get_action('package_relationship_create') + action_map[('dataset', type)] = get_action('package_relationship_create_rest') + action_map[('package', type)] = get_action('package_relationship_create_rest') context = {'model': model, 'session': model.Session, 'user': c.user, 'api_version': ver} @@ -301,6 +301,7 @@ def create(self, ver=None, register=None, subregister=None, id=None, id2=None): return self._finish_bad_request( gettext('Cannot create new entity of this type: %s %s') % \ (register, subregister)) + try: response_data = action(context, data_dict) location = None @@ -330,17 +331,16 @@ def create(self, ver=None, register=None, subregister=None, id=None, id2=None): raise def update(self, ver=None, register=None, subregister=None, id=None, id2=None): - action_map = { - ('dataset', 'relationships'): get_action('package_relationship_update'), - ('package', 'relationships'): get_action('package_relationship_update'), + ('dataset', 'relationships'): get_action('package_relationship_update_rest'), + ('package', 'relationships'): get_action('package_relationship_update_rest'), 'dataset': get_action('package_update_rest'), 'package': get_action('package_update_rest'), 'group': get_action('group_update_rest'), } for type in model.PackageRelationship.get_all_types(): - action_map[('dataset', type)] = get_action('package_relationship_update') - action_map[('package', type)] = get_action('package_relationship_update') + action_map[('dataset', type)] = get_action('package_relationship_update_rest') + action_map[('package', type)] = get_action('package_relationship_update_rest') context = {'model': model, 'session': model.Session, 'user': c.user, 'api_version': ver, 'id': id} @@ -381,15 +381,15 @@ def update(self, ver=None, register=None, subregister=None, id=None, id2=None): def delete(self, ver=None, register=None, subregister=None, id=None, id2=None): action_map = { - ('dataset', 'relationships'): get_action('package_relationship_delete'), - ('package', 'relationships'): get_action('package_relationship_delete'), + ('dataset', 'relationships'): get_action('package_relationship_delete_rest'), + ('package', 'relationships'): get_action('package_relationship_delete_rest'), 'group': get_action('group_delete'), 'dataset': get_action('package_delete'), 'package': get_action('package_delete'), } for type in model.PackageRelationship.get_all_types(): - action_map[('dataset', type)] = get_action('package_relationship_delete') - action_map[('package', type)] = get_action('package_relationship_delete') + action_map[('dataset', type)] = get_action('package_relationship_delete_rest') + action_map[('package', type)] = get_action('package_relationship_delete_rest') context = {'model': model, 'session': model.Session, 'user': c.user, 'api_version': ver} diff --git a/ckan/controllers/group.py b/ckan/controllers/group.py index b1361b54edc..d683c8f992f 100644 --- a/ckan/controllers/group.py +++ b/ckan/controllers/group.py @@ -1,8 +1,9 @@ import genshi import datetime +from urllib import urlencode from sqlalchemy.orm import eagerload_all -from ckan.lib.base import BaseController, c, model, request, render, h +from ckan.lib.base import BaseController, c, model, request, render, h, g from ckan.lib.base import ValidationException, abort, gettext from pylons.i18n import get_lang, _ import ckan.authz as authz @@ -177,7 +178,7 @@ def _setup_template_variables(self, context, data_dict, group_type=None): return _lookup_plugin(group_type).setup_template_variables(context,data_dict) ## end hooks - + def index(self): context = {'model': model, 'session': model.Session, @@ -202,11 +203,14 @@ def index(self): def read(self, id): + from ckan.lib.search import SearchError group_type = self._get_group_type(id.split('@')[0]) context = {'model': model, 'session': model.Session, 'user': c.user or c.author, 'schema': self._form_to_db_schema(group_type=type)} data_dict = {'id': id} + q = c.q = request.params.get('q', '') # unicode format (decoded from utf8) + try: c.group_dict = get_action('group_show')(context, data_dict) c.group = context['group'] @@ -214,35 +218,91 @@ def read(self, id): abort(404, _('Group not found')) except NotAuthorized: abort(401, _('Unauthorized to read group %s') % id) + + # Search within group + q += ' groups: "%s"' % c.group_dict.get('name') + try: - description_formatted = ckan.misc.MarkdownFormat().to_html(c.group.get('description','')) + description_formatted = ckan.misc.MarkdownFormat().to_html(c.group_dict.get('description','')) c.description_formatted = genshi.HTML(description_formatted) except Exception, e: error_msg = "%s" % _("Cannot render description") c.description_formatted = genshi.HTML(error_msg) - try: - desc_formatted = ckan.misc.MarkdownFormat().to_html(c.group.description) - desc_formatted = genshi.HTML(desc_formatted) - except genshi.ParseError, e: - desc_formatted = 'Error: Could not parse group description' - c.group_description_formatted = desc_formatted c.group_admins = self.authorizer.get_admins(c.group) context['return_query'] = True - results = get_action('group_package_show')(context, data_dict) - c.page = Page( - collection=results, - page=request.params.get('page', 1), - url=h.pager_url, - items_per_page=30 - ) + limit = 20 + try: + page = int(request.params.get('page', 1)) + except ValueError, e: + abort(400, ('"page" parameter must be an integer')) + + # most search operations should reset the page counter: + params_nopage = [(k, v) for k,v in request.params.items() if k != 'page'] + + def search_url(params): + url = h.url_for(controller='group', action='read', id=c.group_dict.get('name')) + params = [(k, v.encode('utf-8') if isinstance(v, basestring) else str(v)) \ + for k, v in params] + return url + u'?' + urlencode(params) + + def drill_down_url(**by): + params = list(params_nopage) + params.extend(by.items()) + return search_url(set(params)) + + c.drill_down_url = drill_down_url + + def remove_field(key, value): + params = list(params_nopage) + params.remove((key, value)) + return search_url(params) - result = [] - for pkg_rev in c.page.items: - result.append(package_dictize(pkg_rev, context)) - c.page.items = result + c.remove_field = remove_field + + def pager_url(q=None, page=None): + params = list(params_nopage) + params.append(('page', page)) + return search_url(params) + + try: + c.fields = [] + search_extras = {} + for (param, value) in request.params.items(): + if not param in ['q', 'page'] \ + and len(value) and not param.startswith('_'): + if not param.startswith('ext_'): + c.fields.append((param, value)) + q += ' %s: "%s"' % (param, value) + else: + search_extras[param] = value + + data_dict = { + 'q':q, + 'facet.field':g.facets, + 'rows':limit, + 'start':(page-1)*limit, + 'extras':search_extras + } + + query = get_action('package_search')(context,data_dict) + + c.page = h.Page( + collection=query['results'], + page=page, + url=pager_url, + item_count=query['count'], + items_per_page=limit + ) + c.facets = query['facets'] + c.page.items = query['results'] + except SearchError, se: + log.error('Group search error: %r', se.args) + c.query_error = True + c.facets = {} + c.page = h.Page(collection=[]) # Add the group's activity stream (already rendered to HTML) to the # template context for the group/read.html template to retrieve later. diff --git a/ckan/controllers/package.py b/ckan/controllers/package.py index 82c2f5979e6..cb468cdaf54 100644 --- a/ckan/controllers/package.py +++ b/ckan/controllers/package.py @@ -17,7 +17,6 @@ from ckan.lib.base import request, c, BaseController, model, abort, h, g, render from ckan.lib.base import response, redirect, gettext from ckan.authz import Authorizer -from ckan.lib.search import SearchIndexError, SearchError from ckan.lib.package_saver import PackageSaver, ValidationException from ckan.lib.navl.dictization_functions import DataError, unflatten, validate from ckan.lib.helpers import json @@ -207,6 +206,7 @@ def _setup_template_variables(self, context, data_dict, package_type=None): authorizer = ckan.authz.Authorizer() def search(self): + from ckan.lib.search import SearchError try: context = {'model':model,'user': c.user or c.author} check_access('site_read',context) @@ -278,6 +278,7 @@ def pager_url(q=None, page=None): c.facets = query['facets'] c.page.items = query['results'] except SearchError, se: + log.error('Package search error: %r', se.args) c.query_error = True c.facets = {} c.page = h.Page(collection=[]) @@ -456,14 +457,20 @@ def new(self, data=None, errors=None, error_summary=None): if context['save'] and not data: return self._save_new(context) - data = data or dict(request.params) + data = data or clean_dict(unflatten(tuplize_dict(parse_params(request.params)))) + errors = errors or {} error_summary = error_summary or {} vars = {'data': data, 'errors': errors, 'error_summary': error_summary} self._setup_template_variables(context, {'id': id}) - c.form = render(self._package_form(package_type=package_type), extra_vars=vars) + # 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'): + c.form = render(self.package_form, extra_vars=vars) + else: + c.form = render(self._package_form(package_type=package_type), extra_vars=vars) return render('package/new.html') @@ -504,7 +511,12 @@ def edit(self, id, data=None, errors=None, error_summary=None): self._setup_template_variables(context, {'id': id}, package_type=package_type) - c.form = render(self._package_form(package_type=package_type), extra_vars=vars) + # 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'): + c.form = render(self.package_form, extra_vars=vars) + else: + c.form = render(self._package_form(package_type=package_type), extra_vars=vars) return render('package/edit.html') def read_ajax(self, id, revision=None): @@ -590,6 +602,7 @@ def _get_package_type(self, id): return data['type'] def _save_new(self, context, package_type=None): + from ckan.lib.search import SearchIndexError try: data_dict = clean_dict(unflatten( tuplize_dict(parse_params(request.POST)))) @@ -613,6 +626,7 @@ def _save_new(self, context, package_type=None): return self.new(data_dict, errors, error_summary) def _save_edit(self, id, context): + from ckan.lib.search import SearchIndexError try: package_type = self._get_package_type(id) data_dict = clean_dict(unflatten( diff --git a/ckan/forms/group.py b/ckan/forms/group.py index 8b081435fd9..e6f34e4c2de 100644 --- a/ckan/forms/group.py +++ b/ckan/forms/group.py @@ -49,7 +49,7 @@ def render(self, **kwargs): def build_group_form(is_admin=False, with_packages=False): builder = FormBuilder(model.Group) - builder.set_field_text('name', _('Name'), literal("
Unique identifier for group.
2+ chars, lowercase, using only 'a-z0-9' and '-_'")) + builder.set_field_text('name', _('Name'), literal("Unique identifier for group.
2+ chars, lowercase, using only 'a-z0-9' and '-_'

")) builder.set_field_option('name', 'validate', common.group_name_validator) builder.set_field_option('description', 'textarea', {'size':'60x15'}) builder.add_field(ExtrasField('extras', hidden_label=True)) diff --git a/ckan/lib/cli.py b/ckan/lib/cli.py index 7c5b3b4054b..3605b04b7bc 100644 --- a/ckan/lib/cli.py +++ b/ckan/lib/cli.py @@ -32,6 +32,9 @@ class CkanCommand(paste.script.command.Command): group_name = 'ckan' def _load_config(self): + # Avoids vdm logging warning + logging.basicConfig(level=logging.ERROR) + from paste.deploy import appconfig from ckan.config.environment import load_environment if not self.options.config: @@ -65,8 +68,9 @@ class ManageDb(CkanCommand): db version # returns current version of data schema db dump {file-path} # dump to a pg_dump file db dump-rdf {dataset-name} {file-path} - db simple-dump-csv {file-path} - db simple-dump-json {file-path} + db simple-dump-csv {file-path} # dump just datasets in CSV format + db simple-dump-json {file-path} # dump just datasets in JSON format + db user-dump-csv {file-path} # dump user information to a CSV file db send-rdf {talis-store} {username} {password} db load {file-path} # load a pg_dump from a file db load-only {file-path} # load a pg_dump from a file but don\'t do @@ -78,10 +82,7 @@ class ManageDb(CkanCommand): max_args = None min_args = 1 - def command(self): - # Avoids vdm logging warning - logging.basicConfig(level=logging.ERROR) - + def command(self): self._load_config() from ckan import model import ckan.lib.search as search @@ -115,6 +116,8 @@ def command(self): self.simple_dump_json() elif cmd == 'dump-rdf': self.dump_rdf() + elif cmd == 'user-dump-csv': + self.user_dump_csv() elif cmd == 'create-from-model': model.repo.create_db() if self.verbose: @@ -238,6 +241,15 @@ def dump_rdf(self): f.write(rdf) f.close() + def user_dump_csv(self): + if len(self.args) < 2: + print 'Need csv file path' + return + dump_filepath = self.args[1] + import ckan.lib.dumper as dumper + dump_file = open(dump_filepath, 'w') + dumper.UserDumper().dump(dump_file) + def send_rdf(self): if len(self.args) < 4: print 'Need all arguments: {talis-store} {username} {password}' diff --git a/ckan/lib/dumper.py b/ckan/lib/dumper.py index 76e063820bd..e463017ae80 100644 --- a/ckan/lib/dumper.py +++ b/ckan/lib/dumper.py @@ -4,7 +4,7 @@ import ckan.model as model import ckan.model -from helpers import json +from helpers import json, OrderedDict class SimpleDumper(object): '''Dumps just package data but including tags, groups, license text etc''' @@ -40,7 +40,7 @@ def dump_csv(self, dump_file_obj, query): pkg_dict[name_] = value_ del pkg_dict[name] row_dicts.append(pkg_dict) - writer = PackagesCsvWriter(row_dicts) + writer = CsvWriter(row_dicts) writer.save(dump_file_obj) def dump_json(self, dump_file_obj, query): @@ -201,7 +201,7 @@ def migrate_06_to_07(self): values={'name': record.name}) update.execute() -class PackagesCsvWriter: +class CsvWriter: def __init__(self, package_dict_list=None): self._rows = [] self._col_titles = [] @@ -301,3 +301,25 @@ def pkg_to_xl_dict(pkg): dict_[key_] = value_ del dict_[key] return dict_ + +class UserDumper(object): + def dump(self, dump_file_obj): + query = model.Session.query(model.User) + query = query.order_by(model.User.created.asc()) + + columns = (('id', 'name', 'openid', 'fullname', 'email', 'created', 'about')) + row_dicts = [] + for user in query: + row = OrderedDict() + for col in columns: + value = getattr(user, col) + if not value: + value = '' + if col == 'created': + value = str(value) # or maybe dd/mm/yyyy? + row[col] = value + row_dicts.append(row) + + writer = CsvWriter(row_dicts) + writer.save(dump_file_obj) + dump_file_obj.close() diff --git a/ckan/lib/hash.py b/ckan/lib/hash.py index 3b17778170e..8aae27ae3b8 100644 --- a/ckan/lib/hash.py +++ b/ckan/lib/hash.py @@ -3,9 +3,15 @@ from pylons import config, request -secret = config['beaker.session.secret'] +global secret +secret = None def get_message_hash(value): + if not secret: + global secret + # avoid getting config value at module scope since config may + # not be read in yet + secret = config['beaker.session.secret'] return hmac.new(secret, value, hashlib.sha1).hexdigest() def get_redirect(): diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py index a1d79376ca9..51b645c04ed 100644 --- a/ckan/lib/helpers.py +++ b/ckan/lib/helpers.py @@ -206,8 +206,7 @@ def linked_user(user, maxlength=0): _name = user.name if model.User.VALID_NAME.match(user.name) else user.id # Absolute URL of default user icon from pylons import config - _site_url = config.get('ckan.site_url', '') - _icon_url_default = _site_url + icon_url("user") + _icon_url_default = icon_url("user") _icon = gravatar(user.email_hash, 16, _icon_url_default)+" " displayname = user.display_name if maxlength and len(user.display_name) > maxlength: @@ -230,7 +229,7 @@ def markdown_extract(text, extract_length=190): return unicode(truncate(plain, length=extract_length, indicator='...', whole_word=True)) def icon_url(name): - return '/images/icons/%s.png' % name + return url_for('/images/icons/%s.png' % name) def icon_html(url, alt=None): return literal('%s ' % (url, alt)) diff --git a/ckan/lib/navl/dictization_functions.py b/ckan/lib/navl/dictization_functions.py index 30f10afb3a7..88818d2aaac 100644 --- a/ckan/lib/navl/dictization_functions.py +++ b/ckan/lib/navl/dictization_functions.py @@ -60,9 +60,10 @@ def flatten_schema(schema, flattened=None, key=None): return flattened def get_all_key_combinations(data, flattented_schema): - '''compare the schema agaist the given data and get all valid tuples that - match the schema ignoring the last value in the tuple.''' + '''Compare the schema against the given data and get all valid tuples that + match the schema ignoring the last value in the tuple. + ''' schema_prefixes = set([key[:-1] for key in flattented_schema]) combinations = set([()]) @@ -206,7 +207,7 @@ def _remove_blank_keys(schema): return schema def validate(data, schema, context=None): - '''validate an unflattened nested dict agiast a schema''' + '''Validate an unflattened nested dict against a schema.''' context = context or {} diff --git a/ckan/lib/search/__init__.py b/ckan/lib/search/__init__.py index 30e033e522e..0963e2301d5 100644 --- a/ckan/lib/search/__init__.py +++ b/ckan/lib/search/__init__.py @@ -7,7 +7,7 @@ from ckan.logic import get_action from common import (SearchIndexError, SearchError, SearchQueryError, - make_connection, is_available, DEFAULT_SOLR_URL) + make_connection, is_available, SolrSettings) from index import PackageSearchIndex, NoopSearchIndex from query import TagSearchQuery, ResourceSearchQuery, PackageSearchQuery, QueryOptions, convert_legacy_parameters_to_solr @@ -197,15 +197,13 @@ def check_solr_schema_version(schema_file=None): # Try to get the schema XML file to extract the version if not schema_file: - solr_user = config.get('solr_user') - solr_password = config.get('solr_password') + solr_url, solr_user, solr_password = SolrSettings.get() http_auth = None if solr_user is not None and solr_password is not None: http_auth = solr_user + ':' + solr_password http_auth = 'Basic ' + http_auth.encode('base64').strip() - solr_url = config.get('solr_url', DEFAULT_SOLR_URL) url = solr_url.strip('/') + SOLR_SCHEMA_FILE_OFFSET req = urllib2.Request(url = url) diff --git a/ckan/lib/search/common.py b/ckan/lib/search/common.py index 7ea8b5d1ab8..8780f99ea1f 100644 --- a/ckan/lib/search/common.py +++ b/ckan/lib/search/common.py @@ -8,9 +8,29 @@ class SearchQueryError(SearchError): pass DEFAULT_SOLR_URL = 'http://127.0.0.1:8983/solr' -solr_url = config.get('solr_url', DEFAULT_SOLR_URL) -solr_user = config.get('solr_user') -solr_password = config.get('solr_password') +class SolrSettings(object): + _is_initialised = False + _url = None + _user = None + _password = None + + @classmethod + def init(cls, url, user=None, password=None): + if url is not None: + cls._url = url + cls._user = user + cls._password = password + else: + cls._url = DEFAULT_SOLR_URL + cls._is_initialised = True + + @classmethod + def get(cls): + if not cls._is_initialised: + raise SearchIndexError('SOLR URL not initialised') + if not cls._url: + raise SearchIndexError('SOLR URL is blank') + return (cls._url, cls._user, cls._password) def is_available(): """ @@ -23,12 +43,15 @@ def is_available(): log.exception(e) return False finally: - conn.close() + if 'conn' in dir(): + conn.close() return True def make_connection(): from solr import SolrConnection + solr_url, solr_user, solr_password = SolrSettings.get() + assert solr_url is not None if solr_user is not None and solr_password is not None: return SolrConnection(solr_url, http_user=solr_user, http_pass=solr_password) else: diff --git a/ckan/lib/search/index.py b/ckan/lib/search/index.py index 8ae7390256a..2a538f3e55d 100644 --- a/ckan/lib/search/index.py +++ b/ckan/lib/search/index.py @@ -6,6 +6,7 @@ from pylons import config from common import SearchIndexError, make_connection +from ckan.model import PackageRelationship log = logging.getLogger(__name__) @@ -15,23 +16,24 @@ SOLR_FIELDS = [TYPE_FIELD, "res_url", "text", "urls", "indexed_ts", "site_id"] RESERVED_FIELDS = SOLR_FIELDS + ["tags", "groups", "res_description", "res_format", "res_url"] -# HACK: this is copied over from model.PackageRelationship -RELATIONSHIP_TYPES = [ - (u'depends_on', u'dependency_of'), - (u'derives_from', u'has_derivation'), - (u'links_to', u'linked_from'), - (u'child_of', u'parent_of'), -] +RELATIONSHIP_TYPES = PackageRelationship.types def clear_index(): + import solr.core conn = make_connection() query = "+site_id:\"%s\"" % (config.get('ckan.site_id')) try: conn.delete_query(query) conn.commit() except socket.error, e: - log.error('Could not connect to SOLR: %r' % e) + err = 'Could not connect to SOLR %r: %r' % (conn.url, e) + log.error(err) + raise SearchIndexError(err) raise +## except solr.core.SolrException, e: +## err = 'SOLR %r exception: %r' % (conn.url, e) +## log.error(err) +## raise SearchIndexError(err) finally: conn.close() diff --git a/ckan/lib/search/query.py b/ckan/lib/search/query.py index 0cd266304ad..3e1904c3921 100644 --- a/ckan/lib/search/query.py +++ b/ckan/lib/search/query.py @@ -292,7 +292,6 @@ def run(self, query): conn = make_connection() log.debug('Package query: %r' % query) - try: solr_response = conn.raw_query(**query) except SolrException, e: diff --git a/ckan/lib/search/sql.py b/ckan/lib/search/sql.py index d92681e7e24..fc86aa3a096 100644 --- a/ckan/lib/search/sql.py +++ b/ckan/lib/search/sql.py @@ -24,7 +24,7 @@ def run(self, query): def makelike(field): _attr = getattr(model.Package, field) return _attr.ilike('%' + term + '%') - if q and not (q == '""' or q == "''"): + if q and q not in ('""', "''", '*:*'): terms = q.split() # TODO: tags ...? fields = ['name', 'title', 'notes'] diff --git a/ckan/logic/action/__init__.py b/ckan/logic/action/__init__.py index e69de29bb2d..cd5e3eb086d 100644 --- a/ckan/logic/action/__init__.py +++ b/ckan/logic/action/__init__.py @@ -0,0 +1,21 @@ +from copy import deepcopy + +def rename_keys(dict_, key_map, reverse=False, destructive=False): + '''Returns a dict that has particular keys renamed, + according to the key_map. + + Rename is by default non-destructive, so if the intended new + key name already exists, it won\'t do that rename. + + To reverse the change, set reverse=True.''' + new_dict = deepcopy(dict_) + for key, mapping in key_map.items(): + if reverse: + key, mapping = (mapping, key) + if (not destructive) and new_dict.has_key(mapping): + continue + if dict_.has_key(key): + value = dict_[key] + new_dict[mapping] = value + del new_dict[key] + return new_dict diff --git a/ckan/logic/action/create.py b/ckan/logic/action/create.py index 296f0c85190..4e88ef50ee5 100644 --- a/ckan/logic/action/create.py +++ b/ckan/logic/action/create.py @@ -26,13 +26,16 @@ activity_dictize) -from ckan.logic.schema import default_create_package_schema, default_resource_schema +from ckan.logic.schema import default_create_package_schema, default_resource_schema, default_create_relationship_schema from ckan.logic.schema import default_group_schema, default_user_schema from ckan.lib.navl.dictization_functions import validate from ckan.logic.action.update import (_update_package_relationship, package_error_summary, - group_error_summary) + group_error_summary, + relationship_error_summary) +from ckan.logic.action import rename_keys + log = logging.getLogger(__name__) def package_create(context, data_dict): @@ -110,25 +113,31 @@ def package_relationship_create(context, data_dict): model = context['model'] user = context['user'] - id = data_dict["id"] - id2 = data_dict["id2"] - rel_type = data_dict["rel"] + schema = context.get('schema') or default_create_relationship_schema() api = context.get('api_version') or '1' ref_package_by = 'id' if api == '2' else 'name' - # Create a Package Relationship. + id = data_dict['subject'] + id2 = data_dict['object'] + rel_type = data_dict['type'] + comment = data_dict.get('comment', u'') + pkg1 = model.Package.get(id) pkg2 = model.Package.get(id2) if not pkg1: - raise NotFound('First package named in address was not found.') + raise NotFound('Subject package %r was not found.' % id) if not pkg2: - return NotFound('Second package named in address was not found.') + return NotFound('Object package %r was not found.' % id2) - check_access('package_relationship_create', context, data_dict) + data, errors = validate(data_dict, schema, context) - ##FIXME should have schema - comment = data_dict.get('comment', u'') + if errors: + model.Session.rollback() + raise ValidationError(errors, relationship_error_summary(errors)) + + check_access('package_relationship_create', context, data_dict) + # Create a Package Relationship. existing_rels = pkg1.get_relationships_with(pkg2, rel_type) if existing_rels: return _update_package_relationship(existing_rels[0], @@ -139,6 +148,8 @@ def package_relationship_create(context, data_dict): rel = pkg1.add_relationship(rel_type, pkg2, comment=comment) if not context.get('defer_commit'): model.repo.commit_and_remove() + context['relationship'] = rel + relationship_dicts = rel.as_dict(ref_package_by=ref_package_by) return relationship_dicts @@ -331,3 +342,15 @@ def activity_create(context, activity_dict): log.debug("Created '%s' activity" % activity.activity_type) return activity_dictize(activity, context) + +def package_relationship_create_rest(context, data_dict): + # rename keys + key_map = {'id': 'subject', + 'id2': 'object', + 'rel': 'type'} + # Don't be destructive to enable parameter values for + # object and type to override the URL parameters. + data_dict = rename_keys(data_dict, key_map, destructive=False) + + relationship_dict = package_relationship_create(context, data_dict) + return relationship_dict diff --git a/ckan/logic/action/delete.py b/ckan/logic/action/delete.py index 498e70f7f32..f8f8c14bb02 100644 --- a/ckan/logic/action/delete.py +++ b/ckan/logic/action/delete.py @@ -1,6 +1,7 @@ from ckan.logic import NotFound from ckan.lib.base import _ from ckan.logic import check_access +from ckan.logic.action import rename_keys from ckan.plugins import PluginImplementations, IGroupController, IPackageController @@ -32,18 +33,16 @@ def package_relationship_delete(context, data_dict): model = context['model'] user = context['user'] - id = data_dict['id'] - id2 = data_dict['id2'] - rel = data_dict['rel'] + id = data_dict['subject'] + id2 = data_dict['object'] + rel = data_dict['type'] pkg1 = model.Package.get(id) pkg2 = model.Package.get(id2) if not pkg1: - raise NotFound('First package named in address was not found.') + raise NotFound('Subject package %r was not found.' % id) if not pkg2: - return NotFound('Second package named in address was not found.') - - check_access('package_relationship_delete', context, data_dict) + return NotFound('Object package %r was not found.' % id2) existing_rels = pkg1.get_relationships_with(pkg2, rel) if not existing_rels: @@ -53,7 +52,7 @@ def package_relationship_delete(context, data_dict): revisioned_details = 'Package Relationship: %s %s %s' % (id, rel, id2) context['relationship'] = relationship - check_access('relationship_delete', context, data_dict) + check_access('package_relationship_delete', context, data_dict) rev = model.repo.new_revision() rev.author = user @@ -103,3 +102,19 @@ def task_status_delete(context, data_dict): entity.delete() model.Session.commit() + +def package_relationship_delete_rest(context, data_dict): + + # rename keys + key_map = {'id': 'subject', + 'id2': 'object', + 'rel': 'type'} + # We want 'destructive', so that the value of the subject, + # object and rel in the URI overwrite any values for these + # in params. This is because you are not allowed to change + # these values. + data_dict = rename_keys(data_dict, key_map, destructive=True) + + package_relationship_delete(context, data_dict) + + diff --git a/ckan/logic/action/update.py b/ckan/logic/action/update.py index 7299b3657cc..577763f2c0a 100644 --- a/ckan/logic/action/update.py +++ b/ckan/logic/action/update.py @@ -29,8 +29,10 @@ default_update_package_schema, default_update_user_schema, default_update_resource_schema, + default_update_relationship_schema, default_task_status_schema) from ckan.lib.navl.dictization_functions import validate +from ckan.logic.action import rename_keys log = logging.getLogger(__name__) @@ -82,6 +84,12 @@ def task_status_error_summary(error_dict): error_summary[_(prettify(key))] = error[0] return error_summary +def relationship_error_summary(error_dict): + error_summary = {} + for key, error in error_dict.iteritems(): + error_summary[_(prettify(key))] = error[0] + return error_summary + def _make_latest_rev_active(context, q): session = context['model'].Session @@ -280,18 +288,26 @@ def package_relationship_update(context, data_dict): model = context['model'] user = context['user'] - id = data_dict["id"] - id2 = data_dict["id2"] - rel = data_dict["rel"] + schema = context.get('schema') or default_update_relationship_schema() api = context.get('api_version') or '1' + + id = data_dict['subject'] + id2 = data_dict['object'] + rel = data_dict['type'] ref_package_by = 'id' if api == '2' else 'name' pkg1 = model.Package.get(id) pkg2 = model.Package.get(id2) if not pkg1: - raise NotFound('First package named in address was not found.') + raise NotFound('Subject package %r was not found.' % id) if not pkg2: - return NotFound('Second package named in address was not found.') + return NotFound('Object package %r was not found.' % id2) + + data, errors = validate(data_dict, schema, context) + + if errors: + model.Session.rollback() + raise ValidationError(errors, relationship_error_summary(errors)) check_access('package_relationship_update', context, data_dict) @@ -300,6 +316,7 @@ def package_relationship_update(context, data_dict): raise NotFound('This relationship between the packages was not found.') entity = existing_rels[0] comment = data_dict.get('comment', u'') + context['relationship'] = entity return _update_package_relationship(entity, comment, context) def group_update(context, data_dict): @@ -511,3 +528,20 @@ def group_update_rest(context, data_dict): group_dict = group_to_api2(group, context) return group_dict + +def package_relationship_update_rest(context, data_dict): + + # rename keys + key_map = {'id': 'subject', + 'id2': 'object', + 'rel': 'type'} + + # We want 'destructive', so that the value of the subject, + # object and rel in the URI overwrite any values for these + # in params. This is because you are not allowed to change + # these values. + data_dict = rename_keys(data_dict, key_map, destructive=True) + + relationship_dict = package_relationship_update(context, data_dict) + + return relationship_dict diff --git a/ckan/logic/auth/create.py b/ckan/logic/auth/create.py index 80702bf176f..dc5fb427a5c 100644 --- a/ckan/logic/auth/create.py +++ b/ckan/logic/auth/create.py @@ -26,8 +26,8 @@ def package_relationship_create(context, data_dict): model = context['model'] user = context['user'] - id = data_dict['id'] - id2 = data_dict['id2'] + id = data_dict['subject'] + id2 = data_dict['object'] pkg1 = model.Package.get(id) pkg2 = model.Package.get(id2) diff --git a/ckan/logic/auth/delete.py b/ckan/logic/auth/delete.py index 7dd762fc2e8..52628046fec 100644 --- a/ckan/logic/auth/delete.py +++ b/ckan/logic/auth/delete.py @@ -16,9 +16,10 @@ def package_delete(context, data_dict): return {'success': True} def package_relationship_delete(context, data_dict): - return package_relationship_create(context, data_dict) - -def relationship_delete(context, data_dict): + can_edit_this_relationship = package_relationship_create(context, data_dict) + if not can_edit_this_relationship['success']: + return can_edit_this_relationship + model = context['model'] user = context['user'] relationship = context['relationship'] diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py index 3142df75a95..cd408fd4fd8 100644 --- a/ckan/logic/schema.py +++ b/ckan/logic/schema.py @@ -39,7 +39,7 @@ def default_resource_schema(): schema = { 'id': [ignore_empty, unicode], - 'revistion_id': [ignore_missing, unicode], + 'revision_id': [ignore_missing, unicode], 'resource_group_id': [ignore], 'package_id': [ignore], 'url': [ignore_empty, unicode],#, URL(add_http=False)], @@ -202,14 +202,40 @@ def default_extras_schema(): def default_relationship_schema(): - schema = { + schema = { 'id': [ignore_missing, unicode], - 'subject_package_id': [ignore_missing, unicode], - 'object_package_id': [ignore_missing, unicode], + 'subject': [ignore_missing, unicode], + 'object': [ignore_missing, unicode], 'type': [not_empty, OneOf(ckan.model.PackageRelationship.get_all_types())], 'comment': [ignore_missing, unicode], 'state': [ignore], - } + } + return schema + +def default_create_relationship_schema(): + + schema = default_relationship_schema() + schema['id'] = [empty] + schema['subject'] = [not_empty, unicode, package_id_or_name_exists] + schema['object'] = [not_empty, unicode, package_id_or_name_exists] + + return schema + +def default_update_relationship_schema(): + + schema = default_relationship_schema() + schema['id'] = [ignore_missing, package_id_not_changed] + + # Todo: would like to check subject, object & type haven't changed, but + # no way to do this in schema + schema['subject'] = [ignore_missing] + schema['object'] = [ignore_missing] + schema['type'] = [ignore_missing] + + return schema + + + def default_user_schema(): diff --git a/ckan/migration/versions/034_resource_group_table.py b/ckan/migration/versions/034_resource_group_table.py index cc2e257b30b..e7d6890ab13 100644 --- a/ckan/migration/versions/034_resource_group_table.py +++ b/ckan/migration/versions/034_resource_group_table.py @@ -110,7 +110,7 @@ def upgrade(migrate_engine): # do data transfer # give resource group a hashed version of package uuid # so that we can use the same hash calculation on - # the resource and resource revistion table + # the resource and resource revision table migrate_engine.execute(''' insert into resource_group select diff --git a/ckan/model/package.py b/ckan/model/package.py index 1c38835544d..b2e2f97b496 100644 --- a/ckan/model/package.py +++ b/ckan/model/package.py @@ -221,7 +221,10 @@ def as_dict(self, ref_package_by='name', ref_group_by='name'): def add_relationship(self, type_, related_package, comment=u''): '''Creates a new relationship between this package and a - related_package. It leaves the caller to commit the change.''' + related_package. It leaves the caller to commit the change. + + Raises KeyError if the type_ is invalid. + ''' import package_relationship from ckan import model if type_ in package_relationship.PackageRelationship.get_forward_types(): diff --git a/ckan/public/css/style.css b/ckan/public/css/style.css index 6980e383490..34a2ec04046 100644 --- a/ckan/public/css/style.css +++ b/ckan/public/css/style.css @@ -1,5 +1,5 @@ -@import url('/css/forms.css'); -@import url('/css/pretty_buttons.css'); +@import url('forms.css'); +@import url('pretty_buttons.css'); body.no-sidebar #sidebar { display: none; } body.no-sidebar #content { @@ -207,6 +207,35 @@ tbody tr.table-empty td { font-size: 2.2em; font-weight: normal; } +.hover-for-help { + position: relative; +} +.hover-for-help > .help-text { + position: absolute; + top: 24px; + left: -90px; + display: none; + padding: 2px 8px; + font-size: 11px; + background: #333; + text-align: left; + width: 250px; + z-index: 3; + color: #fff; +} +.hover-for-help > .help-text > span { + display: block; + padding: 2px 0; +} +.hover-for-help > .help-text > span.fail { + color: #999; +} +.hover-for-help:hover > .help-text { + display: block; +} +.semi-link { + border-bottom: 1px dashed #000; +} /* =============== */ @@ -594,13 +623,18 @@ form.simple-form input[type=password] { .group.read .property-list li ul li { margin-left: -2.5em; } -.group.read .notes p { - margin-bottom: 0; -} .group-dataset-list { margin: 2em 0; } +.group-search-box input[type="search"] { + width: 100%; + margin-bottom: 20px; +} +.group-search-box input[type="submit"] { + float: right; +} + /* ============== */ /* = User Index = */ @@ -677,7 +711,7 @@ body.package.search #menusearch { .dataset-search { margin-bottom: 35px; } -.dataset-search input.search { +input.search { width: 100%; font-size: 1.2em; margin: 0px; @@ -701,6 +735,7 @@ body.package.search #menusearch { /* ======================================== */ /* = Dataset listing (eg. search results) = */ /* ======================================== */ +/* TODO strip and use .search-result class in markup everywhere */ ul.datasets { padding-left: 0; margin: 0 0 1em 0; @@ -928,6 +963,9 @@ ul.dataset-edit-nav li a:hover { .dataset-edit-form .resource-add .fileinfo { margin: 7px 0; } +.dataset-edit-form button.dataset-delete { + vertical-align: top; +} /* ================================ */ @@ -1109,44 +1147,57 @@ img.open-data { margin: 1px 0 0 8px; vertical-align: top; } body.package.read h3 { margin-bottom: 8px; } -.dataset-resource { +.search-result { border-left: 2px solid #eee; margin: 0; padding: 8px; margin-bottom: 16px; } -.dataset-resource:hover { +.search-result:hover { border-left: 2px solid #aaa; background: #f7f7f7; } -.dataset-resource .main-link { +.search-result .main-link { font-size: 125%; } -.dataset-resource .view-more-link { +.search-result .extra-links { float: right; + text-align: right; +} +.search-result .view-more-link { color: #000; - text-align: middle; + display: block; margin-top: 4px; padding: 3px 22px 3px 10px; background: url('/images/icons/arrow-right-16-black.png') no-repeat right; opacity:0.4; filter:alpha(opacity=40); /* For IE8 and earlier */ } -.dataset-resource .view-more-link:hover { +.search-result .view-more-link:hover { opacity:1.0; filter:alpha(opacity=100); /* For IE8 and earlier */ text-decoration: underline; } -.dataset-resource .resource-url a { +.search-result .result-url, +.search-result .result-url a { color: #888; } -.dataset-resource .resource-url a:hover { +.search-result .result-url:hover, +.search-result .result-url:hover a { color: #333; +} +.search-result .result-url:hover a { text-decoration: underline; } -.dataset-resource p { +.search-result .result-url img { + opacity: 0.5; +} +.search-result .result-url:hover img { + opacity: 1.0; +} +.search-result p { margin: 0; } .resource-url-cached { @@ -1156,7 +1207,7 @@ body.package.read h3 { body.package.read .resource-information { color: #808080; } -body.package.read .resource-format { +.format-box { border: 1px solid #EEE; margin-left: 8px; padding: 2px 8px; @@ -1177,16 +1228,16 @@ body.package.read #sidebar li.widget-container { border-left: 2px solid #eee; background: url('/images/ldquo.png') no-repeat top left #f7f7f7; } -.notes #dataset-notes-toggle a { +.notes #notes-toggle a { cursor: pointer; } -.notes #dataset-notes-toggle a.more:after { +.notes #notes-toggle a.more:after { content: ' »'; font-size: 150%; position: relative; bottom: -1px; } -.notes #dataset-notes-toggle a.less:before { +.notes #notes-toggle a.less:before { content: '« '; font-size: 150%; position: relative; diff --git a/ckan/public/images/icons/star.png b/ckan/public/images/icons/star.png new file mode 100755 index 00000000000..8cea494e2fa Binary files /dev/null and b/ckan/public/images/icons/star.png differ diff --git a/ckan/public/scripts/application.js b/ckan/public/scripts/application.js index fee666df39f..34633e2c0ac 100644 --- a/ckan/public/scripts/application.js +++ b/ckan/public/scripts/application.js @@ -10,7 +10,7 @@ CKAN.Utils.setupMarkdownEditor($('.markdown-editor')); // set up ckan js var config = { - endpoint: '/' + endpoint: CKAN.SITE_URL + '/' }; var client = new CKAN.Client(config); // serious hack to deal with hacky code in ckanjs @@ -23,15 +23,21 @@ CKAN.Utils.setupWelcomeBanner($('.js-welcome-banner')); } + var isGroupView = $('body.group.read').length > 0; + if (isGroupView) { + // Show extract of notes field + CKAN.Utils.setupNotesExtract(); + } + var isDatasetView = $('body.package.read').length > 0; if (isDatasetView) { // Show extract of notes field - CKAN.Utils.setupDatasetViewNotesExtract(); + CKAN.Utils.setupNotesExtract(); } var isResourceView = $('body.package.resource_read').length > 0; if (isResourceView) { - CKANEXT.DATAPREVIEW.setupDataPreview(preload_resource); + CKANEXT.DATAPREVIEW.loadPreviewDialog(preload_resource); } var isDatasetNew = $('body.package.new').length > 0; @@ -74,6 +80,18 @@ el: $el }); view.render(); + + // Set up dataset delete button + var select = $('select.dataset-delete'); + select.attr('disabled','disabled'); + select.css({opacity: 0.3}); + $('button.dataset-delete').click(function(e) { + select.removeAttr('disabled'); + select.fadeTo('fast',1.0); + $(e.target).css({opacity:0}); + $(e.target).attr('disabled','disabled'); + return false; + }); } var isGroupEdit = $('body.group.edit').length > 0; if (isGroupEdit) { @@ -139,7 +157,7 @@ CKAN.Utils = function($, my) { if (urlInput.length==0) throw "No urlInput found."; if (validMsg.length==0) throw "No validMsg found."; - var api_url = '/api/2/util/is_slug_valid'; + var api_url = CKAN.SITE_URL + '/api/2/util/is_slug_valid'; // (make length less than max, in case we need a few for '_' chars to de-clash slugs.) var MAX_SLUG_LENGTH = 90; @@ -298,7 +316,7 @@ CKAN.Utils = function($, my) { source: function(request, callback) { // here request.term is whole list of tags so need to get last var _realTerm = request.term.split(',').pop().trim(); - var url = '/api/2/util/tag/autocomplete?incomplete=' + _realTerm; + var url = CKAN.SITE_URL + '/api/2/util/tag/autocomplete?incomplete=' + _realTerm; $.getJSON(url, function(data) { // data = { ResultSet: { Result: [ {Name: tag} ] } } (Why oh why?) var tags = $.map(data.ResultSet.Result, function(value, idx) { @@ -334,7 +352,7 @@ CKAN.Utils = function($, my) { elements.autocomplete({ minLength: 1, source: function(request, callback) { - var url = '/api/2/util/resource/format_autocomplete?incomplete=' + request.term; + var url = CKAN.SITE_URL + '/api/2/util/resource/format_autocomplete?incomplete=' + request.term; $.getJSON(url, function(data) { // data = { ResultSet: { Result: [ {Name: tag} ] } } (Why oh why?) var formats = $.map(data.ResultSet.Result, function(value, idx) { @@ -353,7 +371,7 @@ CKAN.Utils = function($, my) { elements.autocomplete({ minLength: 2, source: function(request, callback) { - var url = '/api/2/util/user/autocomplete?q=' + request.term; + var url = CKAN.SITE_URL + '/api/2/util/user/autocomplete?q=' + request.term; $.getJSON(url, function(data) { $.each(data, function(idx, userobj) { var label = userobj.name; @@ -376,7 +394,7 @@ CKAN.Utils = function($, my) { elements.autocomplete({ minLength: 2, source: function(request, callback) { - var url = '/api/2/util/authorizationgroup/autocomplete?q=' + request.term; + var url = CKAN.SITE_URL + '/api/2/util/authorizationgroup/autocomplete?q=' + request.term; $.getJSON(url, function(data) { $.each(data, function(idx, userobj) { var label = userobj.name; @@ -404,7 +422,7 @@ CKAN.Utils = function($, my) { $target.addClass('depressed'); raw_markdown=textarea.val(); preview.html(""+CKAN.Strings.loading+""); - $.post("/api/util/markdown", { q: raw_markdown }, + $.post(CKAN.SITE_URL + "/api/util/markdown", { q: raw_markdown }, function(data) { preview.html(data); } ); preview.width(textarea.width()) @@ -424,17 +442,17 @@ CKAN.Utils = function($, my) { // If notes field is more than 1 paragraph, just show the // first paragraph with a 'Read more' link that will expand // the div if clicked - my.setupDatasetViewNotesExtract = function() { - var notes = $('#dataset div.notes'); + my.setupNotesExtract = function() { + var notes = $('#content div.notes'); if(notes.find('p').length > 1){ var extract = notes.children(':eq(0)'); var remainder = notes.children(':gt(0)'); - notes.html($.tmpl(CKAN.Templates.datasetNotesField)); + notes.html($.tmpl(CKAN.Templates.notesField)); notes.find('#notes-extract').html(extract); notes.find('#notes-remainder').html(remainder); notes.find('#notes-remainder').hide(); - notes.find('#dataset-notes-toggle a').click(function(event){ - notes.find('#dataset-notes-toggle a').toggle(); + notes.find('#notes-toggle a').click(function(event){ + notes.find('#notes-toggle a').toggle(); var remainder = notes.find('#notes-remainder') if ($(event.target).hasClass('more')) { remainder.slideDown(); @@ -442,6 +460,7 @@ CKAN.Utils = function($, my) { else { remainder.slideUp(); } + return false; }) } }; @@ -485,7 +504,13 @@ CKAN.View.DatasetEditForm = Backbone.View.extend({ var boundToUnload = false; return function() { if (!boundToUnload) { - CKAN.Utils.flashMessage(CKAN.Strings.youHaveUnsavedChanges,'notice'); + var parentDiv = $('
').addClass('flash-messages'); + var messageDiv = $('
').html(CKAN.Strings.youHaveUnsavedChanges).addClass('notice').hide(); + parentDiv.append(messageDiv); + $('#unsaved-warning').append(parentDiv); + console.log($('#unsaved-warning')); + messageDiv.show(200); + boundToUnload = true; window.onbeforeunload = function () { return CKAN.Strings.youHaveUnsavedChanges; @@ -494,7 +519,7 @@ CKAN.View.DatasetEditForm = Backbone.View.extend({ } }(); - $form.find('input').live('change', function(e) { + $form.find('input,select').live('change', function(e) { $target = $(e.target); // Entering text in the 'add' box does not represent a change if ($target.closest('.resource-add').length==0) { @@ -742,17 +767,6 @@ CKAN.View.ResourceAddLink = Backbone.View.extend({ my.dialogId = 'ckanext-datapreview'; my.$dialog = $('#' + my.dialogId); - // Initialize data explorer on Resource view page - // - // resourceData: resource as simple hash (suitable for initializing backbone model or result of backboneModel.toJSON()) - my.setupDataPreview = function(resourceData) { - // initialize the tableviewer system - DATAEXPLORER.TABLEVIEW.initialize(my.dialogId); - resourceData.formatNormalized = my.normalizeFormat(resourceData.format); - - my.loadPreviewDialog(resourceData); - }; - // **Public: Loads a data preview** // // Fetches the preview data object from the link provided and loads the @@ -763,9 +777,20 @@ CKAN.View.ResourceAddLink = Backbone.View.extend({ // // Returns nothing. my.loadPreviewDialog = function(resourceData) { - resourceData.url = my.normalizeUrl(resourceData.url); my.$dialog.html('

Loading ...

'); + function initializeDataExplorer(dataset) { + var dataExplorer = new recline.View.DataExplorer({ + el: my.$dialog + , model: dataset + , config: { + readOnly: true + } + }); + // will have to refactor if this can get called multiple times + Backbone.history.start(); + } + // 4 situations // a) have a webstore_url // b) csv or xls (but not webstore) @@ -773,6 +798,9 @@ CKAN.View.ResourceAddLink = Backbone.View.extend({ // d) none of the above but worth iframing (assumption is // that if we got here (i.e. preview shown) worth doing // something ...) + resourceData.formatNormalized = my.normalizeFormat(resourceData.format); + + resourceData.url = my.normalizeUrl(resourceData.url); if (resourceData.formatNormalized === '') { var tmp = resourceData.url.split('/'); tmp = tmp[tmp.length - 1]; @@ -785,18 +813,21 @@ CKAN.View.ResourceAddLink = Backbone.View.extend({ } if (resourceData.webstore_url) { - var _url = resourceData.webstore_url + '.jsontuples?_limit=500'; - my.getResourceDataDirect(_url, function(data) { - DATAEXPLORER.TABLEVIEW.showData(data); - DATAEXPLORER.TABLEVIEW.$dialog.dialog('open'); + var backend = new recline.Model.BackendWebstore({ + url: resourceData.webstore_url }); + recline.Model.setBackend(backend); + var dataset = backend.getDataset(); + initializeDataExplorer(dataset); } else if (resourceData.formatNormalized in {'csv': '', 'xls': ''}) { - var _url = my.jsonpdataproxyUrl + '?url=' + resourceData.url + '&type=' + resourceData.formatNormalized; - my.getResourceDataDirect(_url, function(data) { - DATAEXPLORER.TABLEVIEW.showData(data); - DATAEXPLORER.TABLEVIEW.$dialog.dialog('open'); + var backend = new recline.Model.BackendDataProxy({ + url: resourceData.url + , type: resourceData.formatNormalized }); + recline.Model.setBackend(backend); + var dataset = backend.getDataset(); + initializeDataExplorer(dataset); } else if (resourceData.formatNormalized in { 'rdf+xml': '', @@ -816,7 +847,6 @@ CKAN.View.ResourceAddLink = Backbone.View.extend({ var _url = my.jsonpdataproxyUrl + '?type=csv&url=' + resourceData.url; my.getResourceDataDirect(_url, function(data) { my.showPlainTextData(data); - DATAEXPLORER.TABLEVIEW.$dialog.dialog('open'); }); } else if (resourceData.formatNormalized in {'html':'', 'htm':''} @@ -833,7 +863,10 @@ CKAN.View.ResourceAddLink = Backbone.View.extend({ // Cannot reliably preview this item - with no mimetype/format information, // can't guarantee it's not a remote binary file such as an executable. var _msg = $('

We are unable to preview this type of resource: ' + resourceData.formatNormalized + '

'); - my.$dialog.html(_msg); + my.showError({ + title: 'Unable to preview' + , message: _msg + }); } }; @@ -884,22 +917,23 @@ CKAN.View.ResourceAddLink = Backbone.View.extend({ // // Returns nothing. my.showPlainTextData = function(data) { - // HACK: have to reach into DATAEXPLORER.TABLEVIEW dialog a lot ... - DATAEXPLORER.TABLEVIEW.setupFullscreenDialog(); - if(data.error) { - DATAEXPLORER.TABLEVIEW.showError(data.error); + my.showError(data.error); } else { var content = $('
');
       for (var i=0; i
' + $.trim(error.message); + my.$dialog.html(_html); + }; + my.normalizeFormat = function(format) { var out = format.toLowerCase(); out = out.split('/'); diff --git a/ckan/public/scripts/templates.js b/ckan/public/scripts/templates.js index ca4600a4322..3142dadaf0b 100644 --- a/ckan/public/scripts/templates.js +++ b/ckan/public/scripts/templates.js @@ -153,11 +153,11 @@ CKAN.Templates.resourceEntry = ' \ \ '; -CKAN.Templates.datasetNotesField = ' \ +CKAN.Templates.notesField = ' \
\
\
\
\ - \ + \ '; diff --git a/ckan/public/scripts/vendor/jquery.mustache/jquery.mustache.js b/ckan/public/scripts/vendor/jquery.mustache/jquery.mustache.js new file mode 100755 index 00000000000..5aa67def818 --- /dev/null +++ b/ckan/public/scripts/vendor/jquery.mustache/jquery.mustache.js @@ -0,0 +1,346 @@ +/* +Shameless port of a shameless port +@defunkt => @janl => @aq + +See http://github.com/defunkt/mustache for more info. +*/ + +;(function($) { + +/* + mustache.js — Logic-less templates in JavaScript + + See http://mustache.github.com/ for more info. +*/ + +var Mustache = function() { + var Renderer = function() {}; + + Renderer.prototype = { + otag: "{{", + ctag: "}}", + pragmas: {}, + buffer: [], + pragmas_implemented: { + "IMPLICIT-ITERATOR": true + }, + context: {}, + + render: function(template, context, partials, in_recursion) { + // reset buffer & set context + if(!in_recursion) { + this.context = context; + this.buffer = []; // TODO: make this non-lazy + } + + // fail fast + if(!this.includes("", template)) { + if(in_recursion) { + return template; + } else { + this.send(template); + return; + } + } + + template = this.render_pragmas(template); + var html = this.render_section(template, context, partials); + if(in_recursion) { + return this.render_tags(html, context, partials, in_recursion); + } + + this.render_tags(html, context, partials, in_recursion); + }, + + /* + Sends parsed lines + */ + send: function(line) { + if(line != "") { + this.buffer.push(line); + } + }, + + /* + Looks for %PRAGMAS + */ + render_pragmas: function(template) { + // no pragmas + if(!this.includes("%", template)) { + return template; + } + + var that = this; + var regex = new RegExp(this.otag + "%([\\w-]+) ?([\\w]+=[\\w]+)?" + + this.ctag); + return template.replace(regex, function(match, pragma, options) { + if(!that.pragmas_implemented[pragma]) { + throw({message: + "This implementation of mustache doesn't understand the '" + + pragma + "' pragma"}); + } + that.pragmas[pragma] = {}; + if(options) { + var opts = options.split("="); + that.pragmas[pragma][opts[0]] = opts[1]; + } + return ""; + // ignore unknown pragmas silently + }); + }, + + /* + Tries to find a partial in the curent scope and render it + */ + render_partial: function(name, context, partials) { + name = this.trim(name); + if(!partials || partials[name] === undefined) { + throw({message: "unknown_partial '" + name + "'"}); + } + if(typeof(context[name]) != "object") { + return this.render(partials[name], context, partials, true); + } + return this.render(partials[name], context[name], partials, true); + }, + + /* + Renders inverted (^) and normal (#) sections + */ + render_section: function(template, context, partials) { + if(!this.includes("#", template) && !this.includes("^", template)) { + return template; + } + + var that = this; + // CSW - Added "+?" so it finds the tighest bound, not the widest + var regex = new RegExp(this.otag + "(\\^|\\#)\\s*(.+)\\s*" + this.ctag + + "\n*([\\s\\S]+?)" + this.otag + "\\/\\s*\\2\\s*" + this.ctag + + "\\s*", "mg"); + + // for each {{#foo}}{{/foo}} section do... + return template.replace(regex, function(match, type, name, content) { + var value = that.find(name, context); + if(type == "^") { // inverted section + if(!value || that.is_array(value) && value.length === 0) { + // false or empty list, render it + return that.render(content, context, partials, true); + } else { + return ""; + } + } else if(type == "#") { // normal section + if(that.is_array(value)) { // Enumerable, Let's loop! + return that.map(value, function(row) { + return that.render(content, that.create_context(row), + partials, true); + }).join(""); + } else if(that.is_object(value)) { // Object, Use it as subcontext! + return that.render(content, that.create_context(value), + partials, true); + } else if(typeof value === "function") { + // higher order section + return value.call(context, content, function(text) { + return that.render(text, context, partials, true); + }); + } else if(value) { // boolean section + return that.render(content, context, partials, true); + } else { + return ""; + } + } + }); + }, + + /* + Replace {{foo}} and friends with values from our view + */ + render_tags: function(template, context, partials, in_recursion) { + // tit for tat + var that = this; + + var new_regex = function() { + return new RegExp(that.otag + "(=|!|>|\\{|%)?([^\\/#\\^]+?)\\1?" + + that.ctag + "+", "g"); + }; + + var regex = new_regex(); + var tag_replace_callback = function(match, operator, name) { + switch(operator) { + case "!": // ignore comments + return ""; + case "=": // set new delimiters, rebuild the replace regexp + that.set_delimiters(name); + regex = new_regex(); + return ""; + case ">": // render partial + return that.render_partial(name, context, partials); + case "{": // the triple mustache is unescaped + return that.find(name, context); + default: // escape the value + return that.escape(that.find(name, context)); + } + }; + var lines = template.split("\n"); + for(var i = 0; i < lines.length; i++) { + lines[i] = lines[i].replace(regex, tag_replace_callback, this); + if(!in_recursion) { + this.send(lines[i]); + } + } + + if(in_recursion) { + return lines.join("\n"); + } + }, + + set_delimiters: function(delimiters) { + var dels = delimiters.split(" "); + this.otag = this.escape_regex(dels[0]); + this.ctag = this.escape_regex(dels[1]); + }, + + escape_regex: function(text) { + // thank you Simon Willison + if(!arguments.callee.sRE) { + var specials = [ + '/', '.', '*', '+', '?', '|', + '(', ')', '[', ']', '{', '}', '\\' + ]; + arguments.callee.sRE = new RegExp( + '(\\' + specials.join('|\\') + ')', 'g' + ); + } + return text.replace(arguments.callee.sRE, '\\$1'); + }, + + /* + find `name` in current `context`. That is find me a value + from the view object + */ + find: function(name, context) { + name = this.trim(name); + + // Checks whether a value is thruthy or false or 0 + function is_kinda_truthy(bool) { + return bool === false || bool === 0 || bool; + } + + var value; + if(is_kinda_truthy(context[name])) { + value = context[name]; + } else if(is_kinda_truthy(this.context[name])) { + value = this.context[name]; + } + + if(typeof value === "function") { + return value.apply(context); + } + if(value !== undefined) { + return value; + } + // silently ignore unkown variables + return ""; + }, + + // Utility methods + + /* includes tag */ + includes: function(needle, haystack) { + return haystack.indexOf(this.otag + needle) != -1; + }, + + /* + Does away with nasty characters + */ + escape: function(s) { + s = String(s === null ? "" : s); + return s.replace(/&(?!\w+;)|["<>\\]/g, function(s) { + switch(s) { + case "&": return "&"; + case "\\": return "\\\\"; + case '"': return '\"'; + case "<": return "<"; + case ">": return ">"; + default: return s; + } + }); + }, + + // by @langalex, support for arrays of strings + create_context: function(_context) { + if(this.is_object(_context)) { + return _context; + } else { + var iterator = "."; + if(this.pragmas["IMPLICIT-ITERATOR"]) { + iterator = this.pragmas["IMPLICIT-ITERATOR"].iterator; + } + var ctx = {}; + ctx[iterator] = _context; + return ctx; + } + }, + + is_object: function(a) { + return a && typeof a == "object"; + }, + + is_array: function(a) { + return Object.prototype.toString.call(a) === '[object Array]'; + }, + + /* + Gets rid of leading and trailing whitespace + */ + trim: function(s) { + return s.replace(/^\s*|\s*$/g, ""); + }, + + /* + Why, why, why? Because IE. Cry, cry cry. + */ + map: function(array, fn) { + if (typeof array.map == "function") { + return array.map(fn); + } else { + var r = []; + var l = array.length; + for(var i = 0; i < l; i++) { + r.push(fn(array[i])); + } + return r; + } + } + }; + + return({ + name: "mustache.js", + version: "0.3.1-dev", + + /* + Turns a template and view into HTML + */ + to_html: function(template, view, partials, send_fun) { + var renderer = new Renderer(); + if(send_fun) { + renderer.send = send_fun; + } + renderer.render(template, view, partials); + if(!send_fun) { + return renderer.buffer.join("\n"); + } + }, + escape : function(text) { + return new Renderer().escape(text); + } + }); +}(); + + $.mustache = function(template, view, partials) { + return Mustache.to_html(template, view, partials); + }; + + $.mustache.escape = function(text) { + return Mustache.escape(text); + }; + +})(jQuery); diff --git a/ckan/public/scripts/vendor/recline/css/data-explorer.css b/ckan/public/scripts/vendor/recline/css/data-explorer.css new file mode 100644 index 00000000000..2705d14a63c --- /dev/null +++ b/ckan/public/scripts/vendor/recline/css/data-explorer.css @@ -0,0 +1,526 @@ +.data-explorer .header .navigation, +.data-explorer .header .navigation li, +.data-explorer .header .pagination, +.data-explorer .header .pagination form +{ + display: inline; +} + +.data-explorer .header .navigation { + float: left; + margin-left: 0; + padding-left: 0; +} + +.header .pagination { + float: right; + margin: 4px; +} + +.header .pagination label { + float: none; +} + +.header .pagination input { + width: 30px; +} + +.doc-count { + font-weight: bold; + font-size: 120%; +} + +.data-view-container { + display: block; + clear: both; +} + +/* bootstrap btn */ +.btn { + cursor: pointer; + display: inline-block; + background-color: #e6e6e6; + background-repeat: no-repeat; + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), color-stop(25%, #ffffff), to(#e6e6e6)); + background-image: -webkit-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6); + background-image: -moz-linear-gradient(top, #ffffff, #ffffff 25%, #e6e6e6); + background-image: -ms-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6); + background-image: -o-linear-gradient(#ffffff, #ffffff 25%, #e6e6e6); + background-image: linear-gradient(#ffffff, #ffffff 25%, #e6e6e6); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0); + padding: 5px 14px 6px; + text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75); + color: #333; + font-size: 13px; + line-height: normal; + border: 1px solid #ccc; + border-bottom-color: #bbb; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + -webkit-transition: 0.1s linear all; + -moz-transition: 0.1s linear all; + -ms-transition: 0.1s linear all; + -o-transition: 0.1s linear all; + transition: 0.1s linear all; +} +.btn:hover { + background-position: 0 -15px; + color: #333; + text-decoration: none; +} +.btn:focus { + outline: 1px dotted #666; +} + +/* twitter btn.disabled but for button link that is active. used in navigation */ +.active .btn { + cursor: default; + background-image: none; + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + filter: alpha(opacity=65); + -khtml-opacity: 0.65; + -moz-opacity: 0.65; + opacity: 0.65; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; +} + + +/********************************************************** + * Notifications + *********************************************************/ + +.notification-container { + width: 400px; + left: 520px; + display: none; + position: fixed; + top: 0; + z-index: 100; + text-align: center; +} + +.notification { + display: inline-block; + margin: 0 auto; + padding: 5px 8px 4px; + font-size: 1.3em; + text-align: left; + font-weight: bold; + background: #fe8; + -moz-border-radius: 2px; + -webkit-border-radius: 2px; + border-radius: 2px; +} + +.notification-action { + padding-left: 10px; +} + +.notification-loader { + padding: 0 3px 0 0; + opacity: 0.3; +} + + +/********************************************************** + * Data Table + *********************************************************/ + +.data-table { + border: 1px solid #ccc; + font-size: 12px; +} + +.data-table td, .data-table th { + border-left: 1px solid #ccc; + padding: 3px 4px; +} + +.data-table tr td:first-child, .data-table tr th:first-child { + width: 20px; +} + +/********************************************************** + * Data Table Menus + *********************************************************/ + +a.column-header-menu { + float: right; + display: block; + margin: 0 4px 0 0; + width: 17px; + height: 19px; + background-image: url(images/menu-dropdown.png); + background-repeat: no-repeat; +} + +a.row-header-menu:hover { + background-position: -17px 0px; + text-decoration: none; +} + +a.row-header-menu { + float: left; + display: block; + margin: -2px 0 -4px 0; + width: 17px; + height: 18px; + background-image: url(images/menu-dropdown.png); + background-repeat: no-repeat; +} + +a.column-header-menu:hover { + background-position: -17px 0px; + text-decoration: none; +} + +.column-header-recon-stats-bar { + margin-top: 10px; + height: 4px; + background: #ddd; + border: 1px solid #ccc; + position: relative; + width: 100%; +} + +.column-header-recon-stats-matched { + position: absolute; + height: 100%; + background: #282; +} + +.column-header-recon-stats-blanks { + position: absolute; + height: 100%; + background: #3d3; +} + +div.data-table-cell-content { + line-height: 1.2; + color: #222; + position: relative; +} + +div.data-table-cell-content-numeric { + text-align: right; +} + +a.data-table-cell-edit { + position: absolute; + top: 0; + right: 0; + display: block; + width: 25px; + height: 16px; + text-decoration: none; + background-image: url(images/edit-map.png); + background-repeat: no-repeat; + visibility: hidden; +} + +a.data-table-cell-edit:hover { + background-position: -25px 0px; +} + +.data-table td:hover .data-table-cell-edit { + visibility: visible; +} + +div.data-table-cell-content-numeric > a.data-table-cell-edit { + left: 0px; + right: auto; +} + +.data-table-value-nonstring { + color: #282; +} + +.data-table-error { + color: red; +} + +.data-table-menu-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +ul.data-table-menu { + display: none; + outline-style: none; + background: white; + color: black; + font-size: 12px; + height: auto; + list-style: none; + overflow: hidden; + position: absolute; + text-align: left; + width: 120px; + z-index: 666; + border: 1px solid #CCC; + border-right: 1px solid #666; + border-bottom: 1px solid #666; + margin: 0; padding: 0; } + ul.data-table-menu * { + margin: 0; + padding: 0; } + ul.data-table-menu a { + line-height: 14px; + color: black; + display: block; + padding: 5px 7px; + text-decoration: none; } + ul.data-table-menu li { + height: 24px; } + ul.data-table-menu li:hover { + background-color: #DBE8F8 } + +/* TODO: not sure the rest of this is needed */ +.data-table-cell-editor, .data-table-topic-popup { + overflow: auto; + border: 1px solid #bcf; + background: #e3e9ff; + padding: 5px; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} + +.data-table-topic-popup-header { + padding: 0 0 5px; +} + +.data-table-cell-editor-editor { + overflow: hidden; + display: block; + width: 98%; + height: 3em; + font-family: monospace; + margin: 3px 0; +} + +.data-table-cell-copypaste-editor { + overflow: hidden; + display: block; + width: 98%; + height: 10em; + font-family: monospace; + margin: 3px 0; +} + +.data-table-cell-editor-action { + float: left; + vertical-align: bottom; + text-align: center; +} + +.data-table-cell-editor-key { + font-size: 0.8em; + color: #999; +} + +ul.sorting-dialog-blank-error-positions { + margin: 0; + padding: 5px; + height: 10em; + border: 1px solid #ccc; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + border-radius: 3px; +} + +ul.sorting-dialog-blank-error-positions > li { + display: block; + border: 1px solid #ccc; + background: #eee; + padding: 5px; + margin: 2px; + cursor: move; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + border-radius: 3px; +} + + +/********************************************************** + * Dialogs + *********************************************************/ + +.dialog-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #666; + opacity: 0.5; +} + +.dialog { + position: fixed; + left: 0; + width: 100%; + text-align: center; +} + +.dialog-frame { + margin: 0 auto; + text-align: left; + background: white; + border: 1px solid #3a5774; +} + +.dialog-border { + border: 4px solid #c1d9ff; +} + +.dialog-header { + background: #e0edfe; + padding: 10px; + font-weight: bold; + font-size: 1.6em; + color: #000; + cursor: move; +} + +.dialog-body { + overflow: auto; + font-size: 1.3em; + padding: 15px; +} + +.dialog-instruction { + padding: 0 0 7px; +} + +.dialog-footer { + font-size: 1.3em; + background: #eee; + padding: 10px; +} + +.dialog-busy { + width: 400px; + border: none; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} + +/********************************************************** + * Transform Dialog + *********************************************************/ + +#expression-preview-tabs .ui-tabs-nav li a { + padding: 0.15em 1em; +} + +textarea.expression-preview-code { + font-family: monospace; + height: 5em; + vertical-align: top; +} + +.expression-preview-parsing-status { + color: #999; +} + +.expression-preview-parsing-status.error { + color: red; +} + +#expression-preview-tabs-preview, +#expression-preview-tabs-help, +#expression-preview-tabs-history, +#expression-preview-tabs-starred { + padding: 5px; + overflow: hidden; +} + +#expression-preview-tabs-preview > div, +#expression-preview-tabs-help > div, +#expression-preview-tabs-history > div, +#expression-preview-tabs-starred { + height: 200px; + overflow: auto; +} + +#expression-preview-tabs-preview td, #expression-preview-tabs-preview th, +#expression-preview-tabs-help td, #expression-preview-tabs-help th, +#expression-preview-tabs-history td, #expression-preview-tabs-history th, +#expression-preview-tabs-starred td, #expression-preview-tabs-starred th { + padding: 5px; +} + +.expression-preview-table-wrapper { + padding: 7px; +} + +.expression-preview-container td { + padding: 2px 5px; + border-top: 1px solid #ccc; +} + +td.expression-preview-heading { + border-top: none; + background: #ddd; + font-weight: bold; +} + +td.expression-preview-value { + max-width: 250px !important; + overflow-x: hidden; +} + +.expression-preview-special-value { + color: #aaa; +} + +.expression-preview-help-container h3 { + margin-top: 15px; + margin-bottom: 7px; + border-bottom: 1px solid #999; +} + +.expression-preview-doc-item-title { + font-weight: bold; + text-align: right; +} + +.expression-preview-doc-item-params { +} + +.expression-preview-doc-item-returns { +} + +.expression-preview-doc-item-desc { + color: #666; +} + + +/********************************************************** + * Read-only mode + *********************************************************/ + +.read-only .data-table tr td:first-child, +.read-only .data-table tr th:first-child +{ + display: none; +} + +.read-only .column-header-menu, +.read-only .row-header-menu, +.read-only a.data-table-cell-edit +{ + display: none; +} + diff --git a/ckan/public/scripts/vendor/recline/css/graph-flot.css b/ckan/public/scripts/vendor/recline/css/graph-flot.css new file mode 100644 index 00000000000..d50f11e1f37 --- /dev/null +++ b/ckan/public/scripts/vendor/recline/css/graph-flot.css @@ -0,0 +1,50 @@ +.data-graph-container .graph { + height: 500px; + margin-right: 200px; +} + +.data-graph-container .legend table { + width: auto; + margin-bottom: 0; +} + +.data-graph-container .legend td { + padding: 5px; + line-height: 13px; +} + +/********************************************************** + * Editor + *********************************************************/ + +.data-graph-container .editor { + float: right; + width: 200px; + padding-left: 0px; +} + +.data-graph-container .editor-info { + padding-left: 4px; +} + +.data-graph-container .editor-info { + cursor: pointer; +} + +.data-graph-container .editor form { + padding-left: 4px; +} + +.data-graph-container .editor select { + width: 100%; +} + +.data-graph-container .editor-info { + border-bottom: 1px solid #ddd; + margin-bottom: 10px; +} + +.data-graph-container .editor-hide-info p { + display: none; +} + diff --git a/ckan/public/scripts/vendor/recline/css/images/edit-map.png b/ckan/public/scripts/vendor/recline/css/images/edit-map.png new file mode 100755 index 00000000000..dea0ed1ef4e Binary files /dev/null and b/ckan/public/scripts/vendor/recline/css/images/edit-map.png differ diff --git a/ckan/public/scripts/vendor/recline/css/images/menu-dropdown.png b/ckan/public/scripts/vendor/recline/css/images/menu-dropdown.png new file mode 100755 index 00000000000..c733fef7f4b Binary files /dev/null and b/ckan/public/scripts/vendor/recline/css/images/menu-dropdown.png differ diff --git a/ckan/public/scripts/vendor/recline/recline.js b/ckan/public/scripts/vendor/recline/recline.js new file mode 100644 index 00000000000..ca0e9a28368 --- /dev/null +++ b/ckan/public/scripts/vendor/recline/recline.js @@ -0,0 +1,1836 @@ +// importScripts('lib/underscore.js'); + +onmessage = function(message) { + + function parseCSV(rawCSV) { + var patterns = new RegExp(( + // Delimiters. + "(\\,|\\r?\\n|\\r|^)" + + // Quoted fields. + "(?:\"([^\"]*(?:\"\"[^\"]*)*)\"|" + + // Standard fields. + "([^\"\\,\\r\\n]*))" + ), "gi"); + + var rows = [[]], matches = null; + + while (matches = patterns.exec(rawCSV)) { + var delimiter = matches[1]; + + if (delimiter.length && (delimiter !== ",")) rows.push([]); + + if (matches[2]) { + var value = matches[2].replace(new RegExp("\"\"", "g"), "\""); + } else { + var value = matches[3]; + } + rows[rows.length - 1].push(value); + } + + if(_.isEqual(rows[rows.length -1], [""])) rows.pop(); + + var docs = []; + var headers = _.first(rows); + _.each(_.rest(rows), function(row, rowIDX) { + var doc = {}; + _.each(row, function(cell, idx) { + doc[headers[idx]] = cell; + }) + docs.push(doc); + }) + + return docs; + } + + var docs = parseCSV(message.data.data); + + var req = new XMLHttpRequest(); + + req.onprogress = req.upload.onprogress = function(e) { + if(e.lengthComputable) postMessage({ percent: (e.loaded / e.total) * 100 }); + }; + + req.onreadystatechange = function() { if (req.readyState == 4) postMessage({done: true, response: req.responseText}) }; + req.open('POST', message.data.url); + req.setRequestHeader('Content-Type', 'application/json'); + req.send(JSON.stringify({docs: docs})); +}; +// adapted from https://github.com/harthur/costco. heather rules + +var costco = function() { + + function evalFunction(funcString) { + try { + eval("var editFunc = " + funcString); + } catch(e) { + return {errorMessage: e+""}; + } + return editFunc; + } + + function previewTransform(docs, editFunc, currentColumn) { + var preview = []; + var updated = mapDocs($.extend(true, {}, docs), editFunc); + for (var i = 0; i < updated.docs.length; i++) { + var before = docs[i] + , after = updated.docs[i] + ; + if (!after) after = {}; + if (currentColumn) { + preview.push({before: JSON.stringify(before[currentColumn]), after: JSON.stringify(after[currentColumn])}); + } else { + preview.push({before: JSON.stringify(before), after: JSON.stringify(after)}); + } + } + return preview; + } + + function mapDocs(docs, editFunc) { + var edited = [] + , deleted = [] + , failed = [] + ; + + var updatedDocs = _.map(docs, function(doc) { + try { + var updated = editFunc(_.clone(doc)); + } catch(e) { + failed.push(doc); + return; + } + if(updated === null) { + updated = {_deleted: true}; + edited.push(updated); + deleted.push(doc); + } + else if(updated && !_.isEqual(updated, doc)) { + edited.push(updated); + } + return updated; + }); + + return { + edited: edited, + docs: updatedDocs, + deleted: deleted, + failed: failed + }; + } + + function updateDocs(editFunc) { + var dfd = $.Deferred(); + util.notify("Download entire database into Recline. This could take a while...", {persist: true, loader: true}); + couch.request({url: app.baseURL + "api/json"}).then(function(docs) { + util.notify("Updating " + docs.docs.length + " documents. This could take a while...", {persist: true, loader: true}); + var toUpdate = costco.mapDocs(docs.docs, editFunc).edited; + costco.uploadDocs(toUpdate).then( + function(updatedDocs) { + util.notify(updatedDocs.length + " documents updated successfully"); + recline.initializeTable(app.offset); + dfd.resolve(updatedDocs); + }, + function(err) { + util.notify("Errorz! " + err); + dfd.reject(err); + } + ); + }); + return dfd.promise(); + } + + function updateDoc(doc) { + return couch.request({type: "PUT", url: app.baseURL + "api/" + doc._id, data: JSON.stringify(doc)}) + } + + function uploadDocs(docs) { + var dfd = $.Deferred(); + if(!docs.length) dfd.resolve("Failed: No docs specified"); + couch.request({url: app.baseURL + "api/_bulk_docs", type: "POST", data: JSON.stringify({docs: docs})}) + .then( + function(resp) {ensureCommit().then(function() { + var error = couch.responseError(resp); + if (error) { + dfd.reject(error); + } else { + dfd.resolve(resp); + } + })}, + function(err) { dfd.reject(err.responseText) } + ); + return dfd.promise(); + } + + function ensureCommit() { + return couch.request({url: app.baseURL + "api/_ensure_full_commit", type:'POST', data: "''"}); + } + + function deleteColumn(name) { + var deleteFunc = function(doc) { + delete doc[name]; + return doc; + } + return updateDocs(deleteFunc); + } + + function uploadCSV() { + var file = $('#file')[0].files[0]; + if (file) { + var reader = new FileReader(); + reader.readAsText(file); + reader.onload = function(event) { + var payload = { + url: window.location.href + "/api/_bulk_docs", // todo more robust url composition + data: event.target.result + }; + var worker = new Worker('script/costco-csv-worker.js'); + worker.onmessage = function(event) { + var message = event.data; + if (message.done) { + var error = couch.responseError(JSON.parse(message.response)) + console.log('e',error) + if (error) { + app.emitter.emit(error, 'error'); + } else { + util.notify("Data uploaded successfully!"); + recline.initializeTable(app.offset); + } + util.hide('dialog'); + } else if (message.percent) { + if (message.percent === 100) { + util.notify("Waiting for CouchDB...", {persist: true, loader: true}) + } else { + util.notify("Uploading... " + message.percent + "%"); + } + } else { + util.notify(JSON.stringify(message)); + } + }; + worker.postMessage(payload); + }; + } else { + util.notify('File not selected. Please try again'); + } + }; + + return { + evalFunction: evalFunction, + previewTransform: previewTransform, + mapDocs: mapDocs, + updateDocs: updateDocs, + updateDoc: updateDoc, + uploadDocs: uploadDocs, + deleteColumn: deleteColumn, + ensureCommit: ensureCommit, + uploadCSV: uploadCSV + }; +}(); +this.recline = this.recline || {}; + +// Models module following classic module pattern +recline.Model = function($) { + +var my = {}; + +// A Dataset model. +// +// Other than standard list of Backbone attributes it has two important attributes: +// +// * currentDocuments: a DocumentList containing the Documents we have currently loaded for viewing (you update currentDocuments by calling getRows) +// * docCount: total number of documents in this dataset (obtained on a fetch for this Dataset) +my.Dataset = Backbone.Model.extend({ + __type__: 'Dataset', + initialize: function() { + this.currentDocuments = new my.DocumentList(); + this.docCount = null; + }, + + // AJAX method with promise API to get rows (documents) from the backend. + // + // Resulting DocumentList are used to reset this.currentDocuments and are + // also returned. + // + // :param numRows: passed onto backend getDocuments. + // :param start: passed onto backend getDocuments. + // + // this does not fit very well with Backbone setup. Backbone really expects you to know the ids of objects your are fetching (which you do in classic RESTful ajax-y world). But this paradigm does not fill well with data set up we have here. + // This also illustrates the limitations of separating the Dataset and the Backend + getDocuments: function(numRows, start) { + var self = this; + var dfd = $.Deferred(); + this.backend.getDocuments(this.id, numRows, start).then(function(rows) { + var docs = _.map(rows, function(row) { + return new my.Document(row); + }); + self.currentDocuments.reset(docs); + dfd.resolve(self.currentDocuments); + }); + return dfd.promise(); + }, + + toTemplateJSON: function() { + var data = this.toJSON(); + data.docCount = this.docCount; + return data; + } +}); + +my.Document = Backbone.Model.extend({ + __type__: 'Document' +}); + +my.DocumentList = Backbone.Collection.extend({ + __type__: 'DocumentList', + // webStore: new WebStore(this.url), + model: my.Document +}); + +// Backends section +// ================ + +my.setBackend = function(backend) { + Backbone.sync = backend.sync; +}; + +// Backend which just caches in memory +// +// Does not need to be a backbone model but provides some conveniences +my.BackendMemory = Backbone.Model.extend({ + // Initialize a Backend with a local in-memory dataset. + // + // NB: We can handle one and only one dataset at a time. + // + // :param dataset: the data for a dataset on which operations will be + // performed. Its form should be a hash with metadata and data + // attributes. + // + // - metadata: hash of key/value attributes of any kind (but usually with title attribute) + // - data: hash with 2 keys: + // - headers: list of header names/labels + // - rows: list of hashes, each hash being one row. A row *must* have an id attribute which is unique. + // + // Example of data: + // + // { + // headers: ['x', 'y', 'z'] + // , rows: [ + // {id: 0, x: 1, y: 2, z: 3} + // , {id: 1, x: 2, y: 4, z: 6} + // ] + // }; + initialize: function(dataset) { + // deep copy + this._datasetAsData = $.extend(true, {}, dataset); + _.bindAll(this, 'sync'); + }, + getDataset: function() { + var dataset = new my.Dataset({ + id: this._datasetAsData.metadata.id + }); + // this is a bit weird but problem is in sync this is set to parent model object so need to give dataset a reference to backend explicitly + dataset.backend = this; + return dataset; + }, + sync: function(method, model, options) { + var self = this; + if (method === "read") { + var dfd = $.Deferred(); + // this switching on object type is rather horrible + // think may make more sense to do work in individual objects rather than in central Backbone.sync + if (model.__type__ == 'Dataset') { + var dataset = model; + var rawDataset = this._datasetAsData; + dataset.set(rawDataset.metadata); + dataset.set({ + headers: rawDataset.data.headers + }); + dataset.docCount = rawDataset.data.rows.length; + dfd.resolve(dataset); + } + return dfd.promise(); + } else if (method === 'update') { + var dfd = $.Deferred(); + if (model.__type__ == 'Document') { + _.each(this._datasetAsData.data.rows, function(row, idx) { + if(row.id === model.id) { + self._datasetAsData.data.rows[idx] = model.toJSON(); + } + }); + dfd.resolve(model); + } + return dfd.promise(); + } else if (method === 'delete') { + var dfd = $.Deferred(); + if (model.__type__ == 'Document') { + this._datasetAsData.data.rows = _.reject(this._datasetAsData.data.rows, function(row) { + return (row.id === model.id); + }); + dfd.resolve(model); + } + return dfd.promise(); + } else { + alert('Not supported: sync on BackendMemory with method ' + method + ' and model ' + model); + } + }, + getDocuments: function(datasetId, numRows, start) { + if (start === undefined) { + start = 0; + } + if (numRows === undefined) { + numRows = 10; + } + var dfd = $.Deferred(); + rows = this._datasetAsData.data.rows; + var results = rows.slice(start, start+numRows); + dfd.resolve(results); + return dfd.promise(); + } +}); + +// Webstore Backend for connecting to the Webstore +// +// Initializing model argument must contain a url attribute pointing to +// relevant Webstore table. +// +// Designed to only attach to one dataset and one dataset only ... +// Could generalize to support attaching to different datasets +my.BackendWebstore = Backbone.Model.extend({ + getDataset: function(id) { + var dataset = new my.Dataset({ + id: id + }); + dataset.backend = this; + return dataset; + }, + sync: function(method, model, options) { + if (method === "read") { + // this switching on object type is rather horrible + // think may make more sense to do work in individual objects rather than in central Backbone.sync + if (this.__type__ == 'Dataset') { + var dataset = this; + // get the schema and return + var base = this.backend.get('url'); + var schemaUrl = base + '/schema.json'; + var jqxhr = $.ajax({ + url: schemaUrl, + dataType: 'jsonp', + jsonp: '_callback' + }); + var dfd = $.Deferred(); + jqxhr.then(function(schema) { + headers = _.map(schema.data, function(item) { + return item.name; + }); + dataset.set({ + headers: headers + }); + dataset.docCount = schema.count; + dfd.resolve(dataset, jqxhr); + }); + return dfd.promise(); + } + } + }, + getDocuments: function(datasetId, numRows, start) { + if (start === undefined) { + start = 0; + } + if (numRows === undefined) { + numRows = 10; + } + var base = this.get('url'); + var jqxhr = $.ajax({ + url: base + '.json?_limit=' + numRows, + dataType: 'jsonp', + jsonp: '_callback', + cache: true + }); + var dfd = $.Deferred(); + jqxhr.then(function(results) { + dfd.resolve(results.data); + }); + return dfd.promise(); + } +}); + +// DataProxy Backend for connecting to the DataProxy +// +// Example initialization: +// +// BackendDataProxy({ +// model: { +// url: {url-of-data-to-proxy}, +// type: xls || csv, +// format: json || jsonp # return format (defaults to jsonp) +// dataproxy: {url-to-proxy} # defaults to http://jsonpdataproxy.appspot.com +// } +// }) +my.BackendDataProxy = Backbone.Model.extend({ + defaults: { + dataproxy: 'http://jsonpdataproxy.appspot.com' + , type: 'csv' + , format: 'jsonp' + }, + getDataset: function(id) { + var dataset = new my.Dataset({ + id: id + }); + dataset.backend = this; + return dataset; + }, + sync: function(method, model, options) { + if (method === "read") { + // this switching on object type is rather horrible + // think may make more sense to do work in individual objects rather than in central Backbone.sync + if (this.__type__ == 'Dataset') { + var dataset = this; + // get the schema and return + var base = this.backend.get('dataproxy'); + var data = this.backend.toJSON(); + delete data['dataproxy']; + // TODO: should we cache for extra efficiency + data['max-results'] = 1; + var jqxhr = $.ajax({ + url: base + , data: data + , dataType: 'jsonp' + }); + var dfd = $.Deferred(); + jqxhr.then(function(results) { + dataset.set({ + headers: results.fields + }); + dfd.resolve(dataset, jqxhr); + }); + return dfd.promise(); + } + } else { + alert('This backend only supports read operations'); + } + }, + getDocuments: function(datasetId, numRows, start) { + if (start === undefined) { + start = 0; + } + if (numRows === undefined) { + numRows = 10; + } + var base = this.get('dataproxy'); + var data = this.toJSON(); + delete data['dataproxy']; + data['max-results'] = numRows; + var jqxhr = $.ajax({ + url: base + , data: data + , dataType: 'jsonp' + // , cache: true + }); + var dfd = $.Deferred(); + jqxhr.then(function(results) { + var _out = _.map(results.data, function(row) { + var tmp = {}; + _.each(results.fields, function(key, idx) { + tmp[key] = row[idx]; + }); + return tmp; + }); + dfd.resolve(_out); + }); + return dfd.promise(); + } +}); + +return my; + +}(jQuery); + +var util = function() { + var templates = { + transformActions: '
  • Global transform...
  • ' + , columnActions: ' \ +
  • Transform...
  • \ +
  • Delete this column
  • \ + ' + , rowActions: '
  • Delete this row
  • ' + , cellEditor: ' \ + \ + ' + , editPreview: ' \ +
    \ + \ + \ + \ + \ + \ + \ + \ + \ + {{#rows}} \ + \ + \ + \ + \ + {{/rows}} \ + \ +
    \ + before \ + \ + after \ +
    \ + {{before}} \ + \ + {{after}} \ +
    \ +
    \ + ' + }; + + $.fn.serializeObject = function() { + var o = {}; + var a = this.serializeArray(); + $.each(a, function() { + if (o[this.name]) { + if (!o[this.name].push) { + o[this.name] = [o[this.name]]; + } + o[this.name].push(this.value || ''); + } else { + o[this.name] = this.value || ''; + } + }); + return o; + }; + + function inURL(url, str) { + var exists = false; + if ( url.indexOf( str ) > -1 ) { + exists = true; + } + return exists; + } + + function registerEmitter() { + var Emitter = function(obj) { + this.emit = function(obj, channel) { + if (!channel) var channel = 'data'; + this.trigger(channel, obj); + }; + }; + MicroEvent.mixin(Emitter); + return new Emitter(); + } + + function listenFor(keys) { + var shortcuts = { // from jquery.hotkeys.js + 8: "backspace", 9: "tab", 13: "return", 16: "shift", 17: "ctrl", 18: "alt", 19: "pause", + 20: "capslock", 27: "esc", 32: "space", 33: "pageup", 34: "pagedown", 35: "end", 36: "home", + 37: "left", 38: "up", 39: "right", 40: "down", 45: "insert", 46: "del", + 96: "0", 97: "1", 98: "2", 99: "3", 100: "4", 101: "5", 102: "6", 103: "7", + 104: "8", 105: "9", 106: "*", 107: "+", 109: "-", 110: ".", 111 : "/", + 112: "f1", 113: "f2", 114: "f3", 115: "f4", 116: "f5", 117: "f6", 118: "f7", 119: "f8", + 120: "f9", 121: "f10", 122: "f11", 123: "f12", 144: "numlock", 145: "scroll", 191: "/", 224: "meta" + } + window.addEventListener("keyup", function(e) { + var pressed = shortcuts[e.keyCode]; + if(_.include(keys, pressed)) app.emitter.emit("keyup", pressed); + }, false); + } + + function observeExit(elem, callback) { + var cancelButton = elem.find('.cancelButton'); + // TODO: remove (commented out as part of Backbon-i-fication + // app.emitter.on('esc', function() { + // cancelButton.click(); + // app.emitter.clear('esc'); + // }); + cancelButton.click(callback); + } + + function show( thing ) { + $('.' + thing ).show(); + $('.' + thing + '-overlay').show(); + } + + function hide( thing ) { + $('.' + thing ).hide(); + $('.' + thing + '-overlay').hide(); + // TODO: remove or replace (commented out as part of Backbon-i-fication + // if (thing === "dialog") app.emitter.clear('esc'); // todo more elegant solution + } + + function position( thing, elem, offset ) { + var position = $(elem.target).position(); + if (offset) { + if (offset.top) position.top += offset.top; + if (offset.left) position.left += offset.left; + } + $('.' + thing + '-overlay').show().click(function(e) { + $(e.target).hide(); + $('.' + thing).hide(); + }); + $('.' + thing).show().css({top: position.top + $(elem.target).height(), left: position.left}); + } + + function render( template, target, options ) { + if ( !options ) options = {data: {}}; + if ( !options.data ) options = {data: options}; + var html = $.mustache( templates[template], options.data ); + if (target instanceof jQuery) { + var targetDom = target; + } else { + var targetDom = $( "." + target + ":first" ); + } + if( options.append ) { + targetDom.append( html ); + } else { + targetDom.html( html ); + } + // TODO: remove (commented out as part of Backbon-i-fication + // if (template in app.after) app.after[template](); + } + + function notify(message, options) { + if (!options) var options = {}; + var tmplData = _.extend({ + msg: message, + category: 'warning' + }, + options); + var _template = ' \ +
    × \ +

    {{msg}} \ + {{#loader}} \ + \ + {{/loader}} \ +

    \ +
    '; + var _templated = $.mustache(_template, tmplData); + _templated = $(_templated).appendTo($('.data-explorer .alert-messages')); + if (!options.persist) { + setTimeout(function() { + $(_templated).remove(); + }, 3000); + } + } + + function formatMetadata(data) { + out = '
    '; + $.each(data, function(key, val) { + if (typeof(val) == 'string' && key[0] != '_') { + out = out + '
    ' + key + '
    ' + val; + } else if (typeof(val) == 'object' && key != "geometry" && val != null) { + if (key == 'properties') { + $.each(val, function(attr, value){ + out = out + '
    ' + attr + '
    ' + value; + }) + } else { + out = out + '
    ' + key + '
    ' + val.join(', '); + } + } + }); + out = out + '
    '; + return out; + } + + function getBaseURL(url) { + var baseURL = ""; + if ( inURL(url, '_design') ) { + if (inURL(url, '_rewrite')) { + var path = url.split("#")[0]; + if (path[path.length - 1] === "/") { + baseURL = ""; + } else { + baseURL = '_rewrite/'; + } + } else { + baseURL = '_rewrite/'; + } + } + return baseURL; + } + + var persist = { + restore: function() { + $('.persist').each(function(i, el) { + var inputId = $(el).attr('id'); + if(localStorage.getItem(inputId)) $('#' + inputId).val(localStorage.getItem(inputId)); + }) + }, + save: function(id) { + localStorage.setItem(id, $('#' + id).val()); + }, + clear: function() { + $('.persist').each(function(i, el) { + localStorage.removeItem($(el).attr('id')); + }) + } + } + + // simple debounce adapted from underscore.js + function delay(func, wait) { + return function() { + var context = this, args = arguments; + var throttler = function() { + delete app.timeout; + func.apply(context, args); + }; + if (!app.timeout) app.timeout = setTimeout(throttler, wait); + }; + }; + + function resetForm(form) { + $(':input', form) + .not(':button, :submit, :reset, :hidden') + .val('') + .removeAttr('checked') + .removeAttr('selected'); + } + + function largestWidth(selector, min) { + var min_width = min || 0; + $(selector).each(function(i, n){ + var this_width = $(n).width(); + if (this_width > min_width) { + min_width = this_width; + } + }); + return min_width; + } + + function getType(obj) { + if (obj === null) { + return 'null'; + } + if (typeof obj === 'object') { + if (obj.constructor.toString().indexOf("Array") !== -1) { + return 'array'; + } else { + return 'object'; + } + } else { + return typeof obj; + } + } + + function lookupPath(path) { + var docs = app.apiDocs; + try { + _.each(path, function(node) { + docs = docs[node]; + }) + } catch(e) { + util.notify("Error selecting documents" + e); + docs = []; + } + return docs; + } + + function nodePath(docField) { + if (docField.children('.object-key').length > 0) return docField.children('.object-key').text(); + if (docField.children('.array-key').length > 0) return docField.children('.array-key').text(); + if (docField.children('.doc-key').length > 0) return docField.children('.doc-key').text(); + return ""; + } + + function selectedTreePath() { + var nodes = [] + , parent = $('.chosen'); + while (parent.length > 0) { + nodes.push(nodePath(parent)); + parent = parent.parents('.doc-field:first'); + } + return _.compact(nodes).reverse(); + } + + // TODO refactor handlers so that they dont stack up as the tree gets bigger + function handleTreeClick(e) { + var clicked = $(e.target); + if(clicked.hasClass('expand')) return; + if (clicked.children('.array').length > 0) { + var field = clicked; + } else if (clicked.siblings('.array').length > 0) { + var field = clicked.parents('.doc-field:first'); + } else { + var field = clicked.parents('.array').parents('.doc-field:first'); + } + $('.chosen').removeClass('chosen'); + field.addClass('chosen'); + return false; + } + + var createTreeNode = { + "string": function (obj, key) { + var val = $('
    '); + if (obj[key].length > 45) { + val.append($('') + .text(obj[key].slice(0, 45))) + .append( + $('...') + .click(function () { + val.html('') + .append($('') + .text(obj[key].length ? obj[key] : " ") + ) + }) + ) + } + else { + var val = $('
    '); + val.append( + $('') + .text(obj[key].length ? obj[key] : " ") + ) + } + return val; + } + , "number": function (obj, key) { + var val = $('
    ') + val.append($('' + obj[key] + '')) + return val; + } + , "null": function (obj, key) { + var val = $('
    ') + val.append($('' + obj[key] + '')) + return val; + } + , "boolean": function (obj, key) { + var val = $('
    ') + val.append($('' + obj[key] + '')) + return val; + } + , "array": function (obj, key, indent) { + if (!indent) indent = 1; + var val = $('
    ') + $('[...]') + .click(function (e) { + var n = $(this).parent(); + var cls = 'sub-'+key+'-'+indent + n.html('') + n.append('[') + for (i in obj[key]) { + var field = $('
    ').click(handleTreeClick); + n.append( + field + .append('
    '+i+'
    ') + .append(createTreeNode[getType(obj[key][i])](obj[key], i, indent + 1)) + ) + } + n.append(']') + $('div.'+cls).width(largestWidth('div.'+cls)) + }) + .appendTo($('
    ').appendTo(val)) + return val; + } + , "object": function (obj, key, indent) { + if (!indent) indent = 1; + var val = $('
    ') + $('{...}') + .click(function (e) { + var n = $(this).parent(); + n.html('') + n.append('{') + for (i in obj[key]) { + var field = $('
    ').click(handleTreeClick); + var p = $('
    '); + var di = $('
    '+i+'
    ') + field.append(p) + .append(di) + .append(createTreeNode[getType(obj[key][i])](obj[key], i, indent + 1)) + n.append(field) + } + + n.append('}') + di.width(largestWidth('div.object-key')) + }) + .appendTo($('
    ').appendTo(val)) + return val; + } + } + + function renderTree(doc) { + var d = $('div#document-editor'); + for (i in doc) { + var field = $('
    ').click(handleTreeClick); + $('
    ').appendTo(field); + field.append('
    '+i+'
    ') + field.append(createTreeNode[getType(doc[i])](doc, i)); + d.append(field); + } + + $('div.doc-key-base').width(largestWidth('div.doc-key-base')) + } + + + return { + inURL: inURL, + registerEmitter: registerEmitter, + listenFor: listenFor, + show: show, + hide: hide, + position: position, + render: render, + notify: notify, + observeExit: observeExit, + formatMetadata:formatMetadata, + getBaseURL:getBaseURL, + resetForm: resetForm, + delay: delay, + persist: persist, + lookupPath: lookupPath, + selectedTreePath: selectedTreePath, + renderTree: renderTree + }; +}(); +this.recline = this.recline || {}; + +// Views module following classic module pattern +recline.View = function($) { + +var my = {}; + +// Parse a URL query string (?xyz=abc...) into a dictionary. +function parseQueryString(q) { + var urlParams = {}, + e, d = function (s) { + return unescape(s.replace(/\+/g, " ")); + }, + r = /([^&=]+)=?([^&]*)/g; + + if (q && q.length && q[0] === '?') { + q = q.slice(1); + } + while (e = r.exec(q)) { + // TODO: have values be array as query string allow repetition of keys + urlParams[d(e[1])] = d(e[2]); + } + return urlParams; +} + +// The primary view for the entire application. +// +// It should be initialized with a recline.Model.Dataset object and an existing +// dom element to attach to (the existing DOM element is important for +// rendering of FlotGraph subview). +// +// To pass in configuration options use the config key in initialization hash +// e.g. +// +// var explorer = new DataExplorer({ +// config: {...} +// }) +// +// Config options: +// +// * displayCount: how many documents to display initially (default: 10) +// * readOnly: true/false (default: false) value indicating whether to +// operate in read-only mode (hiding all editing options). +// +// All other views as contained in this one. +my.DataExplorer = Backbone.View.extend({ + template: ' \ +
    \ +
    \ + \ +
    \ + \ + \ +
    \ +
    \ + \ + \ +
    \ + ', + + events: { + 'submit form.display-count': 'onDisplayCountUpdate' + }, + + initialize: function(options) { + var self = this; + this.el = $(this.el); + this.config = _.extend({ + displayCount: 10 + , readOnly: false + }, + options.config); + if (this.config.readOnly) { + this.setReadOnly(); + } + // Hash of 'page' views (i.e. those for whole page) keyed by page name + this.pageViews = { + grid: new my.DataTable({ + model: this.model + }) + , graph: new my.FlotGraph({ + model: this.model + }) + }; + // this must be called after pageViews are created + this.render(); + + this.router = new Backbone.Router(); + this.setupRouting(); + + // retrieve basic data like headers etc + // note this.model and dataset returned are the same + this.model.fetch().then(function(dataset) { + self.el.find('.doc-count').text(self.model.docCount || 'Unknown'); + // initialize of dataTable calls render + self.model.getDocuments(self.config.displayCount); + }); + }, + + onDisplayCountUpdate: function(e) { + e.preventDefault(); + this.config.displayCount = parseInt(this.el.find('input[name="displayCount"]').val()); + this.model.getDocuments(this.config.displayCount); + }, + + setReadOnly: function() { + this.el.addClass('read-only'); + }, + + render: function() { + var tmplData = this.model.toTemplateJSON(); + tmplData.displayCount = this.config.displayCount; + var template = $.mustache(this.template, tmplData); + $(this.el).html(template); + var $dataViewContainer = this.el.find('.data-view-container'); + _.each(this.pageViews, function(view, pageName) { + $dataViewContainer.append(view.el) + }); + }, + + setupRouting: function() { + var self = this; + this.router.route('', 'grid', function() { + self.updateNav('grid'); + }); + this.router.route(/grid(\?.*)?/, 'view', function(queryString) { + self.updateNav('grid', queryString); + }); + this.router.route(/graph(\?.*)?/, 'graph', function(queryString) { + self.updateNav('graph', queryString); + // we have to call here due to fact plot may not have been able to draw + // if it was hidden until now - see comments in FlotGraph.redraw + qsParsed = parseQueryString(queryString); + if ('graph' in qsParsed) { + var chartConfig = JSON.parse(qsParsed['graph']); + _.extend(self.pageViews['graph'].chartConfig, chartConfig); + } + self.pageViews['graph'].redraw(); + }); + }, + + updateNav: function(pageName, queryString) { + this.el.find('.navigation li').removeClass('active'); + var $el = this.el.find('.navigation li a[href=#' + pageName + ']'); + $el.parent().addClass('active'); + // show the specific page + _.each(this.pageViews, function(view, pageViewName) { + if (pageViewName === pageName) { + view.el.show(); + } else { + view.el.hide(); + } + }); + } +}); + +// DataTable provides a tabular view on a Dataset. +// +// Initialize it with a recline.Dataset object. +my.DataTable = Backbone.View.extend({ + tagName: "div", + className: "data-table-container", + + initialize: function() { + var self = this; + this.el = $(this.el); + _.bindAll(this, 'render'); + this.model.currentDocuments.bind('add', this.render); + this.model.currentDocuments.bind('reset', this.render); + this.model.currentDocuments.bind('remove', this.render); + this.state = {}; + }, + + events: { + 'click .column-header-menu': 'onColumnHeaderClick' + , 'click .row-header-menu': 'onRowHeaderClick' + , 'click .data-table-menu li a': 'onMenuClick' + }, + + // TODO: delete or re-enable (currently this code is not used from anywhere except deprecated or disabled methods (see above)). + // showDialog: function(template, data) { + // if (!data) data = {}; + // util.show('dialog'); + // util.render(template, 'dialog-content', data); + // util.observeExit($('.dialog-content'), function() { + // util.hide('dialog'); + // }) + // $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); + // }, + + + // ====================================================== + // Column and row menus + + onColumnHeaderClick: function(e) { + this.state.currentColumn = $(e.target).siblings().text(); + util.position('data-table-menu', e); + util.render('columnActions', 'data-table-menu'); + }, + + onRowHeaderClick: function(e) { + this.state.currentRow = $(e.target).parents('tr:first').attr('data-id'); + util.position('data-table-menu', e); + util.render('rowActions', 'data-table-menu'); + }, + + onMenuClick: function(e) { + var self = this; + e.preventDefault(); + var actions = { + bulkEdit: function() { self.showTransformColumnDialog('bulkEdit', {name: self.state.currentColumn}) }, + transform: function() { self.showTransformDialog('transform') }, + // TODO: Delete or re-implement ... + csv: function() { window.location.href = app.csvUrl }, + json: function() { window.location.href = "_rewrite/api/json" }, + urlImport: function() { showDialog('urlImport') }, + pasteImport: function() { showDialog('pasteImport') }, + uploadImport: function() { showDialog('uploadImport') }, + // END TODO + deleteColumn: function() { + var msg = "Are you sure? This will delete '" + self.state.currentColumn + "' from all documents."; + // TODO: + alert('This function needs to be re-implemented'); + return; + if (confirm(msg)) costco.deleteColumn(self.state.currentColumn); + }, + deleteRow: function() { + var doc = _.find(self.model.currentDocuments.models, function(doc) { + // important this is == as the currentRow will be string (as comes + // from DOM) while id may be int + return doc.id == self.state.currentRow + }); + doc.destroy().then(function() { + self.model.currentDocuments.remove(doc); + util.notify("Row deleted successfully"); + }) + .fail(function(err) { + util.notify("Errorz! " + err) + }) + } + } + util.hide('data-table-menu'); + actions[$(e.target).attr('data-action')](); + }, + + showTransformColumnDialog: function() { + var $el = $('.dialog-content'); + util.show('dialog'); + var view = new my.ColumnTransform({ + model: this.model + }); + view.state = this.state; + view.render(); + $el.empty(); + $el.append(view.el); + util.observeExit($el, function() { + util.hide('dialog'); + }) + $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); + }, + + showTransformDialog: function() { + var $el = $('.dialog-content'); + util.show('dialog'); + var view = new my.DataTransform({ + }); + view.render(); + $el.empty(); + $el.append(view.el); + util.observeExit($el, function() { + util.hide('dialog'); + }) + $('.dialog').draggable({ handle: '.dialog-header', cursor: 'move' }); + }, + + + // ====================================================== + // Core Templating + template: ' \ + \ +
      \ + \ + \ + \ + {{#notEmpty}}{{/notEmpty}} \ + {{#headers}} \ + \ + {{/headers}} \ + \ + \ + \ +
      \ +
      \ + \ + {{.}} \ +
      \ + \ +
      \ + ', + + toTemplateJSON: function() { + var modelData = this.model.toJSON() + modelData.notEmpty = ( modelData.headers.length > 0 ) + return modelData; + }, + render: function() { + var self = this; + var htmls = $.mustache(this.template, this.toTemplateJSON()); + this.el.html(htmls); + this.model.currentDocuments.forEach(function(doc) { + var tr = $(''); + self.el.find('tbody').append(tr); + var newView = new my.DataTableRow({ + model: doc, + el: tr, + headers: self.model.get('headers') + }); + newView.render(); + }); + return this; + } +}); + +// DataTableRow View for rendering an individual document. +// +// Since we want this to update in place it is up to creator to provider the element to attach to. +// In addition you must pass in a headers in the constructor options. This should be list of headers for the DataTable. +my.DataTableRow = Backbone.View.extend({ + initialize: function(options) { + _.bindAll(this, 'render'); + this._headers = options.headers; + this.el = $(this.el); + this.model.bind('change', this.render); + }, + template: ' \ + \ + {{#cells}} \ + \ +
      \ +   \ +
      {{value}}
      \ +
      \ + \ + {{/cells}} \ + ', + events: { + 'click .data-table-cell-edit': 'onEditClick', + // cell editor + 'click .data-table-cell-editor .okButton': 'onEditorOK', + 'click .data-table-cell-editor .cancelButton': 'onEditorCancel' + }, + + toTemplateJSON: function() { + var doc = this.model; + var cellData = _.map(this._headers, function(header) { + return {header: header, value: doc.get(header)} + }) + return { id: this.id, cells: cellData } + }, + + render: function() { + this.el.attr('data-id', this.model.id); + var html = $.mustache(this.template, this.toTemplateJSON()); + $(this.el).html(html); + return this; + }, + + // ====================================================== + // Cell Editor + + onEditClick: function(e) { + var editing = this.el.find('.data-table-cell-editor-editor'); + if (editing.length > 0) { + editing.parents('.data-table-cell-value').html(editing.text()).siblings('.data-table-cell-edit').removeClass("hidden"); + } + $(e.target).addClass("hidden"); + var cell = $(e.target).siblings('.data-table-cell-value'); + cell.data("previousContents", cell.text()); + util.render('cellEditor', cell, {value: cell.text()}); + }, + + onEditorOK: function(e) { + var cell = $(e.target); + var rowId = cell.parents('tr').attr('data-id'); + var header = cell.parents('td').attr('data-header'); + var newValue = cell.parents('.data-table-cell-editor').find('.data-table-cell-editor-editor').val(); + var newData = {}; + newData[header] = newValue; + this.model.set(newData); + util.notify("Updating row...", {loader: true}); + this.model.save().then(function(response) { + util.notify("Row updated successfully", {category: 'success'}); + }) + .fail(function() { + util.notify('Error saving row', { + category: 'error', + persist: true + }); + }); + }, + + onEditorCancel: function(e) { + var cell = $(e.target).parents('.data-table-cell-value'); + cell.html(cell.data('previousContents')).siblings('.data-table-cell-edit').removeClass("hidden"); + } +}); + + +// View (Dialog) for doing data transformations (on columns of data). +my.ColumnTransform = Backbone.View.extend({ + className: 'transform-column-view', + template: ' \ +
      \ + Functional transform on column {{name}} \ +
      \ +
      \ +
      \ + \ + \ + \ + \ + \ + \ +
      \ +
      \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ +
      \ + Expression \ +
      \ +
      \ + \ +
      \ +
      \ + No syntax error. \ +
      \ +
      \ + Preview \ +
      \ +
      \ +
      \ +
      \ +
      \ +
      \ +
      \ +
      \ +
      \ +
      \ + \ + ', + + events: { + 'click .okButton': 'onSubmit' + , 'keydown .expression-preview-code': 'onEditorKeydown' + }, + + initialize: function() { + this.el = $(this.el); + }, + + render: function() { + var htmls = $.mustache(this.template, + {name: this.state.currentColumn} + ) + this.el.html(htmls); + // Put in the basic (identity) transform script + // TODO: put this into the template? + var editor = this.el.find('.expression-preview-code'); + editor.val("function(doc) {\n doc['"+ this.state.currentColumn+"'] = doc['"+ this.state.currentColumn+"'];\n return doc;\n}"); + editor.focus().get(0).setSelectionRange(18, 18); + editor.keydown(); + }, + + onSubmit: function(e) { + var self = this; + var funcText = this.el.find('.expression-preview-code').val(); + var editFunc = costco.evalFunction(funcText); + if (editFunc.errorMessage) { + util.notify("Error with function! " + editFunc.errorMessage); + return; + } + util.hide('dialog'); + util.notify("Updating all visible docs. This could take a while...", {persist: true, loader: true}); + var docs = self.model.currentDocuments.map(function(doc) { + return doc.toJSON(); + }); + // TODO: notify about failed docs? + var toUpdate = costco.mapDocs(docs, editFunc).edited; + var totalToUpdate = toUpdate.length; + function onCompletedUpdate() { + totalToUpdate += -1; + if (totalToUpdate === 0) { + util.notify(toUpdate.length + " documents updated successfully"); + alert('WARNING: We have only updated the docs in this view. (Updating of all docs not yet implemented!)'); + self.remove(); + } + } + // TODO: Very inefficient as we search through all docs every time! + _.each(toUpdate, function(editedDoc) { + var realDoc = self.model.currentDocuments.get(editedDoc.id); + realDoc.set(editedDoc); + realDoc.save().then(onCompletedUpdate).fail(onCompletedUpdate) + }); + }, + + onEditorKeydown: function(e) { + var self = this; + // if you don't setTimeout it won't grab the latest character if you call e.target.value + window.setTimeout( function() { + var errors = self.el.find('.expression-preview-parsing-status'); + var editFunc = costco.evalFunction(e.target.value); + if (!editFunc.errorMessage) { + errors.text('No syntax error.'); + var docs = self.model.currentDocuments.map(function(doc) { + return doc.toJSON(); + }); + var previewData = costco.previewTransform(docs, editFunc, self.state.currentColumn); + util.render('editPreview', 'expression-preview-container', {rows: previewData}); + } else { + errors.text(editFunc.errorMessage); + } + }, 1, true); + } +}); + +// View (Dialog) for doing data transformations on whole dataset. +my.DataTransform = Backbone.View.extend({ + className: 'transform-view', + template: ' \ +
      \ + Recursive transform on all rows \ +
      \ +
      \ +
      \ +

      Traverse and transform objects by visiting every node on a recursive walk using js-traverse.

      \ + \ + \ + \ + \ + \ + \ +
      \ +
      \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ +
      \ + Expression \ +
      \ +
      \ + \ +
      \ +
      \ + No syntax error. \ +
      \ +
      \ + Preview \ +
      \ +
      \ +
      \ +
      \ +
      \ +
      \ +
      \ +
      \ +
      \ +
      \ + \ + ', + + initialize: function() { + this.el = $(this.el); + }, + + render: function() { + this.el.html(this.template); + } +}); + + +// Graph view for a Dataset using Flot graphing library. +// +// Initialization arguments: +// +// * model: recline.Model.Dataset +// * config: (optional) graph configuration hash of form: +// +// { +// group: {column name for x-axis}, +// series: [{column name for series A}, {column name series B}, ... ], +// graphType: 'line' +// } +// +// NB: should *not* provide an el argument to the view but must let the view +// generate the element itself (you can then append view.el to the DOM. +my.FlotGraph = Backbone.View.extend({ + + tagName: "div", + className: "data-graph-container", + + template: ' \ +
      \ +
      \ +

      Help »

      \ +

      To create a chart select a column (group) to use as the x-axis \ + then another column (Series A) to plot against it.

      \ +

      You can add add \ + additional series by clicking the "Add series" button

      \ +
      \ +
      \ +
      \ + \ +
      \ + \ +
      \ + \ +
      \ + \ +
      \ +
      \ +
      \ + \ +
      \ + \ +
      \ +
      \ +
      \ +
      \ +
      \ + \ +
      \ + \ +
      \ +
      \ +
      \ +
      \ +', + + events: { + 'change form select': 'onEditorSubmit' + , 'click .editor-add': 'addSeries' + , 'click .action-remove-series': 'removeSeries' + , 'click .action-toggle-help': 'toggleHelp' + }, + + initialize: function(options, config) { + var self = this; + this.el = $(this.el); + _.bindAll(this, 'render', 'redraw'); + // we need the model.headers to render properly + this.model.bind('change', this.render); + this.model.currentDocuments.bind('add', this.redraw); + this.model.currentDocuments.bind('reset', this.redraw); + this.chartConfig = _.extend({ + group: null, + series: [], + graphType: 'line' + }, + config) + this.render(); + }, + + toTemplateJSON: function() { + return this.model.toJSON(); + }, + + render: function() { + htmls = $.mustache(this.template, this.toTemplateJSON()); + $(this.el).html(htmls); + // now set a load of stuff up + this.$graph = this.el.find('.panel.graph'); + // for use later when adding additional series + // could be simpler just to have a common template! + this.$seriesClone = this.el.find('.editor-series').clone(); + this._updateSeries(); + return this; + }, + + onEditorSubmit: function(e) { + var select = this.el.find('.editor-group select'); + this._getEditorData(); + // update navigation + // TODO: make this less invasive (e.g. preserve other keys in query string) + window.location.hash = window.location.hash.split('?')[0] + + '?graph=' + JSON.stringify(this.chartConfig); + this.redraw(); + }, + + redraw: function() { + // There appear to be issues generating a Flot graph if either: + + // * The relevant div that graph attaches to his hidden at the moment of creating the plot -- Flot will complain with + // + // Uncaught Invalid dimensions for plot, width = 0, height = 0 + // * There is no data for the plot -- either same error or may have issues later with errors like 'non-existent node-value' + var areWeVisible = !jQuery.expr.filters.hidden(this.el[0]); + if (!this.plot && (!areWeVisible || this.model.currentDocuments.length == 0)) { + return + } + // create this.plot and cache it + if (!this.plot) { + // only lines for the present + options = { + id: 'line', + name: 'Line Chart' + }; + this.plot = $.plot(this.$graph, this.createSeries(), options); + } + this.plot.setData(this.createSeries()); + this.plot.resize(); + this.plot.setupGrid(); + this.plot.draw(); + }, + + _getEditorData: function() { + $editor = this + var series = this.$series.map(function () { + return $(this).val(); + }); + this.chartConfig.series = $.makeArray(series) + this.chartConfig.group = this.el.find('.editor-group select').val(); + }, + + createSeries: function () { + var self = this; + var series = []; + if (this.chartConfig) { + $.each(this.chartConfig.series, function (seriesIndex, field) { + var points = []; + $.each(self.model.currentDocuments.models, function (index, doc) { + var x = doc.get(self.chartConfig.group); + var y = doc.get(field); + if (typeof x === 'string') { + x = index; + } + points.push([x, y]); + }); + series.push({data: points, label: field}); + }); + } + return series; + }, + + // Public: Adds a new empty series select box to the editor. + // + // All but the first select box will have a remove button that allows them + // to be removed. + // + // Returns itself. + addSeries: function (e) { + e.preventDefault(); + var element = this.$seriesClone.clone(), + label = element.find('label'), + index = this.$series.length; + + this.el.find('.editor-series-group').append(element); + this._updateSeries(); + label.append(' [Remove]'); + label.find('span').text(String.fromCharCode(this.$series.length + 64)); + return this; + }, + + // Public: Removes a series list item from the editor. + // + // Also updates the labels of the remaining series elements. + removeSeries: function (e) { + e.preventDefault(); + var $el = $(e.target); + $el.parent().parent().remove(); + this._updateSeries(); + this.$series.each(function (index) { + if (index > 0) { + var labelSpan = $(this).prev().find('span'); + labelSpan.text(String.fromCharCode(index + 65)); + } + }); + this.onEditorSubmit(); + }, + + toggleHelp: function() { + this.el.find('.editor-info').toggleClass('editor-hide-info'); + }, + + // Private: Resets the series property to reference the select elements. + // + // Returns itself. + _updateSeries: function () { + this.$series = this.el.find('.editor-series select'); + } +}); + +return my; + +}(jQuery); + diff --git a/ckan/templates/_util.html b/ckan/templates/_util.html index 7107b8a06a2..2d517727200 100644 --- a/ckan/templates/_util.html +++ b/ckan/templates/_util.html @@ -183,7 +183,7 @@ ${'OPEN' if package.isopen() else 'CLOSED'} diff --git a/ckan/templates/package/facets.html b/ckan/templates/facets.html similarity index 94% rename from ckan/templates/package/facets.html rename to ckan/templates/facets.html index 4d5fcaf469d..0ffe47bbd91 100644 --- a/ckan/templates/package/facets.html +++ b/ckan/templates/facets.html @@ -5,9 +5,9 @@ py:strip="" > - +
      -

      ${h.facet_title(code)}

      +

      ${title(code)}

      • diff --git a/ckan/templates/group/layout.html b/ckan/templates/group/layout.html index e4633f8ea3f..6775ae31b09 100644 --- a/ckan/templates/group/layout.html +++ b/ckan/templates/group/layout.html @@ -9,14 +9,28 @@
        • ${h.subnav_named_route(c, h.icon('group') + _('View'), c.group.type + '_read',controller='group', action='read', id=c.group.name)}
        • -
        • - - ${h.subnav_named_route( c,h.icon('group_edit') + _('Edit'), c.group.type + '_action', action='edit', id=c.group.name )} -
        • ${h.subnav_named_route(c, h.icon('page_white_stack') + _('History'), c.group.type + '_action', controller='group', action='history', id=c.group.name)}
        • +   |   + +
        • + + ${h.subnav_named_route( c,h.icon('group_edit') + _('Edit'), c.group.type + '_action', action='edit', id=c.group.name )} +
        • ${h.subnav_named_route(c, h.icon('lock') + _('Authorization'), c.group.type + '_action', controller='group', action='authz', id=c.group.name)}
        • + - + ${optional_head()} @@ -134,7 +134,7 @@

          About ${g.site_title}

        • ${h.link_to(_('API'), h.url_for(controller='api', action='get_api'))}
        • ${h.link_to(_('API Docs'), 'http://wiki.ckan.net/API')}
        • - Contact Us + Contact Us
        • Privacy Policy @@ -218,31 +218,32 @@

          Meta

          - - - - - - + + + + + + - - - - - + + + + + - - + + - + - - - - - - + + + diff --git a/ckan/templates/package/search.html b/ckan/templates/package/search.html index 5d084414303..dea64d3c6f9 100644 --- a/ckan/templates/package/search.html +++ b/ckan/templates/package/search.html @@ -5,7 +5,7 @@ xmlns:xi="http://www.w3.org/2001/XInclude" py:strip=""> - + Search - ${g.site_title} Search - ${g.site_title} diff --git a/ckan/templates/user/login.html b/ckan/templates/user/login.html index 2b6fe7148e2..a24b866b016 100644 --- a/ckan/templates/user/login.html +++ b/ckan/templates/user/login.html @@ -5,10 +5,10 @@ - + - + - - + +
          diff --git a/doc/api.rst b/doc/api.rst index e53985553bc..4e9edbf1883 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -199,6 +199,8 @@ Here are the methods of the Model API. +-------------------------------+--------+------------------+-------------------+ | Dataset Relationship Entity | GET | | Pkg-Relationship | +-------------------------------+--------+------------------+-------------------+ +| Dataset Relationships Register| POST | Pkg-Relationship | | ++-------------------------------+--------+------------------+-------------------+ | Dataset Relationship Entity | PUT | Pkg-Relationship | | +-------------------------------+--------+------------------+-------------------+ | Dataset\'s Revisions Entity | GET | | Pkg-Revisions | @@ -210,29 +212,82 @@ Here are the methods of the Model API. | License List | GET | | License-List | +-------------------------------+--------+------------------+-------------------+ -* POSTing data to a register resource will create a new entity. - -* PUT/POSTing data to an entity resource will update an existing entity. - -* PUT operations may instead use the HTTP POST method. - -Model Formats -````````````` - -Here are the data formats for the Model API. +In general: -.. |format-dataset-ref| replace:: Dataset-Ref +* GET to a register resource will *list* the entities of that type. -.. |format-dataset-register| replace:: [ |format-dataset-ref|, |format-dataset-ref|, |format-dataset-ref|, ... ] +* GET of an entity resource will *show* the entity's properties. -.. |format-dataset-entity| replace:: { id: Uuid, name: Name-String, title: String, version: String, url: String, resources: [ Resource, Resource, ...], author: String, author_email: String, maintainer: String, maintainer_email: String, license_id: String, tags: Tag-List, notes: String, extras: { Name-String: String, ... } } +* POST of entity data to a register resource will *create* the new entity. -.. |format-group-ref| replace:: Group-Ref +* PUT of entity data to an existing entity resource will *update* it. -.. |format-group-register| replace:: [ |format-group-ref|, |format-group-ref|, |format-group-ref|, ... ] +It is usually clear whether you are trying to create or update, so in these cases, HTTP POST and PUT methods are accepted by CKAN interchangeably. -.. |format-group-entity| replace:: { name: Name-String, title: String, description: String, datasets: Dataset-List } +Model Formats +````````````` +Here are the data formats for the Model API: + ++--------------------+------------------------------------------------------------+ +| Name | Format | ++====================+============================================================+ +| Dataset-Ref | Dataset-Name-String (API v1) OR Dataset-Id-Uuid (API v2) | ++--------------------+------------------------------------------------------------+ +| Dataset-List | [ Dataset-Ref, Dataset-Ref, Dataset-Ref, ... ] | ++--------------------+------------------------------------------------------------+ +| Dataset | { id: Uuid, name: Name-String, title: String, version: | +| | String, url: String, resources: [ Resource, Resource, ...],| +| | author: String, author_email: String, maintainer: String, | +| | maintainer_email: String, license_id: String, | +| | tags: Tag-List, notes: String, extras: { Name-String: | +| | String, ... } } | +| | See note below on additional fields upon GET of a dataset. | ++--------------------+------------------------------------------------------------+ +| Group-Ref | Group-Name-String (API v1) OR Group-Id-Uuid (API v2) | ++--------------------+------------------------------------------------------------+ +| Group-List | [ Group-Ref, Group-Ref, Group-Ref, ... ] | ++--------------------+------------------------------------------------------------+ +| Group | { name: Group-Name-String, title: String, | +| | description: String, datasets: Dataset-List } | ++--------------------+------------------------------------------------------------+ +| Tag-List | [ Name-String, Name-String, Name-String, ... ] | ++--------------------+------------------------------------------------------------+ +| Tag | { name: Name-String } | ++--------------------+------------------------------------------------------------+ +| Resource | { url: String, format: String, description: String, | +| | hash: String } | ++--------------------+------------------------------------------------------------+ +| Rating | { dataset: Name-String, rating: int } | ++--------------------+------------------------------------------------------------+ +| Pkg-Relationships | [ Pkg-Relationship, Pkg-Relationship, ... ] | ++--------------------+------------------------------------------------------------+ +| Pkg-Relationship | { subject: Dataset-Name-String, | +| | object: Dataset-Name-String, type: Relationship-Type, | +| | comment: String } | ++--------------------+------------------------------------------------------------+ +| Pkg-Revisions | [ Pkg-Revision, Pkg-Revision, Pkg-Revision, ... ] | ++--------------------+------------------------------------------------------------+ +| Pkg-Revision | { id: Uuid, message: String, author: String, | +| | timestamp: Date-Time } | ++--------------------+------------------------------------------------------------+ +|Relationship-Type | One of: 'depends_on', 'dependency_of', | +| | 'derives_from', 'has_derivation', | +| | 'child_of', 'parent_of', | +| | 'links_to', 'linked_from'. | ++--------------------+------------------------------------------------------------+ +| Revision-List | [ revision_id, revision_id, revision_id, ... ] | ++--------------------+------------------------------------------------------------+ +| Revision | { id: Uuid, message: String, author: String, | +| | timestamp: Date-Time, datasets: Dataset-List } | ++--------------------+------------------------------------------------------------+ +| License-List | [ License, License, License, ... ] | ++--------------------+------------------------------------------------------------+ +| License | { id: Name-String, title: String, is_okd_compliant: | +| | Boolean, is_osi_compliant: Boolean, tags: Tag-List, | +| | family: String, url: String, maintainer: String, | +| | date_created: Date-Time, status: String } | ++--------------------+------------------------------------------------------------+ To send request data, create the JSON-format string (encode in UTF8) put it in the request body and send it using PUT or POST. @@ -244,7 +299,25 @@ Notes: * To delete an 'extra' key-value pair, supply the key with JSON value: ``null`` - * When you read a dataset then some additional information is supplied that cannot current be adjusted throught the CKAN API. This includes info on Dataset Relationship ('relationships'), Group membership ('groups'), ratings ('ratings_average' and 'ratings_count'), full URL of the dataset in CKAN ('ckan_url') and Dataset ID ('id'). This is purely a convenience for clients, and only forms part of the Dataset on GET. + * When you read a dataset, some additional information is supplied that you cannot modify and POST back to the CKAN API. These 'read-only' fields are provided only on the Dataset GET. This is a convenience to clients, to save further requests. This applies to the following fields: + +===================== ================================ +Key Description +===================== ================================ +id Unique Uuid for the Dataset +revision_id Latest revision ID for the core Package data (but is not affected by changes to tags, groups, extras, relationships etc) +metadata_created Date the Dataset (record) was created +metadata_modified Date the Dataset (record) was last modified +relationships info on Dataset Relationships +ratings_average +ratings_count +ckan_url full URL of the Dataset +download_url (API v1) URL of the first Resource +isopen boolean indication of whether dataset is open according to Open Knowledge Definition, based on other fields +notes_rendered HTML rendered version of the Notes field (which may contain Markdown) +===================== ================================ + + Search API ~~~~~~~~~~ diff --git a/doc/common-error-messages.rst b/doc/common-error-messages.rst index e453daddca4..468bfcea488 100644 --- a/doc/common-error-messages.rst +++ b/doc/common-error-messages.rst @@ -105,12 +105,12 @@ This occurs when trying to ``import migrate.exceptions`` and is due to the versi ``ckan.plugins.core.PluginNotFoundException: stats`` ==================================================== -After the CKAN 1.5.1 release, the Stats extension was merged into the core CKAN code, and the ckanext namespace needs registering before the tests will run:: +After the CKAN 1.5.1 release, the Stats and Storage extensions were merged into the core CKAN code, and the ckanext namespace needs registering before the tests will run:: cd pyenv/src/ckan python setup.py develop -Otherwise, this problem may be to enabling an extension which is not installed. See: :doc:`extensions`_. +Otherwise, this problem may be because of specifying an extension in the CKAN config but having not installed it. See: :doc:`extensions`. ``AssertionError: There is no script for 46 version`` ===================================================== diff --git a/doc/configuration.rst b/doc/configuration.rst index 63c2d93084f..7396b4a38bc 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -493,14 +493,28 @@ solr_url Example:: - solr_url = http://solr.okfn.org:8983/solr + solr_url = http://solr.okfn.org:8983/solr/ckan-schema-1.3 + +Default value: ``http://solr.okfn.org:8983/solr`` -This configures the Solr server used for search. The SOLR schema must be one of the ones in ``ckan/config/solr`` (generally the last one). +This configures the Solr server used for search. The Solr schema found at that URL must be one of the ones in ``ckan/config/solr`` (generally the most recent one). A check of the schema version number occurs when CKAN starts. -Optionally, ``solr_user`` and ``solr_password`` can also be passed along to specify HTTP Basic authentication details for all Solr requests. +Optionally, ``solr_user`` and ``solr_password`` can also be configured to specify HTTP Basic authentication details for all Solr requests. Note, if you change this value, you need to rebuild the search index. +simple_search +^^^^^^^^^^^^^ + +Example:: + + ckan.simple_search = true + +Default value: ``false`` + +Switching this on tells CKAN search functionality to just query the database, (rather than using Solr). In this setup, search is crude and limited, e.g. no full-text search, no faceting, etc. However, this might be very useful for getting up and running quickly with CKAN. + + Site Settings ------------- @@ -593,7 +607,7 @@ plugins Example:: - ckan.plugins = disqus synchronous_search datapreview googleanalytics stats storage follower + ckan.plugins = disqus datapreview googleanalytics follower Specify which CKAN extensions are to be enabled. diff --git a/doc/file-upload.rst b/doc/file-upload.rst index f093b1f2bbc..eb294ded888 100644 --- a/doc/file-upload.rst +++ b/doc/file-upload.rst @@ -11,9 +11,10 @@ The important settings for the CKAN .ini file are ckan.storage.bucket = ckan ckan.storage.directory = /data/uploads/ +(See :doc:`configuration`) + The directory where files will be stored should exist or be created before the system is used. It is also possible to have uploaded CSV and Excel files stored in the Webstore which provides a structured data store built on a relational database backend. The configuration of this process is described at `the CKAN wiki `_. -Storing data in the webstore allows for the direct retrieval of the data in a tabular format. It is possible to fetch a single row of the data, all of the data and have it returned in HTML, CSV or JSON format. More information and the API documentation for the webstore is available at `read the docs -`_. \ No newline at end of file +Storing data in the webstore allows for the direct retrieval of the data in a tabular format. It is possible to fetch a single row of the data, all of the data and have it returned in HTML, CSV or JSON format. More information and the API documentation for the webstore is available in the `Webstore Documentation `_. \ No newline at end of file diff --git a/doc/index.rst b/doc/index.rst index 57669fece51..481947dd850 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -29,7 +29,6 @@ Contents: forms form-integration database_dumps - upgrade i18n file-upload configuration diff --git a/doc/paster.rst b/doc/paster.rst index 1281dcf7465..8d6f4f024a3 100644 --- a/doc/paster.rst +++ b/doc/paster.rst @@ -233,4 +233,4 @@ For example, to create a new user called 'admin':: To delete the 'admin' user:: - paster --plugin=ckan user delete admin --config=/etc/ckan/std/std.ini + paster --plugin=ckan user remove admin --config=/etc/ckan/std/std.ini diff --git a/doc/solr-setup.rst b/doc/solr-setup.rst index 45472cc26cf..6066771ade7 100644 --- a/doc/solr-setup.rst +++ b/doc/solr-setup.rst @@ -73,7 +73,12 @@ supported by the CKAN version you are installing (it will generally be the highe sudo mv /etc/solr/conf/schema.xml /etc/solr/conf/schema.xml.bak sudo ln -s ~/ckan/ckan/config/solr/schema-1.3.xml /etc/solr/conf/schema.xml -Restart jetty and check that Solr is still working. +Now restart jetty:: + + sudo /etc/init.d/jetty stop + sudo /etc/init.d/jetty start + +And check that Solr is running by browsing http://localhost:8983/solr/ which should offer the Administration link. .. _solr-multi-core: @@ -87,7 +92,7 @@ or different CKAN versions to use the same Solr instance. The different cores will have different paths in the Solr server URL:: http://localhost:8983/solr/ckan-schema-1.2 # Used by CKAN up to 1.5 - http://localhost:8983/solr/ckan-schema-1.3 # Used by CKAN 1.5.1 and upwards + http://localhost:8983/solr/ckan-schema-1.3 # Used by CKAN 1.5.1 http://localhost:8983/solr/some-other-site # Used by another site To set up a multicore Solr instance, repeat the steps on the previous section @@ -99,33 +104,39 @@ This is how cores are defined:: - + - + -Note that each core has its own data directory. This is really important to -prevent conflicts between cores. Create them like this:: +Adjust the names to match the CKAN schema versions you want to run. + +Note that each core is configured with its own data directory. This is really important to prevent conflicts between cores. Now create them like this:: - sudo mkdir /var/lib/solr/data/core0 - sudo mkdir /var/lib/solr/data/core1 + sudo -u jetty mkdir /var/lib/solr/data/core0 + sudo -u jetty mkdir /var/lib/solr/data/core1 -For each core, we will create a folder with its name in `/usr/share/solr`, +For each core, we will create a folder in `/usr/share/solr`, with a symbolic link to a specific configuration folder in `/etc/solr/`. Copy the existing conf directory to the core directory and link it from the home dir like this:: - sudo mkdir /etc/solr/core0 sudo mv /etc/solr/conf /etc/solr/core0/ sudo mkdir /usr/share/solr/core0 sudo ln -s /etc/solr/core0/conf /usr/share/solr/core0/conf +Now configure the core to use the data directory you have created. Edit `/etc/solr/core0/conf/solrconfig.xml` and change the `` to this variable:: + + ${dataDir} + +This will ensure the core uses the data directory specified earlier in `solr.xml`. + Once you have your first core configured, to create new ones, you just need to add them to the `solr.xml` file and copy the existing configuration dir:: @@ -135,18 +146,19 @@ add them to the `solr.xml` file and copy the existing configuration dir:: sudo mkdir /usr/share/solr/core1 sudo ln -s /etc/solr/core1/conf /usr/share/solr/core1/conf -After configuring the cores, restart Jetty and visit:: +Remember to ensure each core points to the correct CKAN schema. To change core1 to be ckan-schema-1.3:: - http://localhost:8983/solr + sudo rm sudo rm /etc/solr/core1/conf/schema.xml + sudo ln -s /schema-1.3.xml /etc/solr/core1/conf/schema.xml -You should see a list of links to the admin sites for the different Solr cores. +(where ```` is the full path to the schema file on your machine) -**Note**: You should check that the `` directive in the `solrconfig.xml` -file (located in the config dir) points to the correct location. The best thing -to do is use the `dataDir` variable that we defined in `solr.xml` to ensure -that cores are using the right data directory:: +Now restart jetty:: - ${dataDir} + sudo /etc/init.d/jetty stop + sudo /etc/init.d/jetty start + +And check that Solr is listing all the cores when browsing http://localhost:8983/solr/ Troubleshooting --------------- diff --git a/doc/theming.rst b/doc/theming.rst index bae36cd8ac7..ba38297397a 100644 --- a/doc/theming.rst +++ b/doc/theming.rst @@ -116,7 +116,7 @@ Next, copy the ``layout.html`` template and add a reference to the new CSS file. py:strip=""> ${select('*')} - +